omp-vcc 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,30 @@
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { dirname, join } from "path";
5
+ import { createRequire } from "node:module";
5
6
 
7
+ type PluginSettingsLoader = (pluginName: string, cwd: string) => Promise<Record<string, unknown>>;
8
+ let pluginSettingsLoader: PluginSettingsLoader | null | undefined;
9
+ const resolvePluginSettingsLoader = (): PluginSettingsLoader | null => {
10
+ if (pluginSettingsLoader !== undefined) return pluginSettingsLoader;
11
+ try {
12
+ const req = createRequire(import.meta.url);
13
+ for (const id of [
14
+ "@oh-my-pi/pi-coding-agent/extensibility/plugins",
15
+ "@earendil-works/pi-coding-agent/extensibility/plugins",
16
+ ]) {
17
+ try {
18
+ const mod = req(id) as { getPluginSettings?: PluginSettingsLoader };
19
+ if (typeof mod.getPluginSettings === "function") {
20
+ pluginSettingsLoader = mod.getPluginSettings;
21
+ return pluginSettingsLoader;
22
+ }
23
+ } catch {}
24
+ }
25
+ } catch {}
26
+ pluginSettingsLoader = null;
27
+ return pluginSettingsLoader;
28
+ };
6
29
  // omp-vcc: XDG-aware config path, mirrored from pi-vcc but under ~/.omp
7
30
  // Priorities: $OMP_VCC_CONFIG_PATH > $PI_VCC_CONFIG_PATH (legacy) > ~/.omp/omp-vcc/config.json
8
31
  // Also respects $PI_CODING_AGENT_DIR / $OMP_DIR if set (oh-my-pi base dir)
@@ -22,8 +45,8 @@ const fallbackReadPath = (): string | null => {
22
45
  const candidates: string[] = [];
23
46
  if (process.env.OMP_VCC_CONFIG_PATH) candidates.push(process.env.OMP_VCC_CONFIG_PATH);
24
47
  if (process.env.PI_VCC_CONFIG_PATH) candidates.push(process.env.PI_VCC_CONFIG_PATH);
25
- candidates.push(SETTINGS_PATH_DEFAULT);
26
48
  if (!candidates.includes(legacyPiPath)) candidates.push(legacyPiPath);
49
+ if (!candidates.includes(SETTINGS_PATH_DEFAULT)) candidates.push(SETTINGS_PATH_DEFAULT);
27
50
  for (const p of candidates) if (existsSync(p)) return p;
28
51
  // No candidate exists — return primary for creation path (used by scaffold)
29
52
  return null;
@@ -72,6 +95,18 @@ export interface PiVccSettings {
72
95
  * CompactionEntry).
73
96
  */
74
97
  chainShakeHint: boolean;
98
+ /** Use append-only segments with a mutable trailing summary. */
99
+ compactionSummaryMode: "rewrite" | "append";
100
+ /** Maximum provider-visible retained tool-output tokens; 0 disables projection. */
101
+ retainedToolOutputMaxTokens: number;
102
+ /** Emit a display-only notification for text dropped during compaction. */
103
+ showPreCompactionMessage: boolean;
104
+ /** Maximum model-facing recall response characters; 0 disables the cap. */
105
+ recallResponseMaxChars: number;
106
+ /** Query oh-my-pi's public native memory backend during compaction. */
107
+ nativeMemory: boolean;
108
+ /** Write bounded rotating JSONL metrics under the omp config directory. */
109
+ debugLog: boolean;
75
110
  }
76
111
 
