Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/client-configs-custom-base-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Make no remote client configuration request unless `CUSTOM_API_BASE_URL` is set.
5 changes: 5 additions & 0 deletions .changeset/fewer-long-context-surveys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Show the long-context feedback survey at most once per session, and make both survey kinds share one cooldown.
5 changes: 5 additions & 0 deletions .changeset/fs-glob-segment-boundary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Glob filters on the server file endpoints now follow standard glob syntax: `**/` matches whole path segments (so `a/**/b` no longer matches `a/xxb`), and brace sets and character classes are expanded instead of matched literally.
5 changes: 5 additions & 0 deletions .changeset/fs-list-allow-ignored-globs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Add `allow_ignored_globs` to the `fs:list` server endpoint to list named gitignored paths while the rest of gitignore still applies.
5 changes: 5 additions & 0 deletions .changeset/sanitize-foreground-bash-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Strip terminal escape sequences from foreground Bash tool output so they no longer change the terminal state.
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Container, Text } from '@pymodel/pi-tui';

import { currentTheme } from '#/tui/theme';
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
import { sanitizeShellOutput } from '#/tui/utils/shell-output';

import type { ResultRenderer } from './tool-renderers/types';
import { isSpilledToolOutput, PREVIEW_LINES } from './tool-renderers/types';
Expand Down Expand Up @@ -59,8 +60,10 @@ export class ShellExecutionComponent extends Container {
expandHint: boolean,
): void {
if (!result.output) return;
// Untrusted bytes: sanitize the whole buffer, not each chunk, so escape
// sequences split across live-output chunks cannot reach the terminal.
this.addChild(
new TruncatedOutputComponent(result.output, {
new TruncatedOutputComponent(sanitizeShellOutput(result.output), {
expanded,
isError: result.is_error ?? false,
maxLines: PREVIEW_LINES,
Expand Down
37 changes: 28 additions & 9 deletions apps/pythinker-code/src/tui/controllers/survey-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ import {
getSurveyPopupConfig,
peekSurveyPopupConfig,
peekSurveyPopupConfigFresh,
resolveSurveyPopupConfig,
type SurveyPopupConfig,
type SurveyPopupPayload,
} from '#/utils/survey-popup-config';
import { readSurveyLastShownTime, writeSurveyLastShownTime } from '#/utils/survey-state-store';
import { currentPythinkerRegion } from '#/utils/region';
Expand Down Expand Up @@ -94,7 +96,7 @@ function joinModels(models: ReadonlySet<string>): string | undefined {
}

export interface SurveyControllerDeps {
readonly config?: () => SurveyPopupConfig;
readonly config?: () => SurveyPopupPayload;
readonly monotonicNow?: () => number;
readonly wallNow?: () => number;
readonly random?: () => number;
Expand Down Expand Up @@ -145,7 +147,8 @@ export class SurveyController {
private userTurnsAtLastShown: number | undefined;
private appearanceCount = 0;
private globalLastShownAt: number | undefined;
private longContextRollConsumed = false;
private readonly longContextRollConsumedModels = new Set<string | undefined>();
private longContextShownThisMount = false;
private generation = 0;
private idleSince: number | undefined;
private openedAt = 0;
Expand Down Expand Up @@ -196,7 +199,8 @@ export class SurveyController {
this.lastShownAt = undefined;
this.userTurnsAtLastShown = undefined;
this.appearanceCount = 0;
this.longContextRollConsumed = false;
this.longContextRollConsumedModels.clear();
this.longContextShownThisMount = false;
this.idleSince = undefined;
this.stickySample = undefined;
this.toolCallCount = 0;
Expand Down Expand Up @@ -444,6 +448,13 @@ export class SurveyController {
return false;
}

private currentConfig(): SurveyPopupConfig {
return resolveSurveyPopupConfig(
(this.deps.config ?? defaultDeps.config)(),
resolveKfcModelId(this.host.state.appState),
);
}

private evaluate(): void {
if (this.machine.phase !== 'closed') return;
if (!this.configReady) return;
Expand All @@ -463,9 +474,12 @@ export class SurveyController {
) {
return;
}
const config = (this.deps.config ?? defaultDeps.config)();
const verdict = evaluateSurveyGate({ ...this.gateInputs(), config });
if (verdict.longContextRollConsumed === true) this.longContextRollConsumed = true;
const config = this.currentConfig();
const inputs = this.gateInputs();
const verdict = evaluateSurveyGate({ ...inputs, config });
if (verdict.longContextRollConsumed === true) {
this.longContextRollConsumedModels.add(inputs.longContext.kfcModelId);
}
if (!verdict.show) return;
this.open(verdict.survey, config);
}
Expand Down Expand Up @@ -508,7 +522,12 @@ export class SurveyController {
...shared,
cumulativeTokens: appState.cumulativeTokens ?? 0,
virtualContextTokens: appState.contextTokens,
mountRollConsumed: this.longContextRollConsumed,
mountRollConsumed: this.longContextRollConsumedModels.has(shared.kfcModelId),
mountSurveyShown: this.longContextShownThisMount,
msSinceGlobalLastShown:
this.globalLastShownAt === undefined
? undefined
: this.wallNow() - this.globalLastShownAt,
drawMountRoll: () => (this.deps.random ?? defaultDeps.random)(),
},
};
Expand Down Expand Up @@ -564,7 +583,7 @@ export class SurveyController {
this.openedEditorText = this.host.state.editor.getText();
this.lastShownAt = shownAt;
this.userTurnsAtLastShown = this.userTurnCount;
if (survey !== 'session') return;
if (survey === 'long_context') this.longContextShownThisMount = true;
this.globalLastShownAt = this.wallNow();
try {
(this.deps.writeGlobalLastShown ?? defaultDeps.writeGlobalLastShown)(
Expand Down Expand Up @@ -598,7 +617,7 @@ export class SurveyController {
response: effect.response,
},
this.appearanceSnapshot?.fields ?? this.environmentFields(),
this.appearanceConfig ?? (this.deps.config ?? defaultDeps.config)(),
this.appearanceConfig ?? this.currentConfig(),
);
const sessionId = this.appearanceSnapshot?.sessionId ?? '';
if (sessionId.length > 0) {
Expand Down
10 changes: 10 additions & 0 deletions apps/pythinker-code/src/tui/utils/survey-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type SurveyKind = 'session' | 'long_context';

export type SurveyGateSkipReason =
| 'mount-roll-consumed'
| 'mount-survey-shown'
| 'survey-active'
| 'turn-in-progress'
| 'idle-too-short'
Expand Down Expand Up @@ -60,6 +61,8 @@ export interface LongContextArmGateInput extends SharedArmGateInput {
readonly cumulativeTokens: number;
readonly virtualContextTokens: number;
readonly mountRollConsumed: boolean;
readonly mountSurveyShown: boolean;
readonly msSinceGlobalLastShown: number | undefined;
readonly drawMountRoll: () => number;
}

Expand Down Expand Up @@ -141,6 +144,7 @@ function evaluateSessionArm(input: SurveyGateInput): SurveyGateVerdict {

export function evaluateLongContextArm(input: SurveyGateInput): SurveyGateVerdict {
const { longContext, config } = input;
if (longContext.mountSurveyShown) return { show: false, reason: 'mount-survey-shown' };
if (longContext.mountRollConsumed) return { show: false, reason: 'mount-roll-consumed' };
if (longContext.phase !== 'closed') return { show: false, reason: 'survey-active' };
if (longContext.turnInProgress) return { show: false, reason: 'turn-in-progress' };
Expand Down Expand Up @@ -179,6 +183,12 @@ export function evaluateLongContextArm(input: SurveyGateInput): SurveyGateVerdic
if (counter < config.long_context_survey_threshold) {
return { show: false, reason: 'below-threshold' };
}
if (
longContext.msSinceGlobalLastShown !== undefined &&
longContext.msSinceGlobalLastShown < config.min_time_between_global_feedback_ms
) {
return { show: false, reason: 'global-cooldown' };
}
if (longContext.drawMountRoll() >= config.long_context_probability) {
return { show: false, reason: 'sampled-out', longContextRollConsumed: true };
}
Expand Down
22 changes: 12 additions & 10 deletions apps/pythinker-code/src/utils/client-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { z } from 'zod';

import { getCacheDir } from '#/utils/paths';
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
import { currentPythinkerProfile, currentPythinkerRegion } from '#/utils/region';
import { currentPythinkerRegion } from '#/utils/region';

/**
* Generic client for the public client-configs endpoint:
* `POST {baseUrl}/client_configs {"name": "<config name>"}` returns
* `{ name, config: <payload> }`, where the payload shape is config-specific
* and validated by the caller-supplied schema.
*
* The endpoint exists only on a custom API base (`CUSTOM_API_BASE_URL`).
* Without one there is nothing to ask, so no request is made and no
* token leaves the machine.
*
* Each named config is cached for a day, in two layers: an in-process map
* (the only layer the synchronous peek can see) and a JSON file under the
* CLI cache dir (survives restarts, so the TTL holds across processes). An
Expand All @@ -25,15 +29,11 @@ const CLIENT_CONFIGS_PATH = '/client_configs';
const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 5000;

/** The endpoint's API base: the env override keeps winning (custom/internal
envs); otherwise the active region profile, so a global login's token is
not sent to the mainland-China deployment. */
function clientConfigsBaseUrl(): string {
/** The endpoint's API base, or undefined when no custom base is set. */
function clientConfigsBaseUrl(): string | undefined {
const override = process.env['CUSTOM_API_BASE_URL']?.trim();
if (override !== undefined && override.length > 0) {
return override.replace(/\/+$/, '');
}
return currentPythinkerProfile().apiBase.replace(/\/+$/, '');
if (override === undefined || override.length === 0) return undefined;
return override.replace(/\/+$/, '');
}

/** Cache entries are partitioned by region so a login switch never serves
Expand Down Expand Up @@ -165,6 +165,8 @@ export async function fetchClientConfig<S extends z.ZodType>(
schema: S,
options: ClientConfigFetchOptions = {},
): Promise<z.infer<S> | undefined> {
const baseUrl = clientConfigsBaseUrl();
if (baseUrl === undefined) return undefined;
const fetchFn = options.fetchImpl ?? fetch;
const headers: Record<string, string> = {
accept: 'application/json',
Expand All @@ -174,7 +176,7 @@ export async function fetchClientConfig<S extends z.ZodType>(
headers['authorization'] = `Bearer ${options.accessToken}`;
}
try {
const response = await fetchFn(`${clientConfigsBaseUrl()}${CLIENT_CONFIGS_PATH}`, {
const response = await fetchFn(`${baseUrl}${CLIENT_CONFIGS_PATH}`, {
method: 'POST',
headers,
body: JSON.stringify({ name }),
Expand Down
47 changes: 41 additions & 6 deletions apps/pythinker-code/src/utils/survey-popup-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ export const DEFAULT_SURVEY_POPUP_CONFIG: SurveyPopupConfig = {
long_context_trigger_mode: 'virtual_context',
};

export type SurveyPopupOverride = Partial<Omit<SurveyPopupConfig, 'on_for_models'>>;

export interface SurveyPopupPayload extends SurveyPopupConfig {
model_overrides: Record<string, SurveyPopupOverride>;
}

export const DEFAULT_SURVEY_POPUP_PAYLOAD: SurveyPopupPayload = {
...DEFAULT_SURVEY_POPUP_CONFIG,
model_overrides: {},
};

const FIELD_SCHEMAS = {
probability: z.number().min(0).max(1),
on_for_models: z.array(z.string()),
Expand All @@ -55,7 +66,19 @@ const FIELD_SCHEMAS = {
long_context_trigger_mode: z.enum(['cumulative', 'virtual_context']),
} satisfies Record<keyof SurveyPopupConfig, z.ZodType>;

const surveyPopupConfigSchema = z.unknown().transform((raw): Partial<SurveyPopupConfig> => {
const surveyPopupOverrideSchema = z.object(FIELD_SCHEMAS).omit({ on_for_models: true }).partial();

function parseModelOverrides(raw: unknown): Record<string, SurveyPopupOverride> {
if (typeof raw !== 'object' || raw === null) return {};
const overrides: Record<string, SurveyPopupOverride> = {};
for (const [model, entry] of Object.entries(raw)) {
const parsed = surveyPopupOverrideSchema.safeParse(entry);
if (parsed.success) overrides[model] = parsed.data;
}
return overrides;
}

const surveyPopupConfigSchema = z.unknown().transform((raw): Partial<SurveyPopupPayload> => {
if (typeof raw !== 'object' || raw === null) return {};
const record = raw as Record<string, unknown>;
const partial: Record<string, unknown> = {};
Expand All @@ -65,23 +88,35 @@ const surveyPopupConfigSchema = z.unknown().transform((raw): Partial<SurveyPopup
const parsed = schema.safeParse(value);
if (parsed.success) partial[key] = parsed.data;
}
return partial as Partial<SurveyPopupConfig>;
if (record['model_overrides'] !== undefined) {
partial['model_overrides'] = parseModelOverrides(record['model_overrides']);
}
return partial as Partial<SurveyPopupPayload>;
});

function withDefaults(partial: Partial<SurveyPopupConfig> | undefined): SurveyPopupConfig {
return { ...DEFAULT_SURVEY_POPUP_CONFIG, ...partial };
function withDefaults(partial: Partial<SurveyPopupPayload> | undefined): SurveyPopupPayload {
return { ...DEFAULT_SURVEY_POPUP_PAYLOAD, ...partial };
}

export async function getSurveyPopupConfig(
options: ClientConfigFetchOptions = {},
): Promise<SurveyPopupConfig> {
): Promise<SurveyPopupPayload> {
return withDefaults(await getClientConfig(CONFIG_NAME, surveyPopupConfigSchema, options));
}

export function peekSurveyPopupConfig(now?: number): SurveyPopupConfig {
export function peekSurveyPopupConfig(now?: number): SurveyPopupPayload {
return withDefaults(peekClientConfig(CONFIG_NAME, surveyPopupConfigSchema, now));
}

export function resolveSurveyPopupConfig(
payload: SurveyPopupPayload,
kfcModelId: string | undefined,
): SurveyPopupConfig {
const { model_overrides: modelOverrides, ...base } = payload;
const override = kfcModelId === undefined ? undefined : modelOverrides[kfcModelId];
return override === undefined ? base : { ...base, ...override };
}

export function peekSurveyPopupConfigFresh(now?: number): boolean {
return peekClientConfig(CONFIG_NAME, surveyPopupConfigSchema, now) !== undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ describe('ShellExecutionComponent', () => {
expect(output).toContain('step20');
});

it('strips terminal control sequences from captured output', () => {
const component = new ShellExecutionComponent({
result: {
tool_call_id: 'call_shell',
output: 'before\u001B]0;pwned\u0007\u001B[2Jafter\u001B[?1049h',
is_error: false,
},
expanded: true,
});

const output = component.render(100).map(strip).join('\n');
expect(output).toContain('beforeafter');
expect(output).not.toContain('\u001B]0;');
expect(output).not.toContain('\u001B[2J');
expect(output).not.toContain('\u001B[?1049h');
expect(output).not.toContain('\u0007');
});

it('does not count trailing empty lines toward the preview cap', () => {
const component = new ShellExecutionComponent({
result: {
Expand Down
Loading
Loading