@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
package/src/cli-contract.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
42
|
+
let piece = chunk.subarray(offset, end);
|
|
40
43
|
if (!discard) {
|
|
41
|
-
|
|
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;
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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 =
|
|
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, ...
|
|
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
|
+
}
|