77
112
  export const DEFAULT_SETTINGS: PiVccSettings = {
@@ -81,49 +116,204 @@ export const DEFAULT_SETTINGS: PiVccSettings = {
81
116
  continueAfterThresholdCompact: true,
82
117
  debug: false,
83
118
  chainShakeHint: false,
119
+ compactionSummaryMode: "append",
120
+ retainedToolOutputMaxTokens: 20_000,
121
+ showPreCompactionMessage: true,
122
+ recallResponseMaxChars: 48_000,
123
+ nativeMemory: true,
124
+ debugLog: false,
84
125
  };
85
126
 
86
- const readJson = (path: string): Record<string, unknown> | null => {
127
+ export type JsonReadStatus =
128
+ | { kind: "missing" }
129
+ | { kind: "valid"; value: Record<string, unknown> }
130
+ | { kind: "invalid" };
131
+
132
+ export const readJsonStatus = (path: string): JsonReadStatus => {
133
+ let raw: string;
134
+ try {
135
+ raw = readFileSync(path, "utf-8");
136
+ } catch (err) {
137
+ if ((err as { code?: string })?.code === "ENOENT") return { kind: "missing" };
138
+ return { kind: "invalid" };
139
+ }
87
140
  try {
88
- return JSON.parse(readFileSync(path, "utf-8"));
141
+ const value = JSON.parse(raw);
142
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { kind: "invalid" };
143
+ return { kind: "valid", value: value as Record<string, unknown> };
89
144
  } catch {
90
- return null;
145
+ return { kind: "invalid" };
146
+ }
147
+ };
148
+
149
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
150
+ value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
151
+
152
+ const warnedConfigPaths = new WeakMap<object, Set<string>>();
153
+ const warnedConfigPathsWithoutContext = new Set<string>();
154
+ const warnInvalidConfig = (ctx: unknown, path: string): void => {
155
+ const root = asRecord(ctx);
156
+ const owner = asRecord(root?.sessionManager) ?? (ctx && (typeof ctx === "object" || typeof ctx === "function") ? ctx as object : null);
157
+ const seen = owner ? warnedConfigPaths.get(owner) ?? new Set<string>() : warnedConfigPathsWithoutContext;
158
+ if (owner && !warnedConfigPaths.has(owner)) warnedConfigPaths.set(owner, seen);
159
+ if (seen.has(path)) return;
160
+ seen.add(path);
161
+ try {
162
+ const ui = asRecord(root?.ui);
163
+ if (typeof ui?.notify === "function") ui.notify.call(ui, `omp-vcc: config file ${path} is invalid; using defaults`, "warning");
164
+ } catch {}
165
+ };
166
+
167
+ const BOOLEAN_SETTING_KEYS: Array<keyof PiVccSettings> = [
168
+ "vccEnabled", "overrideDefaultCompaction", "smartKeepTail", "continueAfterThresholdCompact",
169
+ "debug", "chainShakeHint", "showPreCompactionMessage", "nativeMemory", "debugLog",
170
+ ];
171
+ const isValidSettingValue = (key: keyof PiVccSettings, value: unknown): boolean => {
172
+ if (BOOLEAN_SETTING_KEYS.includes(key)) return typeof value === "boolean";
173
+ if (key === "compactionSummaryMode") return value === "rewrite" || value === "append";
174
+ if (key === "retainedToolOutputMaxTokens") return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 200_000;
175
+ if (key === "recallResponseMaxChars") return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 2_000_000;
176
+ return true;
177
+ };
178
+
179
+ const normalizeSettings = (value: Record<string, unknown> | undefined): PiVccSettings => {
180
+ const merged: PiVccSettings = { ...DEFAULT_SETTINGS, ...(value ?? {}) };
181
+ for (const key of BOOLEAN_SETTING_KEYS) {
182
+ if (typeof merged[key] !== "boolean") merged[key] = DEFAULT_SETTINGS[key];
183
+ }
184
+ if (merged.compactionSummaryMode !== "rewrite" && merged.compactionSummaryMode !== "append") {
185
+ merged.compactionSummaryMode = DEFAULT_SETTINGS.compactionSummaryMode;
186
+ }
187
+ const retained = merged.retainedToolOutputMaxTokens;
188
+ if (typeof retained !== "number" || !Number.isFinite(retained) || retained < 0 || retained > 200_000) {
189
+ merged.retainedToolOutputMaxTokens = DEFAULT_SETTINGS.retainedToolOutputMaxTokens;
190
+ }
191
+ const recall = merged.recallResponseMaxChars;
192
+ if (typeof recall !== "number" || !Number.isFinite(recall) || recall < 0 || recall > 2_000_000) {
193
+ merged.recallResponseMaxChars = DEFAULT_SETTINGS.recallResponseMaxChars;
194
+ }
195
+ return merged;
196
+ };
197
+
198
+ const tryGetSetting = (ctx: unknown, key: string): unknown => {
199
+ try {
200
+ const root = asRecord(ctx);
201
+ if (!root) return undefined;
202
+ for (const containerKey of ["settings", "config"] as const) {
203
+ const container = asRecord(root[containerKey]);
204
+ if (!container) continue;
205
+ const getter = container.get;
206
+ if (typeof getter === "function") return getter.call(container, key);
207
+ if (key in container) return container[key];
208
+ }
209
+ } catch {}
210
+ return undefined;
211
+ };
212
+
213
+ const contextOverlay = (ctx: unknown): Partial<PiVccSettings> => {
214
+ const overlay: Partial<PiVccSettings> = {};
215
+ for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
216
+ const value = tryGetSetting(ctx, `plugins.@zhulinchng/omp-vcc.${key}`)
217
+ ?? tryGetSetting(ctx, `plugins.omp-vcc.${key}`)
218
+ ?? tryGetSetting(ctx, `omp-vcc.${key}`)
219
+ ?? tryGetSetting(ctx, key);
220
+ if (value !== undefined) Object.assign(overlay, { [key]: value });
221
+ }
222
+ return overlay;
223
+ };
224
+
225
+
226
+ const readFileSettings = (ctx?: unknown): { values: PiVccSettings; parsed: Record<string, unknown> | null; readPath: string | null; filePresent: boolean; fileValid: boolean } => {
227
+ const primary = settingsPath();
228
+ const primaryStatus = readJsonStatus(primary);
229
+ let parsed: Record<string, unknown> | null = null;
230
+ let readPath: string | null = null;
231
+ let filePresent = primaryStatus.kind !== "missing";
232
+ let fileValid = false;
233
+ if (primaryStatus.kind === "valid") {
234
+ parsed = primaryStatus.value;
235
+ readPath = primary;
236
+ fileValid = true;
237
+ } else if (primaryStatus.kind === "invalid") {
238
+ readPath = primary;
239
+ warnInvalidConfig(ctx, primary);
240
+ } else {
241
+ const fb = fallbackReadPath();
242
+ if (fb && fb !== primary) {
243
+ const fallbackStatus = readJsonStatus(fb);
244
+ filePresent = true;
245
+ if (fallbackStatus.kind === "valid") {
246
+ parsed = fallbackStatus.value;
247
+ readPath = fb;
248
+ fileValid = true;
249
+ } else if (fallbackStatus.kind === "invalid") {
250
+ readPath = fb;
251
+ warnInvalidConfig(ctx, fb);
252
+ }
253
+ }
91
254
  }
255
+ return { values: normalizeSettings(parsed ?? undefined), parsed, readPath, filePresent, fileValid };
92
256
  };
93
257
 
94
258
  export function loadSettings(ctx?: unknown): PiVccSettings {
95
- // File is source of truth, but if the host provides plugin-scoped settings
96
- // via ctx.settings (omp manifest `omp.settings` / `pi.settings` UI surface),
97
- // merge them on top of file so /settings toggles take effect without restart.
98
- // Host shapes vary: ctx.settings.get(key), ctx.config.get(key), or plain map.
99
- const tryGet = (key: string): unknown => {
100
- try {
101
- const c = ctx as any;
102
- if (!c) return undefined;
103
- if (c.settings?.get) return c.settings.get(key);
104
- if (c.config?.get) return c.config.get(key);
105
- if (c.settings && typeof c.settings === "object" && key in c.settings) return c.settings[key];
106
- if (c.config && typeof c.config === "object" && key in c.config) return c.config[key];
107
- } catch {}
108
- return undefined;
109
- };
110
- const file = (() => {
111
- const primary = settingsPath();
112
- const parsed = readJson(primary) ?? (() => {
113
- const fb = fallbackReadPath();
114
- return fb && fb !== primary ? readJson(fb) : null;
115
- })();
116
- if (!parsed || typeof parsed !== "object") return { ...DEFAULT_SETTINGS };
117
- return { ...DEFAULT_SETTINGS, ...(parsed as Partial<PiVccSettings>) };
118
- })();
259
+ const file = readFileSettings(ctx).values;
119
260
  if (!ctx) return file;
120
- // Overlay plugin-scoped keys if host exposes them (e.g. plugins["@zhulinchng/omp-vcc"].vccEnabled)
121
- const overlay: Partial<PiVccSettings> = {};
122
- for (const k of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
123
- const v = tryGet(`plugins.@zhulinchng/omp-vcc.${k}`) ?? tryGet(`plugins.omp-vcc.${k}`) ?? tryGet(`omp-vcc.${k}`) ?? tryGet(k);
124
- if (v !== undefined) (overlay as any)[k] = v;
261
+ const overlay = contextOverlay(ctx);
262
+ return Object.keys(overlay).length ? normalizeSettings({ ...file, ...overlay }) : file;
263
+ }
264
+
265
+ const pluginSettingsCwd = (ctx: unknown): string | undefined => {
266
+ const cwd = asRecord(ctx)?.cwd;
267
+ return typeof cwd === "string" && cwd.length > 0 ? cwd : undefined;
268
+ };
269
+
270
+ /** Load file settings plus the host's public plugin-settings overlay. */
271
+ export function loadSettingsWithPluginOverlay(ctx: unknown): PiVccSettings | Promise<PiVccSettings> {
272
+ const base = loadSettings(ctx);
273
+ const loader = resolvePluginSettingsLoader();
274
+ const cwd = pluginSettingsCwd(ctx);
275
+ if (!loader || !cwd) return base;
276
+ return Promise.resolve(loader("omp-vcc", cwd))
277
+ .then((overlay) => normalizeSettings({ ...base, ...overlay }))
278
+ .catch(() => base);
279
+ }
280
+
281
+ export function loadSettingsWithSources(ctx?: unknown): VccConfigView {
282
+ const path = settingsPath();
283
+ const file = readFileSettings(ctx);
284
+ const values = file.values;
285
+ const sources = {} as Record<keyof PiVccSettings, VccSettingSource>;
286
+ for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
287
+ sources[key] = file.fileValid && file.parsed && key in file.parsed && isValidSettingValue(key, file.parsed[key])
288
+ ? "file"
289
+ : "default";
125
290
  }
126
- return Object.keys(overlay).length ? { ...file, ...overlay } : file;
291
+ if (ctx) {
292
+ const overlay = contextOverlay(ctx);
293
+ const merged = normalizeSettings({ ...values, ...overlay });
294
+ for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
295
+ values[key] = merged[key];
296
+ if (overlay[key] !== undefined) sources[key] = isValidSettingValue(key, overlay[key]) ? "overlay" : "default";
297
+ }
298
+ }
299
+ return { path, readPath: file.readPath, filePresent: file.filePresent, fileValid: file.fileValid, values, sources };
300
+ }
301
+
302
+ export function loadSettingsWithSourcesAsync(ctx: unknown): VccConfigView | Promise<VccConfigView> {
303
+ const view = loadSettingsWithSources(ctx);
304
+ const loader = resolvePluginSettingsLoader();
305
+ const cwd = pluginSettingsCwd(ctx);
306
+ if (!loader || !cwd) return view;
307
+ return Promise.resolve(loader("omp-vcc", cwd))
308
+ .then((overlay) => {
309
+ const merged = normalizeSettings({ ...view.values, ...overlay });
310
+ for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
311
+ view.values[key] = merged[key];
312
+ if (overlay[key] !== undefined) view.sources[key] = isValidSettingValue(key, overlay[key]) ? "overlay" : "default";
313
+ }
314
+ return view;
315
+ })
316
+ .catch(() => view);
127
317
  }
