@awak-app/simy-cli 0.5.83 → 0.6.0
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 +1 -1
- package/src/cli-contract.js +1 -1
- package/src/desktop-host.js +23 -0
- package/src/desktop-sqm-settings.js +14 -5
- package/src/session-supervision/claude-sessions.js +12 -4
- package/src/session-supervision/sqm-presentation.js +14 -0
- package/src/session-supervision/statistics.js +102 -0
- package/src/sqm/command.js +8 -3
- package/src/sqm/conversation-identity.js +35 -0
- package/src/sqm/history-store.js +10 -4
- package/src/sqm/index.js +16 -3
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.
|
|
16
|
+
export const CLI_VERSION = "0.6.0";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
package/src/desktop-host.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { readCodexStatistics, unavailableStatistics } from './session-supervision/statistics.js';
|
|
2
|
+
import { readClaudeStatistics } from './session-supervision/claude-sessions.js';
|
|
3
|
+
import { openCodexAppServer } from './session-supervision/codex-app-server.js';
|
|
4
|
+
import { conversationSqmChecks } from './session-supervision/sqm-presentation.js';
|
|
1
5
|
import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
|
|
2
6
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
3
7
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
@@ -89,12 +93,14 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
89
93
|
let agent, updater, analysisManager, automaticContinuation, stopWorkflows = async () => {}, timer, closed = false, relay = null, publishing = null, publishAgain = false, scheduledPublish = null;
|
|
90
94
|
const makeActionAssociations = () => (dependencies.createActionAssociationHost || createActionAssociationHost)({ root: join(root, "delivery-hooks"), sessionRoot: root, webOrigin, list: () => recordReader.list(), onChange: () => { if (!closed) setImmediate(() => { void publish().catch(() => {}); }); } });
|
|
91
95
|
let actionAssociations = makeActionAssociations();
|
|
96
|
+
const statisticsCache = new Map();
|
|
92
97
|
let currentAccess = { state: 'checking', organizationId: null, organizationName: null };
|
|
93
98
|
const accessScope = access => JSON.stringify([access?.revision ?? null, access?.organizationId ?? null]);
|
|
94
99
|
const setAccessSnapshot = async access => {
|
|
95
100
|
const previousScope = accessScope(currentAccess);
|
|
96
101
|
currentAccess = access || { state: 'unavailable', organizationId: null, organizationName: null };
|
|
97
102
|
if (previousScope !== accessScope(currentAccess)) {
|
|
103
|
+
statisticsCache.clear();
|
|
98
104
|
discoveryController?.abort();
|
|
99
105
|
sessionRefreshAgain = Boolean(sessionRefresh) && desktopAccessReady(currentAccess);
|
|
100
106
|
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
@@ -246,6 +252,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
246
252
|
await actionAssociations.decorateSessions?.(sessions, projection);
|
|
247
253
|
const runsBySession = new Map((status.deliveryHooks.runs || []).map(run => [`${run.provider}:${run.session}`, run]));
|
|
248
254
|
for (const session of sessions) {
|
|
255
|
+
session.statistics = statisticsCache.get(`${session.provider}:${session.session}`) || unavailableStatistics();
|
|
256
|
+
session.sqmActivity = conversationSqmChecks(session, [...(status.sqmChecks?.items || []), ...(status.sqmTasks?.items || [])]);
|
|
249
257
|
const context = await actionAssociations.deadlineContext?.(session, projection);
|
|
250
258
|
if (context) session.actionDeadline = await deadlines.reconcile(context, { displayOnly: true });
|
|
251
259
|
else session.actionDeadline = { status: 'unavailable', online: false, reminder: null };
|
|
@@ -457,6 +465,21 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
457
465
|
if (!desktopActionAllowed(access, command)) throw Object.assign(new Error('Desktop access is required.'), { code: 'desktop_access_required' });
|
|
458
466
|
if (desktopAccessReady(access) && !desktopActionAllowed({ state: 'unavailable' }, command)) await authorizeWork();
|
|
459
467
|
|
|
468
|
+
if (action === "session_statistics") {
|
|
469
|
+
const candidate = await selectedSession(command, { preferObserved: true });
|
|
470
|
+
let statistics;
|
|
471
|
+
if (dependencies.readSessionStatistics) statistics = await dependencies.readSessionStatistics(candidate);
|
|
472
|
+
else if (candidate.provider === 'claude') statistics = await readClaudeStatistics(candidate);
|
|
473
|
+
else {
|
|
474
|
+
const client = await openCodexAppServer();
|
|
475
|
+
try { statistics = await readCodexStatistics(client, candidate); } finally { await client.close(); }
|
|
476
|
+
}
|
|
477
|
+
await authorizeWork(commandScope);
|
|
478
|
+
statisticsCache.clear(); // Only the selected conversation needs chart data.
|
|
479
|
+
statisticsCache.set(`${candidate.provider}:${candidate.session}`, statistics);
|
|
480
|
+
emit({ type: "result", id: command.id, ok: true });
|
|
481
|
+
void publish(); return;
|
|
482
|
+
}
|
|
460
483
|
if (action === "my_ai_session") {
|
|
461
484
|
const ticket = await agent.createDesktopWebSession();
|
|
462
485
|
await authorizeWork(commandScope);
|
|
@@ -15,10 +15,15 @@ export async function readSqmSettings(root, origin, session) {
|
|
|
15
15
|
if (!path) return { enabled: false, enable_engine_fallback: false, allowed_fallback_providers: [] };
|
|
16
16
|
try {
|
|
17
17
|
const settings = JSON.parse(await readFile(path, 'utf8'));
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
// Desktop analysis is automatic. Legacy UI opt-outs no longer gate it.
|
|
19
|
+
if (settings?.policyVersion !== 2) return defaultSqmSettings();
|
|
20
|
+
const providers = Array.isArray(settings.allowed_fallback_providers) ? settings.allowed_fallback_providers : [];
|
|
21
|
+
return {
|
|
22
|
+
enabled: settings.enabled === true,
|
|
23
|
+
enable_engine_fallback: settings.enable_engine_fallback === true,
|
|
24
|
+
allowed_fallback_providers: [...new Set(providers.filter(provider => ['codex', 'claude'].includes(provider)))],
|
|
25
|
+
};
|
|
26
|
+
} catch (error) { if (error.code === 'ENOENT' || error instanceof SyntaxError) return defaultSqmSettings(); throw error; }
|
|
22
27
|
}
|
|
23
28
|
export async function writeSqmEnabled(root, origin, session, enabled) {
|
|
24
29
|
return writeSqmSettings(root, origin, session, { enabled });
|
|
@@ -35,6 +40,10 @@ async function writeSqmSettings(root, origin, session, update) {
|
|
|
35
40
|
const current = await readSqmSettings(root, origin, session);
|
|
36
41
|
await mkdir(join(root, 'sqm-settings'), { recursive: true, mode: 0o700 });
|
|
37
42
|
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
38
|
-
await writeFile(temporary, JSON.stringify({ ...current, ...update }), { mode: 0o600 });
|
|
43
|
+
await writeFile(temporary, JSON.stringify({ ...current, ...update, policyVersion: 2 }), { mode: 0o600 });
|
|
39
44
|
await rename(temporary, path);
|
|
40
45
|
}
|
|
46
|
+
|
|
47
|
+
function defaultSqmSettings() {
|
|
48
|
+
return { enabled: true, enable_engine_fallback: true, allowed_fallback_providers: ['codex', 'claude'] };
|
|
49
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { statisticsFromHandle, unavailableStatistics } from './statistics.js';
|
|
1
2
|
import { constants } from 'node:fs';
|
|
2
3
|
import { lstat, open, readdir, realpath } from 'node:fs/promises';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
@@ -97,7 +98,7 @@ async function latestCustomTitle(handle, size, id) {
|
|
|
97
98
|
return title;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false) {
|
|
101
|
+
async function inspectTranscript(root, projectName, fileName, observedAt, includeContext = false, cacheMetadata = false, includeStatistics = false) {
|
|
101
102
|
const id = fileName.slice(0, -'.jsonl'.length);
|
|
102
103
|
if (!UUID.test(id)) return null;
|
|
103
104
|
const project = join(root, projectName);
|
|
@@ -139,13 +140,14 @@ async function inspectTranscript(root, projectName, fileName, observedAt, includ
|
|
|
139
140
|
}
|
|
140
141
|
if (!metadata) return null;
|
|
141
142
|
const candidate = candidateMetadata(id, metadata, opened.mtimeMs, observedAt);
|
|
143
|
+
if (includeStatistics) candidate.statistics = await statisticsFromHandle(handle, opened, 'claude', id);
|
|
142
144
|
if (includeContext) candidate.context = await conversationFromHandle(handle, opened, id, metadata.cwd);
|
|
143
145
|
return candidate;
|
|
144
146
|
} catch { metadataCache.delete(path); return null; }
|
|
145
147
|
finally { await handle?.close(); }
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
async function collect({ home = homedir(), limit = 100, session = null, includeContext = false, cacheMetadata = false, signal } = {}) {
|
|
150
|
+
async function collect({ home = homedir(), limit = 100, session = null, includeContext = false, cacheMetadata = false, includeStatistics = false, signal } = {}) {
|
|
149
151
|
if (signal?.aborted) return [];
|
|
150
152
|
const root = await projectsRoot(home);
|
|
151
153
|
if (!root) return [];
|
|
@@ -167,9 +169,9 @@ async function collect({ home = homedir(), limit = 100, session = null, includeC
|
|
|
167
169
|
const id = file.name.slice(0, -'.jsonl'.length);
|
|
168
170
|
if (session && id !== session) continue;
|
|
169
171
|
inspected++;
|
|
170
|
-
const candidate = await inspectTranscript(root, entry.name, file.name, observedAt, includeContext, cacheMetadata);
|
|
172
|
+
const candidate = await inspectTranscript(root, entry.name, file.name, observedAt, includeContext, cacheMetadata, includeStatistics);
|
|
171
173
|
if (!candidate) continue;
|
|
172
|
-
const key = `${candidate.session}\0${candidate.cwd}${includeContext ? `\0${entry.name}\0${file.name}` : ''}`;
|
|
174
|
+
const key = `${candidate.session}\0${candidate.cwd}${includeContext || includeStatistics ? `\0${entry.name}\0${file.name}` : ''}`;
|
|
173
175
|
if (!result.has(key) || candidate.lastActivityAt > result.get(key).lastActivityAt) result.set(key, candidate);
|
|
174
176
|
}
|
|
175
177
|
if (inspected >= MAX_TRANSCRIPTS) break;
|
|
@@ -230,3 +232,9 @@ export async function readClaudeConversation(candidate, { home, signal } = {}) {
|
|
|
230
232
|
}
|
|
231
233
|
return { ...selected[0].context, awaitingTurn: candidate.isRunning === true };
|
|
232
234
|
}
|
|
235
|
+
|
|
236
|
+
export async function readClaudeStatistics(candidate, { home } = {}) {
|
|
237
|
+
if (!UUID.test(candidate?.session || '')) return unavailableStatistics();
|
|
238
|
+
const matches = (await collect({ home, session: candidate.session, includeStatistics: true })).filter(item => item.cwd === candidate.cwd);
|
|
239
|
+
return matches.length === 1 ? matches[0].statistics : unavailableStatistics();
|
|
240
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Never associate by repository or the AI that executed an analysis. A result
|
|
2
|
+
// belongs under a conversation only when the original provider/session match.
|
|
3
|
+
export function conversationSqmChecks(candidate, checks) {
|
|
4
|
+
return checks.filter(check => check.conversationProvider === candidate.provider && check.providerSession === candidate.session &&
|
|
5
|
+
!['unavailable', 'interrupted'].includes(check.state) && (check.state !== 'failed' || ['failed', 'preliminary_failed'].includes(check.outcome) && (!check.errorStage || check.errorStage === 'evidence_upload'))).sort((a, b) => (b.finishedAt || b.startedAt || '').localeCompare(a.finishedAt || a.startedAt || '')).slice(0, 5).map(check => ({
|
|
6
|
+
id: check.key, state: check.state, checkedAt: check.finishedAt || check.startedAt,
|
|
7
|
+
scope: check.phase === 'min' ? 'Quick review of the changed work' : 'Review of the changed work and its checks',
|
|
8
|
+
result: check.state === 'running' ? 'Checking this work now.' : check.state === 'verification_pending'
|
|
9
|
+
? 'Some checks still need a person to confirm the result.' : check.outcome === 'preliminary_failed'
|
|
10
|
+
? 'The quick review found issues that need attention.' : check.outcome === 'failed' ? 'The review found issues that need attention.' : check.outcome === 'passed'
|
|
11
|
+
? check.noApplicableChecks ? 'No applicable checks were recorded for this work.' : check.phase === 'min' ? 'The quick review found no blocking issues.' : 'The recorded checks passed.' : 'The review finished. Open the recorded result for its coverage.',
|
|
12
|
+
checked: check.checked || [], findings: check.findings ?? null,
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, relative, isAbsolute, sep } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const HOUR = 3_600_000;
|
|
7
|
+
export const unavailableStatistics = () => ({ state: 'unavailable', hours: [], activity: [] });
|
|
8
|
+
|
|
9
|
+
// Only usage envelopes and timestamps leave this reader, never message text.
|
|
10
|
+
// Work time is an estimate from recorded activity, not a provider billing metric.
|
|
11
|
+
export function transcriptStatistics(text, provider, session, { now = Date.now(), truncated = false } = {}) {
|
|
12
|
+
const events = [], usage = new Map();
|
|
13
|
+
let cumulative = 0, missing = truncated, matched = false;
|
|
14
|
+
for (const line of text.split('\n')) {
|
|
15
|
+
if (!line.trim()) continue;
|
|
16
|
+
let row;
|
|
17
|
+
try { row = JSON.parse(line); } catch { missing = true; continue; }
|
|
18
|
+
if (provider === 'codex' && row.type === 'session_meta') {
|
|
19
|
+
if (row.payload?.id !== session) return unavailableStatistics();
|
|
20
|
+
matched = true;
|
|
21
|
+
}
|
|
22
|
+
if (provider === 'claude') {
|
|
23
|
+
if (row.sessionId !== session || row.isSidechain === true) continue;
|
|
24
|
+
matched = true;
|
|
25
|
+
}
|
|
26
|
+
const at = Date.parse(row.timestamp);
|
|
27
|
+
if (!Number.isFinite(at) || at > now) continue;
|
|
28
|
+
const active = provider === 'claude' ? ['user', 'assistant'].includes(row.type) && !row.isMeta
|
|
29
|
+
: ['response_item', 'event_msg'].includes(row.type) && row.payload?.type !== 'token_count';
|
|
30
|
+
if (active) events.push(at);
|
|
31
|
+
if (provider === 'codex' && row.type === 'event_msg' && row.payload?.type === 'token_count') {
|
|
32
|
+
const total = row.payload.info?.total_token_usage?.total_tokens;
|
|
33
|
+
if (!Number.isSafeInteger(total) || total < 0) continue;
|
|
34
|
+
if (total < cumulative) { missing = true; cumulative = total; continue; }
|
|
35
|
+
const delta = total - cumulative; cumulative = total;
|
|
36
|
+
const key = `codex:${at}:${total}`;
|
|
37
|
+
if (delta > 0 || !usage.has(key)) usage.set(key, { at, tokens: delta });
|
|
38
|
+
} else if (provider === 'claude' && row.type === 'assistant' && row.message?.id) {
|
|
39
|
+
const u = row.message.usage;
|
|
40
|
+
if (!u || ![u.input_tokens, u.output_tokens].every(x => Number.isSafeInteger(x) && x >= 0)) continue;
|
|
41
|
+
// Cached input is separate in Claude. Count it once with uncached input.
|
|
42
|
+
if (![u.cache_creation_input_tokens ?? 0, u.cache_read_input_tokens ?? 0].every(x => Number.isSafeInteger(x) && x >= 0)) continue;
|
|
43
|
+
const tokens = u.input_tokens + u.output_tokens + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
|
|
44
|
+
if (!Number.isSafeInteger(tokens) || tokens < 0) continue;
|
|
45
|
+
const previous = usage.get(row.message.id);
|
|
46
|
+
if (!previous || tokens > previous.tokens) usage.set(row.message.id, { at, tokens });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!matched) return unavailableStatistics();
|
|
50
|
+
const timestamps = [...events, ...[...usage.values()].map(x => x.at)].sort((a, b) => a - b);
|
|
51
|
+
if (!timestamps.length) return unavailableStatistics();
|
|
52
|
+
const start = Math.max(timestamps[0], now - 7 * 24 * HOUR);
|
|
53
|
+
const end = now;
|
|
54
|
+
const hours = new Map();
|
|
55
|
+
for (let at = Math.floor(start / HOUR) * HOUR; at <= end; at += HOUR) hours.set(at, { at, tokens: null });
|
|
56
|
+
for (const item of usage.values()) {
|
|
57
|
+
if (item.at < start || item.at > end) continue;
|
|
58
|
+
const bucket = hours.get(Math.floor(item.at / HOUR) * HOUR);
|
|
59
|
+
bucket.tokens = (bucket.tokens ?? 0) + item.tokens;
|
|
60
|
+
}
|
|
61
|
+
const activity = [];
|
|
62
|
+
for (const at of events.filter(x => x >= start && x <= end).sort((a, b) => a - b)) {
|
|
63
|
+
const previous = activity.at(-1);
|
|
64
|
+
if (previous && at - previous.end <= 5 * 60_000) previous.end = at;
|
|
65
|
+
else activity.push({ start: at, end: at });
|
|
66
|
+
}
|
|
67
|
+
const activeMs = activity.reduce((total, x) => total + x.end - x.start, 0);
|
|
68
|
+
const elapsedMs = end - start;
|
|
69
|
+
return { state: missing ? 'partial' : 'ready', start, end, observedAt: now, hours: [...hours.values()], activity,
|
|
70
|
+
tokens: [...hours.values()].some(h => h.tokens !== null) ? [...hours.values()].reduce((sum, h) => sum + (h.tokens || 0), 0) : null,
|
|
71
|
+
activeMs, idleMs: Math.max(0, elapsedMs - activeMs), utilization: elapsedMs ? activeMs / elapsedMs : null,
|
|
72
|
+
activityMethod: 'recorded_events_5m_gap', includesCachedInput: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function statisticsFromHandle(handle, info, provider, session, options) {
|
|
76
|
+
const maximum = 32 * 1024 * 1024;
|
|
77
|
+
// Keep the prefix for identity and cumulative usage. Never invent a baseline
|
|
78
|
+
// from a truncated tail or claim the unobserved remainder as zero.
|
|
79
|
+
const buffer = Buffer.alloc(Math.min(info.size, maximum));
|
|
80
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
81
|
+
const after = await handle.stat();
|
|
82
|
+
return transcriptStatistics(buffer.subarray(0, bytesRead).toString('utf8'), provider, session,
|
|
83
|
+
{ ...options, truncated: info.size > maximum || after.size !== info.size || after.mtimeMs !== info.mtimeMs });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function readCodexStatistics(client, candidate, { home = process.env.CODEX_HOME || join(homedir(), '.codex'), ...options } = {}) {
|
|
87
|
+
const response = await client.request('thread/read', { threadId: candidate.session, includeTurns: false });
|
|
88
|
+
const thread = response?.thread;
|
|
89
|
+
if (thread?.id !== candidate.session || thread.cwd !== candidate.cwd || typeof thread.path !== 'string') return unavailableStatistics();
|
|
90
|
+
let handle;
|
|
91
|
+
try {
|
|
92
|
+
const root = await realpath(home), path = await realpath(thread.path), rel = relative(root, path);
|
|
93
|
+
if (isAbsolute(rel) || rel.startsWith(`..${sep}`) || !['sessions', 'archived_sessions'].includes(rel.split(sep)[0])) return unavailableStatistics();
|
|
94
|
+
const before = await lstat(thread.path);
|
|
95
|
+
if (!before.isFile() || before.isSymbolicLink()) return unavailableStatistics();
|
|
96
|
+
handle = await open(thread.path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
97
|
+
const info = await handle.stat();
|
|
98
|
+
if (info.ino !== before.ino || info.dev !== before.dev || await realpath(thread.path) !== path) return unavailableStatistics();
|
|
99
|
+
return await statisticsFromHandle(handle, info, 'codex', candidate.session, options);
|
|
100
|
+
} catch { return unavailableStatistics(); }
|
|
101
|
+
finally { await handle?.close(); }
|
|
102
|
+
}
|
package/src/sqm/command.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sqmConversationIdentity } from './conversation-identity.js';
|
|
1
2
|
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
2
3
|
import { createSqmCheckRecorder } from "./history-store.js";
|
|
3
4
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
@@ -40,7 +41,10 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
40
41
|
const deviceId = process.env.SIMY_SQM_DEVICE_ID || validSession?.device_id;
|
|
41
42
|
if (!organizationId) throw new Error(`SQM needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
|
|
42
43
|
const endpoint = process.env.SIMY_SQM_BUNDLE_URL || (validSession?.api_base_url ? new URL("sqm/knowledge-bundle", validSession.api_base_url).href : undefined);
|
|
44
|
+
const { providerSession, conversationProvider } = await sqmConversationIdentity({ session: option(argv, "--session"), provider: option(argv, "--provider") });
|
|
45
|
+
let checkedDescriptions = [];
|
|
43
46
|
const common = {
|
|
47
|
+
onCheckedDescriptions: descriptions => { checkedDescriptions = descriptions; },
|
|
44
48
|
cwd: process.cwd(),
|
|
45
49
|
baseBranch: option(argv, "--base") || "dev",
|
|
46
50
|
cliVersion,
|
|
@@ -53,10 +57,11 @@ export async function runSqmCommand(argv, { cliVersion }) {
|
|
|
53
57
|
deviceId,
|
|
54
58
|
};
|
|
55
59
|
|
|
56
|
-
const
|
|
57
|
-
phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
|
|
60
|
+
const recorder = createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
|
|
61
|
+
providerSession, conversationProvider, phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch });
|
|
62
|
+
const save = result => recorder(result, { checked: checkedDescriptions });
|
|
58
63
|
const onRecoveryEvent = async diagnostic => createSqmCheckRecorder({ scope: { webOrigin, accountId, organizationId },
|
|
59
|
-
phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch })({ status: "failed", diagnostic });
|
|
64
|
+
providerSession, conversationProvider, phase: argv[0] === "min-check" ? "min" : "full", cwd: common.cwd, baseBranch: common.baseBranch })({ status: "failed", diagnostic });
|
|
60
65
|
common.bundleDependencies = { onRecoveryEvent };
|
|
61
66
|
common.onRecoveryEvent = onRecoveryEvent;
|
|
62
67
|
let authenticationAttempted = false;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { defaultSessionRoot } from '../session-store.js';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { listRecords, processIdentity } from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
6
|
+
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
export async function sqmConversationIdentity({ session, provider, env = process.env, cwd = process.cwd(), root = defaultSessionRoot(),
|
|
10
|
+
records, ancestorPids, inspectProcess = processIdentity } = {}) {
|
|
11
|
+
if (!session && UUID.test(env.CODEX_THREAD_ID || '')) { session = env.CODEX_THREAD_ID; provider = 'codex'; }
|
|
12
|
+
if (UUID.test(session || '') && ['codex', 'claude'].includes(provider)) return { providerSession: session, conversationProvider: provider };
|
|
13
|
+
if (process.platform === 'win32' && !ancestorPids) return {};
|
|
14
|
+
// Claude does not export a session ID. Resolve only a live provider process
|
|
15
|
+
// in this command's ancestor chain, never a similar title or repository.
|
|
16
|
+
try {
|
|
17
|
+
const ancestors = ancestorPids || [];
|
|
18
|
+
if (!ancestorPids) {
|
|
19
|
+
let pid = process.ppid;
|
|
20
|
+
for (let depth = 0; pid > 1 && depth < 16; depth++) {
|
|
21
|
+
ancestors.push(pid);
|
|
22
|
+
const { stdout } = await exec('ps', ['-o', 'ppid=', '-p', String(pid)], { timeout: 1000 });
|
|
23
|
+
const parent = Number(stdout.trim());
|
|
24
|
+
if (!Number.isSafeInteger(parent) || parent === pid) break;
|
|
25
|
+
pid = parent;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const candidates = (records || await listRecords(join(root, 'delivery-hooks'))).filter(record =>
|
|
29
|
+
record.cwd === cwd && UUID.test(record.session || '') && ['codex', 'claude'].includes(record.provider) && ancestors.includes(record.process?.pid));
|
|
30
|
+
const verified = [];
|
|
31
|
+
for (const candidate of candidates) if (candidate.process?.identity && await inspectProcess(candidate.process.pid) === candidate.process.identity) verified.push(candidate);
|
|
32
|
+
if (verified.length === 1) return { providerSession: verified[0].session, conversationProvider: verified[0].provider };
|
|
33
|
+
} catch { /* Standalone or ambiguous commands have no conversation tag. */ }
|
|
34
|
+
return {};
|
|
35
|
+
}
|
package/src/sqm/history-store.js
CHANGED
|
@@ -76,11 +76,14 @@ function metadata(entry) {
|
|
|
76
76
|
if (!entry || !["analysis", "check"].includes(entry.kind) || typeof entry.key !== "string" || !entry.key.startsWith(entry.kind === "analysis" ? "sqm:" : "sqm-check:")) return null;
|
|
77
77
|
if (!["running", "completed", "failed", "unavailable", "interrupted", "verification_pending"].includes(entry.state) || !Number.isFinite(Date.parse(entry.startedAt))) return null;
|
|
78
78
|
const item = {};
|
|
79
|
-
for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl"]) {
|
|
79
|
+
for (const name of ["key", "id", "kind", "label", "state", "provider", "phase", "repository", "startedAt", "finishedAt", "instruction", "result", "error", "errorCode", "errorStage", "requestId", "webUrl", "providerSession", "conversationProvider", "outcome"]) {
|
|
80
80
|
if (typeof entry[name] === "string") item[name] = entry[name].slice(0, ["instruction", "result", "error"].includes(name) ? 4096 : 512);
|
|
81
81
|
}
|
|
82
82
|
if (Number.isInteger(entry.errorStatus) && entry.errorStatus >= 400 && entry.errorStatus <= 599) item.errorStatus = entry.errorStatus;
|
|
83
83
|
if (Number.isInteger(entry.httpStatus) && entry.httpStatus >= 400 && entry.httpStatus <= 599) item.httpStatus = entry.httpStatus;
|
|
84
|
+
if (typeof entry.noApplicableChecks === 'boolean') item.noApplicableChecks = entry.noApplicableChecks;
|
|
85
|
+
if (Array.isArray(entry.checked)) item.checked = entry.checked.filter(value => typeof value === 'string').slice(0, 10).map(value => value.slice(0, 160));
|
|
86
|
+
if (Number.isSafeInteger(entry.findings) && entry.findings >= 0) item.findings = entry.findings;
|
|
84
87
|
const providerDiagnostic = safeProviderDiagnostic(entry.providerDiagnostic);
|
|
85
88
|
if (Object.keys(providerDiagnostic).length) item.providerDiagnostic = providerDiagnostic;
|
|
86
89
|
return item;
|
|
@@ -95,12 +98,12 @@ export function projectSqmHistory(items, live = { activeCount: 0, items: [] }) {
|
|
|
95
98
|
sqmChecks: { activeCount: 0, items: rows(items.filter(item => item.kind === "check")) } };
|
|
96
99
|
}
|
|
97
100
|
|
|
98
|
-
export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, onError = () => console.warn("Could not save the local SQM check history.") }) {
|
|
101
|
+
export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, providerSession, conversationProvider, onError = () => console.warn("Could not save the local SQM check history.") }) {
|
|
99
102
|
if (!scope?.webOrigin || !scope?.accountId || !scope?.organizationId) return async () => {};
|
|
100
103
|
const history = createSqmHistoryStore({ root });
|
|
101
104
|
const key = `sqm-check:${randomUUID()}`;
|
|
102
105
|
const startedAt = new Date().toISOString();
|
|
103
|
-
return async result => {
|
|
106
|
+
return async (result, presentation = {}) => {
|
|
104
107
|
const proof = result.proof, identity = proof?.repository_identity || result.repository_identity || result.min_check?.repository_identity;
|
|
105
108
|
const repository = identity?.repository || result.repository;
|
|
106
109
|
const outcome = proof?.outcome.status || result.min_check?.status || result.status;
|
|
@@ -119,7 +122,10 @@ export function createSqmCheckRecorder({ root, scope, phase, cwd, baseBranch, on
|
|
|
119
122
|
: state === "failed" && !identity ? "The command did not finish. See the recorded diagnostics below."
|
|
120
123
|
: `Min-check: ${result.min_check?.status || result.status} · ${(result.preliminary_findings || result.min_check?.preliminary_findings)?.length || 0} preliminary finding(s) · ${(result.matched_modules || result.min_check?.matched_modules)?.length || 0} matched module(s).\nThis is a preflight; run the full check for signed verification.`;
|
|
121
124
|
try {
|
|
122
|
-
await history.record(scope, { key, id: key, kind: "check", phase, state, startedAt,
|
|
125
|
+
await history.record(scope, { key, id: key, kind: "check", phase, state, startedAt, providerSession, conversationProvider, outcome,
|
|
126
|
+
checked: result.checked_descriptions || presentation.checked || [],
|
|
127
|
+
noApplicableChecks: (proof?.outcome.reasons || minCheck.coverage_reasons || []).includes('no_applicable_rules'),
|
|
128
|
+
findings: (proof?.findings || result.preliminary_findings || minCheck.preliminary_findings || []).length, finishedAt: new Date().toISOString(), repository,
|
|
123
129
|
label: `${phase === "min" ? "Min-check" : "Check"} · ${repository || basename(cwd)}`,
|
|
124
130
|
instruction: [repository || basename(cwd), `Base: ${identity?.base_ref || baseBranch}`, identity?.head_commit ? `Head: ${identity.head_commit}` : ""].filter(Boolean).join("\n"),
|
|
125
131
|
result: [summary, ...minWarnings].join("\n"),
|
package/src/sqm/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sqmConversationIdentity } from './conversation-identity.js';
|
|
1
2
|
import { sqmCoverageWarnings } from "./report.js";
|
|
2
3
|
import { safeSqmDiagnostic } from "./diagnostics.js";
|
|
3
4
|
import { createSqmCheckRecorder } from "./history-store.js";
|
|
@@ -31,12 +32,14 @@ export function createSqmChecker({
|
|
|
31
32
|
executeKnowledgeBundleImpl,
|
|
32
33
|
...dependencies
|
|
33
34
|
}) {
|
|
35
|
+
let conversationIdentity;
|
|
36
|
+
const getConversationIdentity = () => conversationIdentity ||= sqmConversationIdentity({ cwd, root: historyRoot });
|
|
34
37
|
let pinnedLoad = null;
|
|
35
38
|
let pinnedRepository = null;
|
|
36
39
|
let latestMinCheck = null;
|
|
37
40
|
let pinnedSystemScope;
|
|
38
41
|
const onRecoveryEvent = async (diagnostic) => {
|
|
39
|
-
await createSqmCheckRecorder({ root: historyRoot,
|
|
42
|
+
await createSqmCheckRecorder({ ...await getConversationIdentity(), root: historyRoot,
|
|
40
43
|
scope: historyRoot ? { webOrigin, accountId, organizationId } : null,
|
|
41
44
|
phase: diagnostic.stage === "evidence_upload" ? "full" : "min", cwd, baseBranch })({ status: "failed", diagnostic });
|
|
42
45
|
await dependencies.onRecoveryEvent?.(diagnostic);
|
|
@@ -98,7 +101,7 @@ export function createSqmChecker({
|
|
|
98
101
|
}
|
|
99
102
|
};
|
|
100
103
|
const checker = async (attempt, options = {}) => {
|
|
101
|
-
const save = createSqmCheckRecorder({ root: historyRoot, scope: historyRoot ? { webOrigin, accountId, organizationId } : null, phase: options.phase || "min", cwd, baseBranch });
|
|
104
|
+
const save = createSqmCheckRecorder({ ...await getConversationIdentity(), root: historyRoot, scope: historyRoot ? { webOrigin, accountId, organizationId } : null, phase: options.phase || "min", cwd, baseBranch });
|
|
102
105
|
try {
|
|
103
106
|
const result = await checkCurrentRepository(attempt, options);
|
|
104
107
|
await save(result); return result;
|
|
@@ -126,6 +129,7 @@ export async function runSqmMinCheck(options) {
|
|
|
126
129
|
const context = await loadContext(options);
|
|
127
130
|
if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings, diagnostic: context.loaded.diagnostic };
|
|
128
131
|
const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
|
|
132
|
+
await options.onCheckedDescriptions?.(checkedDescriptions(evaluation.modules, context.loaded.bundle));
|
|
129
133
|
return buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion });
|
|
130
134
|
}
|
|
131
135
|
|
|
@@ -219,6 +223,7 @@ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organi
|
|
|
219
223
|
min_check: minCheck,
|
|
220
224
|
findings: evaluation.findings,
|
|
221
225
|
executions: evaluation.modules,
|
|
226
|
+
checked_descriptions: checkedDescriptions(evaluation.modules, bundle),
|
|
222
227
|
matcher_diagnostics: evaluation.matcherDiagnostics || [],
|
|
223
228
|
proof,
|
|
224
229
|
signed_evidence: signedEvidence,
|
|
@@ -228,7 +233,15 @@ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organi
|
|
|
228
233
|
}
|
|
229
234
|
|
|
230
235
|
function toLegacyCheck(minCheck, loaded) {
|
|
231
|
-
return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, warnings: [...loaded.warnings, ...(minCheck.warnings || [])], bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
|
|
236
|
+
return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, checked_descriptions: checkedDescriptions(minCheck.matched_modules, loaded.bundle), warnings: [...loaded.warnings, ...(minCheck.warnings || [])], bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Display descriptions come from the verified knowledge bundle. They are
|
|
240
|
+
// separate from the signed proof/min-check contract and never certify coverage.
|
|
241
|
+
function checkedDescriptions(executions, bundle) {
|
|
242
|
+
const matched = new Set(executions.map(module => module.module_id));
|
|
243
|
+
return (bundle.modules || []).filter(module => matched.has(module.module_id || module.id))
|
|
244
|
+
.map(module => module.invariant?.statement).filter(value => typeof value === 'string');
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
function bundleIdentity(bundle) {
|