@awak-app/simy-cli 0.5.55 → 0.5.57

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awak-app/simy-cli",
3
- "version": "0.5.55",
3
+ "version": "0.5.57",
4
4
  "description": "Local SIMY executor for non-coding Local Tasks and Agentic Loop runs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@ import {
13
13
  } from "./durable-local-task-steps-contract.js";
14
14
  import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
15
15
 
16
- export const CLI_VERSION = "0.5.55";
16
+ export const CLI_VERSION = "0.5.57";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -4,12 +4,14 @@ import { realpath } from 'node:fs/promises';
4
4
  import { inspectBackendExecutable } from '../backend-executable.js';
5
5
  import { assessedCheckpointContract, understandingCheckpointContract } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
6
6
  import { boundConversation, boundProviderGoal, textContent } from './conversation-context.js';
7
+ import { JsonDataUrlFilter } from './json-data-url-filter.js';
7
8
 
8
9
  const REQUEST_TIMEOUT = 15_000;
9
10
  const MAX_LINE = 256 * 1024;
10
11
  const ANALYSIS_MAX_LINE = 1024 * 1024;
11
12
  const ANALYSIS_MAX_TURNS = 16;
12
13
  const ANALYSIS_PAGE_SIZE = 6;
14
+ const ANALYSIS_MAX_WIRE_LINE = 8 * 1024 * 1024;
13
15
  const SOURCE_KINDS = ['cli', 'vscode', 'exec', 'appServer', 'unknown'];
14
16
 