128
318
 
129
319
  /**
@@ -139,22 +329,26 @@ export function scaffoldSettings(): void {
139
329
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
140
330
 
141
331
  if (!existsSync(path)) {
142
- // migrate legacy pi-vcc config if present before creating fresh
143
- const legacy = existsSync(legacyPiPath) ? readJson(legacyPiPath) : null;
144
- if (legacy && typeof legacy === "object") {
145
- const migrated = { ...DEFAULT_SETTINGS, ...(legacy as Partial<PiVccSettings>) };
146
- writeFileSync(path, `${JSON.stringify(migrated, null, 2)}\n`);
147
- return;
332
+ const fallback = fallbackReadPath();
333
+ if (fallback && fallback !== path) {
334
+ const status = readJsonStatus(fallback);
335
+ if (status.kind === "valid") {
336
+ writeFileSync(path, `${JSON.stringify(normalizeSettings(status.value), null, 2)}\n`);
337
+ } else if (status.kind === "invalid") {
338
+ return;
339
+ } else {
340
+ writeFileSync(path, `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`);
341
+ }
342
+ } else {
343
+ writeFileSync(path, `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`);
148
344
  }
149
- writeFileSync(path, `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`);
150
345
  return;
151
346
  }
152
347
 
153
- const parsed = readJson(path);
154
- if (!parsed || typeof parsed !== "object") return; // don't clobber
155
-
348
+ const status = readJsonStatus(path);
349
+ if (status.kind !== "valid") return;
156
350
  let changed = false;
157
- const next: Record<string, unknown> = { ...parsed };
351
+ const next: Record<string, unknown> = { ...status.value };
158
352
  for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
159
353
  if (!(key in next)) {
160
354
  next[key] = value;
@@ -189,57 +383,3 @@ export interface VccConfigView {
189
383
  * happens to equal the default still counts as `file`. */
