@awak-app/simy-cli 0.6.9 → 0.6.10

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.6.9",
3
+ "version": "0.6.10",
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.6.9";
16
+ export const CLI_VERSION = "0.6.10";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { analyzeContinuation, CONTINUATION_ANALYSIS_VERSION } from './continuation-analysis.js';
5
+ import { providerGoalEvidence } from './conversation-context.js';
5
6
 
6
7
  const BACKFILL_LIMIT = 10;
7
8
  const MAX_CACHE_ENTRIES = 2000;
@@ -9,12 +10,17 @@ const AUTH_RECOVERY_COOLDOWN_MS = 60_000;
9
10
  const RESULT_STATES = new Set(['continue', 'no_action', 'needs_user', 'waiting', 'uncertain']);
10
11
  const keyOf = candidate => `${candidate.provider}:${candidate.session}`;
11
12
  const activityOf = candidate => Number.isFinite(candidate.lastActivityAt) ? candidate.lastActivityAt : 0;
12
- const goalOf = candidate => candidate.providerGoal && typeof candidate.providerGoal === 'object'
13
- ? [candidate.providerGoal.objective ?? null, candidate.providerGoal.status ?? null, candidate.providerGoal.updatedAt ?? null,
14
- candidate.providerGoal.textTruncated === true] : null;
13
+ const turnOf = candidate => candidate.provider === 'codex' && typeof candidate.latestTurnId === 'string' && candidate.latestTurnId
14
+ ? candidate.latestTurnId : null;
15
+ const goalOf = candidate => providerGoalEvidence(candidate.providerGoal);
16
+ const legacyRevision = candidate => createHash('sha256').update(JSON.stringify([
17
+ candidate.provider, candidate.session, candidate.cwd, activityOf(candidate), ...(candidate.isRunning === true ? ['running'] : []),
18
+ ...(goalOf(candidate) === null ? [] : ['goal', [candidate.providerGoal.objective ?? null, candidate.providerGoal.status ?? null,
19
+ candidate.providerGoal.updatedAt ?? null, candidate.providerGoal.textTruncated === true]]),
20
+ ])).digest('hex');
15
21
  const goalRevisionOf = candidate => goalOf(candidate) === null ? null : createHash('sha256').update(JSON.stringify(goalOf(candidate))).digest('hex');
16
22
  export const continuationAnalysisRevision = candidate => createHash('sha256')
17
- .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, activityOf(candidate),
23
+ .update(JSON.stringify([candidate.provider, candidate.session, candidate.cwd, turnOf(candidate) ? ['turn', turnOf(candidate)] : activityOf(candidate),
18
24
  ...(candidate.isRunning === true ? ['running'] : []), ...(goalOf(candidate) === null ? [] : ['goal', goalOf(candidate)])]))
19
25
  .digest('hex');
20
26
  const eligible = candidate => candidate.capabilities?.canAnalyze === true &&
@@ -50,6 +56,14 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
50
56
  const known = new Map(document.known.slice(-MAX_CACHE_ENTRIES));
51
57
  const entries = new Map(document.entries.slice(-MAX_CACHE_ENTRIES));
52
58
  const candidates = new Map();
59
+ const observedCandidate = candidate => {
60
+ const previous = known.get(keyOf(candidate));
61
+ // A bounded discovery may skip this turn probe. Preserve its prior ID
62
+ // only while identity and activity still match; changed activity must be read.
63
+ return !turnOf(candidate) && candidate.provider === 'codex' && previous?.latestTurnId &&
64
+ previous.cwd === candidate.cwd && previous.activity === activityOf(candidate)
65
+ ? { ...candidate, latestTurnId: previous.latestTurnId } : candidate;
66
+ };
53
67
  const providerStatuses = new Map(), signedOutChecks = new Map();