15
17
  export class CodexAppServerError extends Error {
@@ -21,7 +23,7 @@ export class CodexAppServerError extends Error {
21
23
  }
22
24
 
23
25
  export class CodexAppServerClient {
24
- constructor(child, { maxLineBytes = MAX_LINE } = {}) {
26
+ constructor(child, { maxLineBytes = MAX_LINE, omitInlineAttachments = false } = {}) {
25
27
  this.maxLineBytes = Number.isInteger(maxLineBytes) ? Math.max(MAX_LINE, Math.min(maxLineBytes, 8 * 1024 * 1024)) : MAX_LINE;
26
28
  this.child = child;
27
29
  this.nextId = 0;
@@ -30,15 +32,21 @@ export class CodexAppServerClient {
30
32
  this.closed = false;
31
33
  // Provider output can include private text. Hold only one bounded JSON-RPC
32
34
  // line at a time, and discard overlong lines before parsing them.
33
- let pending = Buffer.alloc(0), discard = false;
35
+ let pending = Buffer.alloc(0), discard = false, wireBytes = 0;
36
+ let filter = omitInlineAttachments ? new JsonDataUrlFilter() : null;
34
37
  child.stdout.on('data', chunk => {
35
38
  let offset = 0;
36
39
  while (offset < chunk.length) {
37
40
  const newline = chunk.indexOf(10, offset);
38
41
  const end = newline < 0 ? chunk.length : newline;
39
- const piece = chunk.subarray(offset, end);
42
+ let piece = chunk.subarray(offset, end);
40
43
  if (!discard) {
41
- if (pending.length + piece.length > this.maxLineBytes) {
44
+ wireBytes += piece.length;
45
+ // Analysis does not use attachment bytes. Remove inline data URLs
46
+ // before its retained-byte limit, with a separate finite wire cap.
47
+ const wireLimit = omitInlineAttachments ? ANALYSIS_MAX_WIRE_LINE : this.maxLineBytes;
48
+ if (wireBytes <= wireLimit && filter) piece = filter.push(piece);
49
+ if (wireBytes > wireLimit || pending.length + piece.length > this.maxLineBytes) {
42
50
  pending = Buffer.alloc(0); discard = true;
43
51
  this.rejectPending(new CodexAppServerError('response_too_large', 'Codex response exceeded the bounded context limit.'));
44
52
  }
@@ -46,7 +54,9 @@ export class CodexAppServerClient {
46
54
  }
47
55
  if (newline >= 0) {
48
56
  if (!discard && pending.length) this.consume(pending.toString('utf8'));
49
- pending = Buffer.alloc(0); discard = false; offset = newline + 1;
57
+ pending = Buffer.alloc(0); discard = false; wireBytes = 0;
58
+ filter = omitInlineAttachments ? new JsonDataUrlFilter() : null;
59
+ offset = newline + 1;
50
60
  } else offset = end;
51
61
  }
52
62
  });
@@ -124,7 +134,7 @@ export function supportsCodexWriterLock(version) {
124
134
  return parts[0] > 0 || parts[1] > 158 || parts[1] === 158 && parts[2] >= 0;
125
135
  }
126
136
 
127
- export async function openCodexAppServer({ inspectExecutable = inspectBackendExecutable, spawnImpl = spawn, signal, maxLineBytes } = {}) {
137
+ export async function openCodexAppServer({ inspectExecutable = inspectBackendExecutable, spawnImpl = spawn, signal, maxLineBytes, omitInlineAttachments = false } = {}) {
128
138
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Codex app-server was cancelled.');
129
139
  const inspection = await inspectExecutable('codex');
130
140
  if (!inspection?.available || !inspection.executable) throw new CodexAppServerError('unavailable', 'Codex executable is unavailable.');
@@ -134,7 +144,7 @@ export async function openCodexAppServer({ inspectExecutable = inspectBackendExe
134
144
  // restore a temporary read-only override. The caller's signal is checked
135
145
  // before spawn and by the turn watcher below.
136
146
  const child = spawnImpl(inspection.executable, ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
137
- const client = new CodexAppServerClient(child, { maxLineBytes });
147
+ const client = new CodexAppServerClient(child, { maxLineBytes, omitInlineAttachments });
138
148
  try {
139
149
  await client.request('initialize', { clientInfo: { name: 'simy_session_supervision', title: 'SIMY Session Supervision', version: '1.0.0' } });
140
150
  client.notify('initialized');
@@ -256,7 +266,7 @@ export async function readCodexSession(client, session) {
256
266
 
257
267
  export async function readCodexConversation(candidate, { openClient = openCodexAppServer, signal } = {}) {
258
268
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
259
- const client = await openClient({ signal, maxLineBytes: ANALYSIS_MAX_LINE });
269
+ const client = await openClient({ signal, maxLineBytes: ANALYSIS_MAX_LINE, omitInlineAttachments: true });
260
270
  const cancelled = () => { void client.close(); };
261
271
  signal?.addEventListener('abort', cancelled, { once: true });
262
272
  try {
@@ -269,22 +279,28 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
269
279
  const terminal = turn => ['completed', 'interrupted', 'failed'].includes(turn?.status);
270
280
  const turns = [];
271
281
  const turnIds = new Set(), cursors = new Set();
282
+ let pageSize = ANALYSIS_PAGE_SIZE;
283
+ const readPage = async (cursor, maximum = ANALYSIS_PAGE_SIZE) => {
284
+ let limit = Math.min(pageSize, maximum);
285
+ for (;;) {
286
+ if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
287
+ try {
288
+ const page = await client.request('thread/turns/list', { threadId: candidate.session, cursor, limit, sortDirection: 'desc', itemsView: 'summary' });
289
+ if (!Array.isArray(page?.data) || page.data.length > limit || page.nextCursor != null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 2048)) {
290
+ throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid bounded conversation page.');
291
+ }
292
+ return page;
293
+ } catch (error) {
294
+ if (unsupportedMethod(error)) throw new CodexAppServerError('unsupported', 'This Codex version does not support bounded conversation history.');
295
+ if (error?.code !== 'response_too_large' || limit <= 1) throw error;
296
+ pageSize = limit = Math.max(1, Math.floor(limit / 2));
297
+ }
298
+ }
299
+ };
272
300
  let cursor = null;
273
301
  do {
274
302
  if (signal?.aborted) throw new CodexAppServerError('cancelled', 'Conversation analysis was cancelled.');
275
- const limit = Math.min(ANALYSIS_PAGE_SIZE, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
276
- let page;
277
- try {
278
- page = await client.request('thread/turns/list', { threadId: candidate.session, cursor, limit, sortDirection: 'desc', itemsView: 'summary' });
279
- } catch (error) {
280
- if (String(error?.code) === '-32601' || /unknown method|method.*not found|unrecognized method/i.test(String(error?.message || ''))) {
281
- throw new CodexAppServerError('unsupported', 'This Codex version does not support bounded conversation history.');
282
- }
283
- throw error;
284
- }
285
- if (!Array.isArray(page?.data) || page.data.length > limit || page.nextCursor != null && (typeof page.nextCursor !== 'string' || !page.nextCursor || page.nextCursor.length > 2048)) {
286
- throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid bounded conversation page.');
287
- }
303
+ const page = await readPage(cursor, ANALYSIS_MAX_TURNS - turns.filter(terminal).length);
288
304
  for (const turn of page.data) {
289
305
  if (typeof turn?.id !== 'string' || !turn.id || turnIds.has(turn.id)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous conversation pagination.');
290
306
  turnIds.add(turn.id); turns.push(turn);
@@ -298,13 +314,18 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
298
314
  // to this read-only snapshot, but a newly settled turn must invalidate it.
299
315
  const settled = turns.filter(terminal).slice(0, ANALYSIS_MAX_TURNS);
300
316
  const boundary = settled[0];
301
- const verification = await client.request('thread/turns/list', {
302
- threadId: candidate.session, cursor: null, limit: ANALYSIS_PAGE_SIZE, sortDirection: 'desc', itemsView: 'summary',
303
- });
304
- if (!Array.isArray(verification?.data) || verification.data.length > ANALYSIS_PAGE_SIZE) {
305
- throw new CodexAppServerError('context_unavailable', 'Codex returned an invalid snapshot check.');
317
+ let verification = await readPage(null);
318
+ const verificationTurns = [...verification.data];
319
+ const verificationCursors = new Set();
320
+ // A reduced first page may contain only the running turn. Follow bounded
321
+ // pages to the terminal boundary rather than mistaking it for a new one.
322
+ while (!verificationTurns.some(terminal) && verification.nextCursor && verificationTurns.length < ANALYSIS_MAX_TURNS + ANALYSIS_PAGE_SIZE) {
323
+ if (!verification.data.length || verificationCursors.has(verification.nextCursor)) throw new CodexAppServerError('context_unavailable', 'Codex returned ambiguous snapshot pagination.');
324
+ verificationCursors.add(verification.nextCursor);
325
+ verification = await readPage(verification.nextCursor);
326
+ verificationTurns.push(...verification.data);
306
327
  }
307
- const latestBoundary = verification.data.find(terminal);
328
+ const latestBoundary = verificationTurns.find(terminal);
308
329
  // Compare the immutable settled summary, never the running turn's prose.
309
330
  if (JSON.stringify(boundary) !== JSON.stringify(latestBoundary)) {
310
331
  throw new CodexAppServerError('turn_settling', 'The latest turn settled during the snapshot read.');
@@ -322,7 +343,7 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
322
343
  // A display summary omits intermediate reasoning/tool context even when
323
344
  // every turn fits. Never represent it as the complete conversation; the
324
345
  // classifier must establish a self-contained objective from retained text.
325
- const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verification.data].some(turn => ['inProgress', 'in_progress'].includes(turn?.status));
346
+ const awaitingTurn = latestMetadata.isRunning === true || [...turns, ...verificationTurns].some(turn => ['inProgress', 'in_progress'].includes(turn?.status));
326
347
  let incomplete = !settled.length || turns.some(turn => !terminal(turn) && !['inProgress', 'in_progress'].includes(turn?.status)), truncated = true;
327
348
  for (const [index, turn] of settled.toReversed().entries()) {
328
349
  if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
@@ -0,0 +1,66 @@
1
+ // JSON remains validated by JSON.parse after this lexical filter. Only inline
2
+ // URL values are removed; prose, identities, cursors and escaped quotes retain
3
+ // their original bytes. Latin-1 here preserves split UTF-8 sequences verbatim.
4
+ export class JsonDataUrlFilter {
5
+ constructor() {
6
+ this.inString = false;
7
+ this.escaped = false;
8
+ this.token = '';
9
+ this.lastString = null;
10
+ this.valueKey = null;
11
+ this.probe = null;
12
+ this.dropping = false;
13
+ }
14
+
15
+ push(bytes) {
16
+ let output = '';
17
+ for (const char of bytes.toString('latin1')) {
18
+ if (!this.inString) {
19
+ output += char;
20
+ if (char === '"') {
21
+ this.inString = true;
22
+ this.token = '';
23
+ this.escaped = false;
24
+ this.probe = ['url', 'imageUrl', 'image_url'].includes(this.valueKey) ? '' : null;
25
+ this.dropping = false;
26
+ this.valueKey = null;
27
+ } else if (char === ':') {
28
+ this.valueKey = this.lastString;
29
+ this.lastString = null;
30
+ } else if (!/\s/.test(char)) {
31
+ this.lastString = null;
32
+ this.valueKey = null;
33
+ }
34
+ continue;
35
+ }
36
+ const closing = char === '"' && !this.escaped;
37
+ if (closing) {
38
+ if (this.probe !== null) output += this.probe;
39
+ output += char;
40
+ this.inString = false;
41
+ this.lastString = null;
42
+ if (this.token !== null && !this.dropping) {
43
+ try { this.lastString = JSON.parse(Buffer.from(`"${this.token}"`, 'latin1').toString('utf8')); } catch { /* Final JSON validation rejects malformed input. */ }
44
+ }
45
+ this.probe = null;
46
+ this.dropping = false;
47
+ continue;
48
+ }
49
+ if (this.token !== null) this.token = this.token.length < 128 ? this.token + char : null;
50
+ if (this.escaped) this.escaped = false;
51
+ else if (char === '\\') this.escaped = true;
52
+ if (this.dropping) continue;
53
+ if (this.probe !== null) {
54
+ this.probe += char;
55
+ if (!'data:'.startsWith(this.probe)) {
56
+ output += this.probe;
57
+ this.probe = null;
58
+ } else if (this.probe === 'data:') {
59
+ this.dropping = true;
60
+ this.probe = null;
61
+ }
62
+ } else output += char;
63
+ }
64
+ return Buffer.from(output, 'latin1');
65
+ }
66
+ }