190
384
  sources: Record<keyof PiVccSettings, VccSettingSource>;
191
385
  }
192
-
193
- /**
194
- * `loadSettings` plus provenance for `/vcc-config`. Read-only: never creates or
195
- * repairs files (`scaffoldSettings` owns that). Merge order mirrors `loadSettings`
196
- * exactly — defaults, then the first readable candidate (primary, else the same
197
- * `fallbackReadPath()` order), then the identical ctx overlay chain.
198
- */
199
- export function loadSettingsWithSources(ctx?: unknown): VccConfigView {
200
- const path = settingsPath();
201
- const primaryParsed = readJson(path);
202
- let parsed: Record<string, unknown> | null = primaryParsed;
203
- let readPath: string | null = primaryParsed ? path : null;
204
- if (!parsed) {
205
- const fb = fallbackReadPath();
206
- if (fb && fb !== path) {
207
- const fbParsed = readJson(fb);
208
- if (fbParsed) {
209
- parsed = fbParsed;
210
- readPath = fb;
211
- }
212
- }
213
- }
214
- const candidateExists = fallbackReadPath() !== null;
215
- const valid = !!parsed && typeof parsed === "object";
216
- const values: PiVccSettings =
217
- valid && parsed
218
- ? { ...DEFAULT_SETTINGS, ...(parsed as Partial<PiVccSettings>) }
219
- : { ...DEFAULT_SETTINGS };
220
- const sources = {} as Record<keyof PiVccSettings, VccSettingSource>;
221
- for (const k of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
222
- sources[k] = valid && parsed && k in parsed ? "file" : "default";
223
- }
224
- if (ctx) {
225
- const tryGet = (key: string): unknown => {
226
- try {
227
- const c = ctx as any;
228
- if (!c) return undefined;
229
- if (c.settings?.get) return c.settings.get(key);
230
- if (c.config?.get) return c.config.get(key);
231
- if (c.settings && typeof c.settings === "object" && key in c.settings) return c.settings[key];
232
- if (c.config && typeof c.config === "object" && key in c.config) return c.config[key];
233
- } catch {}
234
- return undefined;
235
- };
236
- for (const k of Object.keys(DEFAULT_SETTINGS) as (keyof PiVccSettings)[]) {
237
- const v = tryGet(`plugins.@zhulinchng/omp-vcc.${k}`) ?? tryGet(`plugins.omp-vcc.${k}`) ?? tryGet(`omp-vcc.${k}`) ?? tryGet(k);
238
- if (v !== undefined) {
239
- (values as any)[k] = v;
240
- sources[k] = "overlay";
241
- }
242
- }
243
- }
244
- return { path, readPath, filePresent: candidateExists, fileValid: valid, values, sources };
245
- }
@@ -12,6 +12,7 @@ export interface CompileInput {
12
12
  messages: Message[];
13
13
  previousSummary?: string;
14
14
  fileOps?: FileOps;
15
+ sourceIndices?: Array<number | undefined>;
15
16
  }