54
68
  for (const [key, entry] of entries) {
55
69
  if (entry.result?.state === 'analyzing') entry.result = { state: 'queued' };
@@ -231,7 +245,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
231
245
  await exclusive(async () => {
232
246
  if (closed || suspended) return;
233
247
  candidates.clear();
234
- for (const candidate of sessions) candidates.set(keyOf(candidate), candidate);
248
+ for (const candidate of sessions) candidates.set(keyOf(candidate), observedCandidate(candidate));
235
249
  const sorted = [...candidates.values()].sort((a, b) => activityOf(b) - activityOf(a) || keyOf(a).localeCompare(keyOf(b)));
236
250
  const backfill = document.initialized ? new Set() : new Set(sorted.slice(0, BACKFILL_LIMIT).map(keyOf));
237
251
  let dirty = !document.initialized;
@@ -242,10 +256,19 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
242
256
  // Old cache formats did not observe goal/tracking metadata. Establish
243
257
  // their baseline once, rather than mistaking the upgrade for activity.
244
258
  const goalObserved = candidate.goalReadState !== 'unavailable';
245
- const goalChanged = previous && Object.hasOwn(previous, 'goalRevision') && previous.goalObserved !== false && goalObserved && previous.goalRevision !== goalRevision;
259
+ const goalChanged = previous && previous.goalEvidenceVersion === 1 && Object.hasOwn(previous, 'goalRevision') && previous.goalObserved !== false && goalObserved && previous.goalRevision !== goalRevision;
246
260
  const trackingEnabled = candidate.tracked === true && (!previous || Object.hasOwn(previous, 'tracked') && previous.tracked !== true);
247
- const newActivity = document.initialized && (previous ? active > previous.activity || (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
261
+ // A known turn boundary is stronger than updated_at: tool/status
262
+ // events in that same turn must not enqueue or cancel paid inference.
263
+ const newActivity = document.initialized && (previous ?
264
+ (turnOf(candidate) && previous.latestTurnId ? turnOf(candidate) !== previous.latestTurnId : active > previous.activity) ||
265
+ (candidate.isRunning === true) !== (previous.isRunning === true) : active > document.baselineAt);
248
266
  const entry = entries.get(key);
267
+ // Rebase unchanged old cache keys once without rerunning historical
268
+ // inference when semantic goal/turn keys become available.
269
+ if (entry && entry.revision !== revision && entry.cwd === candidate.cwd && entry.revision === legacyRevision(candidate)) {
270
+ entry.revision = revision; dirty = true;
271
+ }
249
272
  // Migrate prior idle cache entries without treating historical sessions
250
273
  // as fresh activity. Its old revision already proves this exact cwd.
251
274
  if (entry && !entry.cwd && entry.revision === continuationAnalysisRevision({ ...candidate, isRunning: false })) {
@@ -261,9 +284,10 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
261
284
  if (eligible(candidate) && (backfill.has(key) || newActivity || recheck || trackingEnabled || goalChanged || schemaRefresh)) {
262
285
  dirty = enqueue(candidate, recheck || trackingEnabled || goalChanged || schemaRefresh, trackingEnabled || goalChanged || schemaRefresh) || dirty;
263
286
  }
264
- if (previous?.revision !== revision || previous?.goalObserved !== goalObserved || (previous?.tracked === true) !== (candidate.tracked === true) ||
287
+ if (previous?.revision !== revision || previous?.activity !== active || previous?.goalEvidenceVersion !== 1 || previous?.goalObserved !== goalObserved || (previous?.tracked === true) !== (candidate.tracked === true) ||
265
288
  previous && (!Object.hasOwn(previous, 'goalRevision') || !Object.hasOwn(previous, 'tracked'))) {
266
- known.delete(key); known.set(key, { revision, activity: active, isRunning: candidate.isRunning, tracked: candidate.tracked === true, goalRevision, goalObserved }); dirty = true;
289
+ known.delete(key); known.set(key, { revision, cwd: candidate.cwd, activity: active, isRunning: candidate.isRunning, latestTurnId: turnOf(candidate),
290
+ tracked: candidate.tracked === true, goalRevision, goalObserved, goalEvidenceVersion: 1 }); dirty = true;
267
291
  }
268
292
  }
269
293
  // Cancelling owned analysis is separate from stopping the original
@@ -280,6 +304,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
280
304
  startDrain();
281
305
  },
282
306
  project(candidate) {
307
+ candidate = observedCandidate(candidate);
283
308
  const revision = continuationAnalysisRevision(candidate), entry = entries.get(keyOf(candidate));
284
309
  let analysis = entry?.revision === revision ? { ...entry.result, revision, requestId: entry.requestId } : { state: 'unanalysed', revision };
285
310
  const previous = entry?.cwd === candidate.cwd ? entry.settledResult : null;
@@ -295,6 +320,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
295
320
  async request(candidate, { requestId } = {}) {
296
321
  if (requestId !== undefined && (typeof requestId !== 'string' || !requestId || requestId.length > 128)) throw new Error('Invalid analysis request identity.');
297
322
  if (!candidate || !eligible(candidate)) throw new Error('This conversation cannot be analyzed.');
323
+ candidate = observedCandidate(candidate);
298
324
  await exclusive(async () => {
299
325
  if (closed || suspended) throw new Error('Session analysis is unavailable.');
300
326
  candidates.set(keyOf(candidate), candidate);
@@ -306,6 +332,7 @@ export async function createContinuationAnalysisManager({ root, analyze = analyz
306
332
  startDrain();
307
333
  },
308
334
  async invalidate(candidate) {
335
+ candidate = observedCandidate(candidate);
309
336
  await exclusive(async () => {
310
337
  if (closed || suspended) throw new Error('Session analysis is unavailable.');
311
338
  entries.delete(keyOf(candidate));
@@ -262,6 +262,7 @@ export async function readCodexObservation(client, session, { deadline = Date.no
262
262
  const page = await client.request('thread/turns/list', { threadId: session, cursor: null, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' }, timeout());
263
263
  if (Array.isArray(page?.data) && page.data.length === 1 && ['inProgress', 'completed', 'interrupted', 'failed'].includes(page.data[0]?.status)) {
264
264
  observation.isRunning = page.data[0].status === 'inProgress';
265
+ if (typeof page.data[0].id === 'string' && page.data[0].id && page.data[0].id.length <= 160) observation.latestTurnId = page.data[0].id;
265
266
  }
266
267
  } catch (error) {
267
268
  if (['timeout', 'closed', 'unavailable'].includes(error?.code)) return { goalReadState: 'unavailable', providerUnavailable: true };
@@ -1,6 +1,6 @@
1
1
  import { readCodexConversation } from './codex-app-server.js';
2
2
  import { readClaudeConversation } from './claude-sessions.js';
3
- import { boundConversation, boundProviderGoal, sanitizedText } from './conversation-context.js';
3
+ import { boundConversation, boundProviderGoal, providerGoalEvidence, sanitizedText } from './conversation-context.js';
4
4
  import { runStructuredProviderReview } from '../sqm/review-worker.js';
5
5
  import { inspectProviderStatus } from '../desktop-status.js';
6
6
  import { createHash } from 'node:crypto';
@@ -152,7 +152,7 @@ export async function readContinuationContext(candidate, { signal, readContext,
152
152
  const context = boundConversation(raw?.messages, { truncated: raw?.truncated === true, incomplete: raw?.incomplete === true });
153
153
  const providerGoal = boundProviderGoal(raw?.providerGoal);
154
154
  const contextDigest = createHash('sha256').update(JSON.stringify({ provider: candidate.provider, session: candidate.session, cwd: candidate.cwd, ...context,
155
- ...(providerGoal ? { providerGoal } : {}) })).digest('hex');
155
+ ...(providerGoal ? { providerGoal: providerGoalEvidence(providerGoal) } : {}) })).digest('hex');
156
156
  return { ...context, providerGoal, contextDigest, awaitingTurn: raw?.awaitingTurn === true, settledTurnId: typeof raw?.settledTurnId === 'string' ? raw.settledTurnId.slice(0, 160) : null };
157
157
  }
158
158
 
@@ -185,7 +185,7 @@ export async function analyzeContinuation(candidate, {
185
185
  basis: [{ role: 'goal', turnRef: 'provider-goal', snippet: context.providerGoal.objective.slice(0, 280) }],
186
186
  };
187
187
  if (previousAnalysis?.analysisVersion === CONTINUATION_ANALYSIS_VERSION && previousAnalysis?.contextDigest === context.contextDigest && STATES.includes(previousAnalysis.state)) {
188
- return { ...snapshot, ...previousAnalysis };
188
+ return { ...previousAnalysis, ...snapshot };
189
189
  }
190
190
  if (context.incomplete || !context.messages.length) return uncertain('The latest request or reply could not be read completely. Open the original conversation to review it, then retry.', 'context_incomplete');
191
191
  let result;
@@ -21,6 +21,13 @@ export function textContent(content) {
21
21
  }
22
22
 
23
23
  const GOAL_STATES = new Set(['active', 'paused', 'blocked', 'usageLimited', 'budgetLimited', 'complete']);
24
+ // Goal timestamps can advance with provider bookkeeping without changing the
25
+ // evidence relevant to triage. Keep timestamps for provenance, not cache keys.
26
+ export function providerGoalEvidence(goal) {
27
+ return goal && typeof goal === 'object' ? { objective: goal.objective ?? null, status: goal.status ?? null,
28
+ textTruncated: goal.textTruncated === true } : null;
29
+ }
30
+
24
31
  // Original provider metadata is evidence about the goal, never permission for
25
32
  // this separate read-only classifier to execute work or change provider limits.
26
33
  export function boundProviderGoal(goal) {
@@ -114,6 +114,7 @@ export async function discoverSessions({ root, openClient = openCodexAppServer,
114
114
  return { provider: item.provider, session: item.session, cwd: item.cwd,
115
115
  title: sessionTitle(item.title) || (item.provider === 'codex' ? codexTitles.get(item.session) || '' : ''), source: item.source,
116
116
  ...(typeof isRunning === 'boolean' ? { isRunning } : {}),
117
+ ...(item.latestTurnId ? { latestTurnId: item.latestTurnId } : {}),
117
118
  ...(item.providerGoal !== undefined ? { providerGoal: item.providerGoal } : {}),
118
119
  ...(item.goalReadState ? { goalReadState: item.goalReadState } : {}),
119
120
  tracked: typeof record?.tracked === 'boolean' ? record.tracked : record?.monitor === true,
@@ -11,7 +11,7 @@ export function safeProviderDiagnostic(value) {
11
11
  if (SIGNALS.has(value.signal)) result.signal = value.signal;
12
12
  if (Number.isInteger(value.outputBytes) && value.outputBytes >= 0 && value.outputBytes <= 2147483647) result.outputBytes = value.outputBytes;
13
13
  if (value.retryAttempt === 0 || value.retryAttempt === 1) result.retryAttempt = value.retryAttempt;
14
- if (typeof value.model === "string" && /^(?:gpt-6-(?:astra)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
14
+ if (typeof value.model === "string" && /^(?:gpt-6-(?:astra|sol)|gpt-6\.1-sol|claude-(?:sonnet|opus|haiku)-[0-9]+(?:-[0-9]+){0,3})$/.test(value.model) && value.model.length <= 64) result.model = value.model;
15
15
  return result;
16
16
  }
17
17
 
@@ -20,7 +20,8 @@ const DEFAULT_REQUEST_TIMEOUT_MS = 75_000;
20
20
  const DEFAULT_EXECUTION_TIMEOUT_MS = 240_000;
21
21
  // Keep analysis independent of interactive config. Availability varies by
22
22
  // account and CLI; only an explicit model rejection may try the next model.
23
- export const SQM_CODEX_MODELS = Object.freeze(["gpt-6-astra", "gpt-6.1-sol"]);
23
+ // Background SQM and conversation triage use Sol models to limit usage.
24
+ export const SQM_CODEX_MODELS = Object.freeze(["gpt-6.1-sol", "gpt-6-sol"]);
24
25
  export const SQM_CODEX_MODEL = SQM_CODEX_MODELS[0];
25
26
 
26
27
  export class SqmLocalReviewError extends Error {