@awak-app/simy-cli 0.5.47 → 0.5.48

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.47",
3
+ "version": "0.5.48",
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.47";
16
+ export const CLI_VERSION = "0.5.48";
17
17
  export const CLI_API_CONTRACT_VERSION = 10;
18
18
 
19
19
  export function withCliContract(
@@ -315,13 +315,16 @@ export async function readCodexConversation(candidate, { openClient = openCodexA
315
315
  for (const [index, turn] of settled.toReversed().entries()) {
316
316
  if (turn.itemsView === 'notLoaded') { truncated = true; if (turn === boundary) incomplete = true; continue; }
317
317
  if (turn.itemsView !== 'summary') throw new CodexAppServerError('context_unavailable', 'Codex did not return the requested conversation summaries.');
318
- if (!Array.isArray(turn?.items)) { incomplete = true; continue; }
318
+ if (!Array.isArray(turn?.items)) { if (turn === boundary) incomplete = true; continue; }
319
319
  if (turn === boundary && !turn.items.length) incomplete = true;
320
320
  for (const [itemIndex, item] of turn.items.entries()) {
321
321
  const role = item?.type === 'userMessage' ? 'user' : item?.type === 'agentMessage' ? 'assistant' : null;
322
322
  if (!role) continue;
323
323
  const text = role === 'user' ? textContent(item.content) : item.text;
324
- if (typeof text !== 'string' || !text.trim()) { incomplete = true; continue; }
324
+ // An older interrupted/empty answer is an omission in the display
325
+ // history, not a missing latest exchange. Retain human corrections;
326
+ // the classifier must still establish a self-contained current goal.
327
+ if (typeof text !== 'string' || !text.trim()) { if (turn === boundary) incomplete = true; continue; }
325
328
  messages.push({ role, turnRef: `${turn.id || index}:${item.id || itemIndex}`, text });
326
329
  }
327
330
  }
@@ -2,9 +2,10 @@ import { readCodexConversation } from './codex-app-server.js';
2
2
  import { readClaudeConversation } from './claude-sessions.js';
3
3
  import { boundConversation, boundProviderGoal, sanitizedText } from './conversation-context.js';
4
4
  import { runStructuredProviderReview } from '../sqm/review-worker.js';
5
+ import { inspectProviderStatus } from '../desktop-status.js';
5
6
  import { createHash } from 'node:crypto';
6
7
 
7
- export const CONTINUATION_ANALYSIS_VERSION = 2;
8
+ export const CONTINUATION_ANALYSIS_VERSION = 3;
8
9
  const BLOCKER_TYPES = ['approval', 'missing_information', 'external_dependency', 'execution_failure'];
9
10
  const STATES = ['continue', 'no_action', 'needs_user', 'waiting', 'uncertain'];
10
11
  const string = maximum => ({ type: 'string', maxLength: maximum });
@@ -50,8 +51,22 @@ Older history may be omitted. Set assessment.contextSufficient=true only if the
50
51
  Return blocker=null unless concrete unfinished original work is currently prevented by a specific unresolved approval, missing_information, external_dependency or execution_failure. State and needsUser/externalWait flags alone are not blockers. Running/status-only waiting, future reminders, voluntary pauses, budget limits, uncertainty, optional follow-ups and resolved historical failures must have blocker=null. A recoverable failure with a feasible next step is not a blocker. A blocker never changes continuation authority. Its reason must describe the actual obstacle concisely; basis must quote exact original conversation excerpts that establish the obstacle, checking all later supplied messages to establish that it is still current. Evidence may originate in an earlier turn when no later message resolves the obstacle. Goal metadata alone cannot establish a blocker. Later authorization, supplied information, resolution or completion clears older blockers. For execution_failure, require explicit evidence that the failure prevents further useful progress, not simply that a command failed.
51
52
  Return concise reasons and exact short supporting excerpts, each tied to the role and turnRef provided. Excerpts must occur verbatim in the supplied message. Do not reproduce secrets, credentials or large private passages. Set nextAction=null except for continue. Populate assessment honestly; nextActionKind names the concrete action, not merely a plan to act.`;
52
53
 
53
- function uncertain(reason) {
54
- return { state: 'uncertain', reason, objective: '', remaining: [], nextAction: null, basis: [], blocker: null, analysisVersion: CONTINUATION_ANALYSIS_VERSION };
54
+ function uncertain(reason, reasonCode) {
55
+ return { state: 'uncertain', reason, ...(reasonCode ? { reasonCode } : {}), objective: '', remaining: [], nextAction: null, basis: [], blocker: null, analysisVersion: CONTINUATION_ANALYSIS_VERSION };
56
+ }
57
+
58
+ async function unavailableAnalysis(candidate, error, inspectProvider, signal) {
59
+ if (error?.code === 'analysis_cancelled') return uncertain('Conversation analysis was cancelled.', 'cancelled');
60
+ if (error?.code === 'analysis_timeout') return uncertain('The local AI app did not finish checking this turn in time. Retry when it is available.', 'timeout');
61
+ if (error?.code === `${candidate.provider}_local_unavailable`) return uncertain('The local AI app is unavailable. Open or update it, then retry.', 'unavailable');
62
+ let status;
63
+ try { status = await inspectProvider(candidate.provider); } catch { /* Unknown is never proof of sign-out. */ }
64
+ if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
65
+ if (error?.code === 'provider_sign_in_required' || status?.state === 'signed_out') {
66
+ const label = candidate.provider === 'claude' ? 'Claude Code' : 'Codex';
67
+ return uncertain(`Sign in to ${label} to check this conversation and continue its unfinished work. After signing in, retry this check.`, 'provider_sign_in_required');
68
+ }
69
+ return uncertain('The local AI app could not check this turn. Check its connection, then retry.', 'analysis_unavailable');
55
70
  }
56
71
 
57
72
  function unreadableContext(error) {
@@ -143,7 +158,7 @@ export async function readContinuationContext(candidate, { signal, readContext,
143
158
 
144
159
  export async function analyzeContinuation(candidate, {
145
160
  signal, readContext, openCodexClient, claudeHome, previousAnalysis,
146
- runProvider = runStructuredProviderReview, providerOptions = {},
161
+ runProvider = runStructuredProviderReview, providerOptions = {}, inspectProvider = inspectProviderStatus,
147
162
  } = {}) {
148
163
  if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
149
164
  if (!['codex', 'claude'].includes(candidate?.provider) || typeof candidate?.session !== 'string' || typeof candidate?.cwd !== 'string') {
@@ -172,18 +187,26 @@ export async function analyzeContinuation(candidate, {
172
187
  if (previousAnalysis?.analysisVersion === CONTINUATION_ANALYSIS_VERSION && previousAnalysis?.contextDigest === context.contextDigest && STATES.includes(previousAnalysis.state)) {
173
188
  return { ...snapshot, ...previousAnalysis };
174
189
  }
175
- if (context.incomplete || !context.messages.length) return uncertain('The latest conversation request or response is missing or incomplete.');
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
+ let result;
176
192
  try {
177
- const result = await runProvider(candidate.provider, {
193
+ result = await runProvider(candidate.provider, {
178
194
  system: SYSTEM, prompt: `Classify this conversation evidence only (older history omitted: ${context.truncated}):\n${JSON.stringify(context.providerGoal ? { originalProviderGoal: context.providerGoal, messages: context.messages } : context.messages)}`, output_schema: OUTPUT_SCHEMA,
179
195
  }, { ...providerOptions, signal, readOnlyAnalysis: true });
180
- if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
196
+ } catch (error) {
197
+ if (signal?.aborted) return uncertain('Conversation analysis was cancelled.', 'cancelled');
198
+ // Only a known machine code or the provider's own login probe can
199
+ // establish sign-out. SIMY account login is unrelated to local analysis.
200
+ return unavailableAnalysis(candidate, error, inspectProvider, signal);
201
+ }
202
+ if (signal?.aborted) return uncertain('Conversation analysis was cancelled.');
203
+ try {
181
204
  const analysis = validateContinuationAnalysis(result?.output, context);
182
205
  const model = typeof result?.model === 'string' && /^[a-zA-Z0-9._/-]{1,120}$/.test(result.model) ? result.model : null;
183
206
  return { ...snapshot, ...analysis, ...(model ? { model } : {}) };
184
207
  } catch {
185
208
  // Provider stderr/errors may contain transcript text or credentials. Never
186
209
  // expose them to the desktop, cache, diagnostics or control plane.
187
- return uncertain('Local conversation analysis is unavailable or returned an invalid result.');
210
+ return uncertain('The AI app returned a check that could not be verified. Retry this check or review the original conversation.', 'analysis_invalid_result');
188
211
  }
189
212
  }
@@ -260,6 +260,9 @@ export async function runStructuredProviderReview(
260
260
  );
261
261
  const envelope = provider === "claude" ? JSON.parse(stdout) : null;
262
262
  if (envelope?.is_error || (envelope && envelope.subtype !== "success")) {
263
+ if (readOnlyAnalysis && envelope?.is_error === true && providerSignInRequired(envelope.result)) {
264
+ throw new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' });
265
+ }
263
266
  throw new Error("Claude Code did not complete a structured review");
264
267
  }
265
268
  const output = provider === "claude"
@@ -322,7 +325,7 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
322
325
  };
323
326
  const cancelled = () => halt(new Error('Local analysis was cancelled'));
324
327
  const timer = setTimeout(() => {
325
- halt(new Error(`${label} timed out after ${executionTimeoutMs}ms`));
328
+ halt(readOnlyAnalysis ? new SqmLocalReviewError('Local analysis timed out.', { code: 'analysis_timeout' }) : new Error(`${label} timed out after ${executionTimeoutMs}ms`));
326
329
  }, executionTimeoutMs);
327
330
  timer.unref?.();
328
331
  const finish = (error = null) => {
@@ -341,6 +344,10 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
341
344
  for (const line of stdout.split('\n').filter(Boolean)) {
342
345
  let event;
343
346
  try { event = JSON.parse(line); } catch { continue; }
347
+ if (['error', 'turn.failed'].includes(event.type) && providerSignInRequired(event.message ?? event.error?.message)) {
348
+ halt(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
349
+ return;
350
+ }
344
351
  if (/command_execution|mcp_tool_call|web_search|file_change|tool_call/.test(event.item?.type || '')) {
345
352
  halt(new Error('Read-only analysis attempted to use a tool'));
346
353
  return;
@@ -358,6 +365,14 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
358
365
  });
359
366
  child.on("error", finish);
360
367
  child.on("close", (code) => {
368
+ if (readOnlyAnalysis && provider === 'claude' && !terminalError) {
369
+ let envelope;
370
+ try { envelope = JSON.parse(stdout); } catch { /* Non-JSON failure remains unavailable. */ }
371
+ if (envelope?.is_error === true && providerSignInRequired(envelope.result)) {
372
+ finish(new SqmLocalReviewError('Sign in to the local AI app again.', { code: 'provider_sign_in_required' }));
373
+ return;
374
+ }
375
+ }
361
376
  finish(terminalError || (code === 0 ? null : new Error(stderr.trim() || `${label} exited with code ${code}`)));
362
377
  });
363
378
  signal?.addEventListener('abort', cancelled, { once: true });
@@ -366,6 +381,12 @@ function spawnReview(command, args, prompt, { spawnImpl, executionTimeoutMs, lab
366
381
  });
367
382
  }
368
383
 
384
+ function providerSignInRequired(message) {
385
+ // Match explicit provider authentication failures only, never general 403s
386
+ // (which can be permissions/quotas) or model-generated prose/tool output.
387
+ return typeof message === 'string' && /(?:not logged in|please (?:log|sign) in|invalid api key|authentication token (?:is )?(?:invalid|expired)|refresh token (?:has )?expired)/i.test(message.slice(0, 4000));
388
+ }
389
+
369
390
  function safeReviewEnvironment(environment, provider) {
370
391
  const allowed = [
371
392
  "HOME", "PATH", "CODEX_HOME", "CLAUDE_CONFIG_DIR",