16
17
 
17
18
  export interface RankedCompileInput extends CompileInput {
@@ -236,11 +237,12 @@ const mergePrevious = (prev: string, fresh: string, options: { preserveFreshBrie
236
237
  interface CompileWithBriefBlocksOptions {
237
238
  briefBlocksFor?: (blocks: ReturnType<typeof normalize>) => ReturnType<typeof normalize>;
238
239
  capFreshBrief?: boolean;
240
+ includeRecallNote?: boolean;
239
241
  preserveFreshBriefOnMerge?: boolean;
240
242
  }
241
243
 
242
244
  const compileWithBriefBlocks = (input: CompileInput, options: CompileWithBriefBlocksOptions = {}): string => {
243
- const blocks = filterNoise(normalize(input.messages));
245
+ const blocks = filterNoise(normalize(input.messages, input.sourceIndices));
244
246
  const briefBlocks = options.briefBlocksFor?.(blocks);
245
247
  const data = buildSections({ blocks, briefBlocks, fileOps: input.fileOps });
246
248
  const fresh = formatSummary(data, { capBriefTranscript: options.capFreshBrief ?? true });
@@ -251,7 +253,7 @@ const compileWithBriefBlocks = (input: CompileInput, options: CompileWithBriefBl
251
253
  : undefined;
252
254
  const merged = prev ? mergePrevious(prev, fresh, { preserveFreshBrief: options.preserveFreshBriefOnMerge }) : fresh;
253
255
  if (!merged) return "";
254
- return wrapLongLines(merged) + SEPARATOR + RECALL_NOTE;
256
+ return wrapLongLines(merged) + (options.includeRecallNote === false ? "" : SEPARATOR + RECALL_NOTE);
255
257
  };
256
258
 
257
259
  export const compile = (input: CompileInput): string =>
@@ -279,3 +281,7 @@ const stripRecallNote = (text: string): string => {
279
281
  if (!match || match.index < 0) return text;
280
282
  return text.slice(0, match.index).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
281
283
  };
284
+
285
+ /** Compile only the fresh selected window; prior summary and recall note are excluded. */
286
+ export const compileSegment = (input: CompileInput): string =>
287
+ compileWithBriefBlocks({ ...input, previousSummary: undefined }, { includeRecallNote: false });
@@ -139,6 +139,61 @@ export const estimateMessageContentTokens = (
139
139
  charsPerToken = DEFAULT_CHARS_PER_TOKEN,
140
140
  ): number => estimateTokensFromChars(estimateMessageContentChars(content), charsPerToken);
141
141
 
142
+ const asRecord = (value: unknown): Record<string, unknown> | undefined =>
143
+ value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
144
+
145
+ const isCjkCodePoint = (codePoint: number): boolean =>
146
+ (codePoint >= 0x2e80 && codePoint <= 0x9fff)
147
+ || (codePoint >= 0xac00 && codePoint <= 0xd7ff)
148
+ || (codePoint >= 0x3000 && codePoint <= 0x303f);
149
+
150
+ /** Script-aware estimate for non-authoritative retained-tail and recall budgets. */
151
+ export const estimateScriptAwareTokens = (text: string): number => {
152
+ if (!text) return 0;
153
+ let cjk = 0;
154
+ let other = 0;
155
+ for (const character of text) {
156
+ if (isCjkCodePoint(character.codePointAt(0) ?? 0)) cjk++;
157
+ else other++;
158
+ }
159
+ return cjk + Math.ceil(other / 4);
160
+ };
161
+
162
+ export const estimateScriptAwareMessageContentTokens = (content: unknown): number => {
163
+ if (typeof content === "string") return estimateScriptAwareTokens(content);
164
+ if (!Array.isArray(content)) return 0;
165
+ let total = 0;
166
+ for (const rawPart of content) {
167
+ const part = asRecord(rawPart);
168
+ if (!part) continue;
169
+ switch (part.type) {
170
+ case "text":
171
+ total += estimateScriptAwareTokens(typeof part.text === "string" ? part.text : "");
172
+ break;
173
+ case "thinking":
174
+ total += estimateScriptAwareTokens(typeof part.thinking === "string" ? part.thinking : "");
175
+ break;
176
+ case "toolCall": {
177
+ const args = part.arguments ?? part.input;
178
+ const argText = typeof args === "string" ? args : safeJsonStringify(args);
179
+ total += estimateScriptAwareTokens(`${typeof part.name === "string" ? part.name : ""}${argText}`);
180
+ break;
181
+ }
182
+ case "toolResult": {
183
+ const value = part.content;
184
+ total += estimateScriptAwareTokens(typeof value === "string" ? value : safeJsonStringify(value));
185
+ break;
186
+ }
187
+ case "image":
188
+ total += IMAGE_CONTENT_CHARS / DEFAULT_CHARS_PER_TOKEN;
189
+ break;
190
+ default:
191
+ total += estimateScriptAwareTokens(typeof part.text === "string" ? part.text : "");
192
+ }
193
+ }
194
+ return Math.ceil(total);
195
+ };
196
+
142
197
  export interface UsageStats {
143
198
  messageCount: number;
144
199
  byRole: Record<string, number>;