shraga 0.1.110 → 0.1.112
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/README.md +2 -1
- package/defaults/skills/mcp-server.md +2 -0
- package/defaults/skills/platform.md +3 -0
- package/defaults/skills/self-aware.md +1 -1
- package/dist/client/assets/{index-BaZu5Ojo.js → index-Dc1ljSt3.js} +2 -2
- package/dist/client/index.html +1 -1
- package/package.json +2 -2
- package/src/mcp-stdio-bridge.ts +25 -9
- package/src/server/claude.ts +15 -2
- package/src/server/data-sync.ts +7 -10
- package/src/server/directives.ts +8 -1
- package/src/server/engine/claude-code.ts +153 -18
- package/src/server/engine/claude-resume.ts +205 -0
- package/src/server/engine/types.ts +3 -0
- package/src/server/integrity-audit.ts +117 -46
- package/src/server/sessions.ts +16 -9
- package/src/server/shraga-config.ts +3 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in SDK session resume for the claude-code engine — the pure half (decision + prompt building),
|
|
3
|
+
* kept free of SDK/IO wiring so every fallback reason is unit-testable.
|
|
4
|
+
*
|
|
5
|
+
* Fresh turn (today's path): ONE user message = contextBlock + <conversation_history> + new message, so
|
|
6
|
+
* the whole history is re-written to prompt cache every turn. Resume turn: `resume: <claudeSessionId>`
|
|
7
|
+
* and send only the new message; the stable context went in once on the session's fresh query, and
|
|
8
|
+
* whatever CHANGED since (context sections, messages other channels appended) plus the current speaker's
|
|
9
|
+
* `user` section rides AFTER the user text so the cached transcript prefix stays byte-identical. Only one
|
|
10
|
+
* speaker's turns ever share a CC session (speaker-change).
|
|
11
|
+
*
|
|
12
|
+
* Flag: `directives.resume` (per conversation: `[resume:on]` or PUT /api/sessions/:id/directives) over
|
|
13
|
+
* `agent-config.json` `sdkResume` (global, re-read every turn). Default OFF.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import type { ConvMessage } from '../sessions.ts';
|
|
20
|
+
import type { Directives } from '../directives.ts';
|
|
21
|
+
import type { AgentSettings } from '../shraga-config.ts';
|
|
22
|
+
|
|
23
|
+
/** Persisted on SessionMeta.claudeResume — the facts needed to decide whether the stored CC session
|
|
24
|
+
* may be resumed. Hashes, not content: this lives in the 9 MB sessions index. */
|
|
25
|
+
export interface ClaudeResumeState {
|
|
26
|
+
/** Claude Code session id (SDK init/result `session_id`); resume keeps it stable across turns. */
|
|
27
|
+
claudeSessionId: string;
|
|
28
|
+
/** Hash of the CLAUDE_CONFIG_DIR the transcript was written under (per-user login or box default). */
|
|
29
|
+
configDirHash: string;
|
|
30
|
+
/** speakerKey() of the person whose turns this CC session holds. Another speaker never resumes it: the
|
|
31
|
+
* transcript carries this speaker's private user context (learnings/corrections). */
|
|
32
|
+
speaker?: string;
|
|
33
|
+
/** Model the last turn resolved (informational — CC resumes across models; only the cache is per-model). */
|
|
34
|
+
model?: string;
|
|
35
|
+
/** When this CC session was started (first fresh query). */
|
|
36
|
+
startedAt: number;
|
|
37
|
+
/** Id of the last conversation message at the start of the last turn; messages after it are what CC hasn't seen. */
|
|
38
|
+
markId?: string;
|
|
39
|
+
/** Identity of the newest shraga summary / compact marker when the state was saved. */
|
|
40
|
+
summaryKey: string;
|
|
41
|
+
/** Section name → hash of the context CC has already been given. */
|
|
42
|
+
sections: Record<string, string>;
|
|
43
|
+
/** Set by the core when another engine ran a turn on this session (its turns aren't in the CC transcript). */
|
|
44
|
+
interruptedBy?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type TurnPath = 'resume' | 'fresh' | `fallback:${string}`;
|
|
48
|
+
|
|
49
|
+
/** Unseen out-of-band text above this size means resume would re-send a history anyway — go fresh. */
|
|
50
|
+
export const MAX_UNSEEN_CHARS = 30_000;
|
|
51
|
+
|
|
52
|
+
export function isResumeEnabled(directives: Directives, config: AgentSettings): boolean {
|
|
53
|
+
// The per-session directives endpoint stores passthrough values opaquely, so accept string forms too.
|
|
54
|
+
const v = (directives.resume ?? config.sdkResume) as unknown;
|
|
55
|
+
return v === true || v === 'on' || v === 'true';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function shortHash(text: string): string {
|
|
59
|
+
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Who is speaking, as a hash (the sessions index must not gain raw emails). */
|
|
63
|
+
export function speakerKey(uid: string, email?: string): string {
|
|
64
|
+
return shortHash(`${uid}\n${email?.trim().toLowerCase() ?? ''}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Errors that mean THIS resume can't run (the stored transcript is gone/unreadable) — worth one fresh retry.
|
|
68
|
+
* Anything else (quota, auth, API, process crash) would fail a fresh query the same way: surface it as-is. */
|
|
69
|
+
export function isResumeFailure(text: string): boolean {
|
|
70
|
+
return /No conversation found|\b(session|transcript)\b.{0,60}\b(not found|missing|corrupt|invalid)\b/i.test(text);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Where the CLI keeps transcripts for a run: the routed per-user login, else the process's config dir. */
|
|
74
|
+
export function claudeConfigDir(accountDir: string | null | undefined): string {
|
|
75
|
+
return accountDir || process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(homedir(), '.claude');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `<configDir>/projects/<cwd-slug>/<id>.jsonl`, found by scan rather than by re-deriving the CLI's slug
|
|
79
|
+
* rule (a wrong guess would silently turn every resume into a fallback). */
|
|
80
|
+
export function findClaudeTranscript(sessionId: string, configDir: string): string | null {
|
|
81
|
+
const projects = path.join(configDir, 'projects');
|
|
82
|
+
if (!existsSync(projects)) return null;
|
|
83
|
+
for (const dir of readdirSync(projects, { withFileTypes: true })) {
|
|
84
|
+
if (!dir.isDirectory()) continue;
|
|
85
|
+
const file = path.join(projects, dir.name, `${sessionId}.jsonl`);
|
|
86
|
+
if (existsSync(file)) return file;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One conversation message as history text — shared by the fresh history prompt and the resume tail. */
|
|
92
|
+
export function renderConvMessage(m: ConvMessage): string | null {
|
|
93
|
+
const texts = m.blocks
|
|
94
|
+
.filter((b) => b.type === 'text' || b.type === 'context')
|
|
95
|
+
.map((b) => (b.type === 'context' ? `[${b.label}]: ${b.text}` : (b as { text: string }).text))
|
|
96
|
+
.filter(Boolean);
|
|
97
|
+
return texts.length ? `${m.role === 'user' ? 'User' : 'Assistant'}: ${texts.join('\n')}` : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Changes whenever maybeCompact writes a summary or /compact adds a marker (applyCompactMarkers
|
|
101
|
+
* turns the latter into a synthetic leading message). */
|
|
102
|
+
export function conversationSummaryKey(conv: ConvMessage[]): string {
|
|
103
|
+
for (let i = conv.length - 1; i >= 0; i--) {
|
|
104
|
+
const s = conv[i].blocks.find((b) => b.type === 'summary') as { compactedCount: number } | undefined;
|
|
105
|
+
if (s) return `summary:${s.compactedCount}`;
|
|
106
|
+
}
|
|
107
|
+
const first = conv[0];
|
|
108
|
+
if (first?.id === 'compact-summary') return `marker:${shortHash(renderConvMessage(first) ?? '')}`;
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Messages CC has not seen: everything after the mark, minus the previous turn's own reply (the first
|
|
114
|
+
* message, when it is the assistant's) and this turn's prompt (the last, when it is a user message —
|
|
115
|
+
* every channel appends it before streaming). null = the mark is gone (history was rewritten).
|
|
116
|
+
*/
|
|
117
|
+
export function unseenMessages(conv: ConvMessage[], markId: string | undefined): ConvMessage[] | null {
|
|
118
|
+
if (!markId) return null;
|
|
119
|
+
const idx = conv.findLastIndex((m) => m.id === markId);
|
|
120
|
+
if (idx < 0) return null;
|
|
121
|
+
const after = conv.slice(idx + 1);
|
|
122
|
+
if (after[0]?.role === 'assistant') after.shift();
|
|
123
|
+
if (after.at(-1)?.role === 'user') after.pop();
|
|
124
|
+
return after;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface TurnDecisionInput {
|
|
128
|
+
enabled: boolean;
|
|
129
|
+
state?: ClaudeResumeState;
|
|
130
|
+
conversation: ConvMessage[];
|
|
131
|
+
configDirHash: string;
|
|
132
|
+
speaker: string;
|
|
133
|
+
/** A CLI process from an earlier run on this session is still alive (e.g. the run a steer took over). */
|
|
134
|
+
cliAlive?: boolean;
|
|
135
|
+
conversationReset?: boolean;
|
|
136
|
+
hasTranscript: (claudeSessionId: string) => boolean;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function decideClaudeTurn(i: TurnDecisionInput): { path: TurnPath; unseen: ConvMessage[] } {
|
|
140
|
+
const fallback = (reason: string) => ({ path: `fallback:${reason}` as TurnPath, unseen: [] });
|
|
141
|
+
if (!i.enabled) return { path: 'fresh', unseen: [] };
|
|
142
|
+
const s = i.state;
|
|
143
|
+
if (!s?.claudeSessionId) return fallback('no-session');
|
|
144
|
+
// Two CLI processes appending to one transcript can interleave it; the takeover turn starts its own.
|
|
145
|
+
if (i.cliAlive) return fallback('concurrent-run');
|
|
146
|
+
if (s.speaker !== i.speaker) return fallback('speaker-change');
|
|
147
|
+
if (s.interruptedBy) return fallback('engine-switch');
|
|
148
|
+
if (s.configDirHash !== i.configDirHash) return fallback('account-change');
|
|
149
|
+
if (i.conversationReset) return fallback('reset');
|
|
150
|
+
if (s.summaryKey !== conversationSummaryKey(i.conversation)) return fallback('summary');
|
|
151
|
+
const unseen = unseenMessages(i.conversation, s.markId);
|
|
152
|
+
if (!unseen) return fallback('history-diverged');
|
|
153
|
+
if (unseen.reduce((n, m) => n + (renderConvMessage(m)?.length ?? 0), 0) > MAX_UNSEEN_CHARS) return fallback('drift');
|
|
154
|
+
// Checked last (filesystem): the CLI's periodic cleanup (cleanupPeriodDays) or a moved config dir
|
|
155
|
+
// deletes transcripts under sessions we still hold — catch it before spawning a doomed resume.
|
|
156
|
+
if (!i.hasTranscript(s.claudeSessionId)) return fallback('transcript-missing');
|
|
157
|
+
return { path: 'resume', unseen };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function sectionHashes(sections: Record<string, string>): Record<string, string> {
|
|
161
|
+
const out: Record<string, string> = {};
|
|
162
|
+
for (const [k, v] of Object.entries(sections)) if (v) out[k] = shortHash(v);
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Re-sent on every resume turn regardless of hashes: who is speaking (identity + role) must never depend on
|
|
167
|
+
* bookkeeping about what an earlier, possibly cancelled, turn managed to deliver. Small. */
|
|
168
|
+
export const ALWAYS_SENT_SECTIONS = ['user'];
|
|
169
|
+
|
|
170
|
+
/** Hash marking a section whose delivery is unknown — never equals a real hash, so the next turn re-sends it. */
|
|
171
|
+
const UNKNOWN = '?';
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Section hashes to store the moment a resume prompt is SUBMITTED. The CLI writes the prompt to the
|
|
175
|
+
* transcript before any output, so a turn cancelled early may or may not have delivered its delta. Unchanged
|
|
176
|
+
* sections are known either way; changed, new and removed ones are marked unknown and re-sent next turn.
|
|
177
|
+
*/
|
|
178
|
+
export function sectionsAfterSubmit(prev: Record<string, string>, next: Record<string, string>): Record<string, string> {
|
|
179
|
+
const out: Record<string, string> = {};
|
|
180
|
+
for (const k of new Set([...Object.keys(prev), ...Object.keys(next)])) out[k] = prev[k] === next[k] ? prev[k] : UNKNOWN;
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The context sections that changed since CC last saw them (new or edited) plus ALWAYS_SENT_SECTIONS, and a
|
|
185
|
+
* note for sections that no longer apply. '' when there is nothing to send. */
|
|
186
|
+
export function buildContextDelta(prev: Record<string, string>, sections: Record<string, string>): string {
|
|
187
|
+
const changed = Object.entries(sections).filter(([k, v]) => v && (ALWAYS_SENT_SECTIONS.includes(k) || prev[k] !== shortHash(v)));
|
|
188
|
+
const removed = Object.keys(prev).filter((k) => !sections[k]);
|
|
189
|
+
if (!changed.length && !removed.length) return '';
|
|
190
|
+
return [
|
|
191
|
+
'<context_update>',
|
|
192
|
+
'Current context for this turn. Each section below replaces its earlier version.',
|
|
193
|
+
...changed.map(([k, v]) => `<section name="${k}">\n${v}\n</section>`),
|
|
194
|
+
...(removed.length ? [`No longer applicable: ${removed.join(', ')}`] : []),
|
|
195
|
+
'</context_update>',
|
|
196
|
+
].join('\n');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function buildResumePrompt(prompt: string, unseen: ConvMessage[], delta: string): string {
|
|
200
|
+
const lines = unseen.map(renderConvMessage).filter(Boolean);
|
|
201
|
+
const unseenBlock = lines.length
|
|
202
|
+
? `<messages_since_last_turn>\nAdded to this conversation since your last reply (other participants, channel context, notices):\n${lines.join('\n\n')}\n</messages_since_last_turn>`
|
|
203
|
+
: '';
|
|
204
|
+
return [prompt, unseenBlock, delta].filter(Boolean).join('\n\n');
|
|
205
|
+
}
|
|
@@ -11,6 +11,9 @@ export interface EngineStreamOpts {
|
|
|
11
11
|
conversation: ConvMessage[];
|
|
12
12
|
/** Contextual blocks to prepend (user block, skills, workspace tree, etc.) */
|
|
13
13
|
contextBlock: string;
|
|
14
|
+
/** The same context split into named sections (in contextBlock order), so an engine that keeps
|
|
15
|
+
* state across turns can send only what changed. Joined with '\n' they equal contextBlock. */
|
|
16
|
+
contextSections?: Record<string, string>;
|
|
14
17
|
|
|
15
18
|
attachments?: AttachmentMeta[];
|
|
16
19
|
images?: string[];
|
|
@@ -3,14 +3,21 @@
|
|
|
3
3
|
* Detects: missing files, truncated content, degraded JSON arrays/objects, invalid JSON.
|
|
4
4
|
*
|
|
5
5
|
* CLI: bun run src/server/integrity-audit.ts [git-ref] [data-dir]
|
|
6
|
-
* API: import { audit } from './integrity-audit.ts'
|
|
6
|
+
* API: import { audit } from './integrity-audit.ts' (async — never blocks the event loop)
|
|
7
7
|
* Remote: run `bun run src/server/integrity-audit.ts` from $APP_DIR on the target host.
|
|
8
8
|
*
|
|
9
9
|
* When no ref is given, picks the most recent "auto: sync" commit as baseline.
|
|
10
10
|
* Exit code 1 if issues found.
|
|
11
|
+
*
|
|
12
|
+
* Proportional by design: only files that DIFFER between ref and HEAD are examined (an unchanged
|
|
13
|
+
* file can't be missing, truncated or degraded, and its JSON validity was judged when it changed).
|
|
14
|
+
* Work is ≤3 git processes total — `diff --raw`, one `cat-file --batch-check` (byte sizes for the
|
|
15
|
+
* truncation check) and one `cat-file --batch` for changed .json only (≤16MB each) — regardless of
|
|
16
|
+
* repo size. Non-JSON blobs (media/binaries) are never loaded into memory.
|
|
17
|
+
* The old version ran `git show` via execSync for every tracked file (x2, plus every .json): on a
|
|
18
|
+
* 4.4k-file prod data repo that froze the server's event loop for ~5 min after every restart.
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
|
-
import { execSync } from 'node:child_process';
|
|
14
21
|
import { resolve } from 'node:path';
|
|
15
22
|
import { readdirSync } from 'node:fs';
|
|
16
23
|
|
|
@@ -18,26 +25,66 @@ import { readdirSync } from 'node:fs';
|
|
|
18
25
|
// Guard (mirrors paths.ts): never silently fall back to bare ./data when named env dirs (data-*)
|
|
19
26
|
// exist — that targets a stale, env-less data/ folder. An explicit [data-dir]/DATA_DIR overrides.
|
|
20
27
|
function resolveDataDir(): string {
|
|
21
|
-
if (process.argv[3]) return process.argv[3];
|
|
22
28
|
if (process.env.DATA_DIR) return process.env.DATA_DIR;
|
|
23
29
|
const root = process.cwd();
|
|
24
30
|
if (readdirSync(root).some((f) => f.startsWith('data-')))
|
|
25
31
|
throw new Error('DATA_DIR not set but named data dirs exist (data-*). Pass [data-dir] or set DATA_DIR.');
|
|
26
32
|
return resolve(root, 'data');
|
|
27
33
|
}
|
|
28
|
-
const DATA_DIR = resolveDataDir();
|
|
29
34
|
|
|
30
35
|
const SIZE_RATIO = 0.5;
|
|
31
36
|
const COUNT_SLACK = 2;
|
|
32
37
|
const STRUCTURAL_JSON = ['schedules.json', 'contacts.json', 'skills-defaults.json', 'api-keys.json'];
|
|
38
|
+
const MAX_JSON_BYTES = 16 * 1024 * 1024; // larger .json isn't read/parsed (bounds memory)
|
|
39
|
+
const GITLINK = '160000'; // submodule entry — not a blob
|
|
40
|
+
const ABSENT = '000000'; // side of an add/delete
|
|
41
|
+
|
|
42
|
+
async function git(dir: string, args: string[], input?: string): Promise<Uint8Array> {
|
|
43
|
+
const proc = Bun.spawn(['git', '-C', dir, ...args], {
|
|
44
|
+
stdin: input === undefined ? 'ignore' : new Blob([input]),
|
|
45
|
+
stdout: 'pipe',
|
|
46
|
+
stderr: 'pipe',
|
|
47
|
+
});
|
|
48
|
+
const [out, err] = await Promise.all([
|
|
49
|
+
new Response(proc.stdout).arrayBuffer(),
|
|
50
|
+
new Response(proc.stderr).text(),
|
|
51
|
+
]);
|
|
52
|
+
const code = await proc.exited;
|
|
53
|
+
if (code !== 0) throw new Error(`git ${args[0]} failed (${code}): ${err.trim()}`);
|
|
54
|
+
return new Uint8Array(out);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ignoreBOM keeps a leading BOM in the text, matching the old execSync decode (a BOM'd .json is invalid).
|
|
58
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true });
|
|
33
59
|
|
|
34
|
-
|
|
35
|
-
|
|
60
|
+
/** Byte sizes of many objects through ONE `git cat-file --batch-check` (no content read). Missing omitted. */
|
|
61
|
+
async function blobSizes(dir: string, shas: string[]): Promise<Map<string, number>> {
|
|
62
|
+
const sizes = new Map<string, number>();
|
|
63
|
+
if (!shas.length) return sizes;
|
|
64
|
+
for (const line of decoder.decode(await git(dir, ['cat-file', '--batch-check'], shas.join('\n') + '\n')).split('\n')) {
|
|
65
|
+
const [sha, type, size] = line.split(' ');
|
|
66
|
+
if (type && type !== 'missing' && size !== undefined) sizes.set(sha, Number(size));
|
|
67
|
+
}
|
|
68
|
+
return sizes;
|
|
36
69
|
}
|
|
37
70
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
71
|
+
/** Read many blobs through ONE `git cat-file --batch` process. Missing objects are omitted. */
|
|
72
|
+
async function readBlobs(dir: string, shas: string[]): Promise<Map<string, string>> {
|
|
73
|
+
const blobs = new Map<string, string>();
|
|
74
|
+
if (!shas.length) return blobs;
|
|
75
|
+
const buf = await git(dir, ['cat-file', '--batch'], shas.join('\n') + '\n');
|
|
76
|
+
let pos = 0;
|
|
77
|
+
while (pos < buf.length) {
|
|
78
|
+
const nl = buf.indexOf(10, pos);
|
|
79
|
+
if (nl < 0) break;
|
|
80
|
+
const [sha, type, size] = decoder.decode(buf.subarray(pos, nl)).split(' ');
|
|
81
|
+
pos = nl + 1;
|
|
82
|
+
if (type === 'missing' || size === undefined) continue;
|
|
83
|
+
const n = Number(size);
|
|
84
|
+
blobs.set(sha, decoder.decode(buf.subarray(pos, pos + n)));
|
|
85
|
+
pos += n + 1; // content + trailing LF
|
|
86
|
+
}
|
|
87
|
+
return blobs;
|
|
41
88
|
}
|
|
42
89
|
|
|
43
90
|
function jsonEntryCount(text: string): number | null {
|
|
@@ -55,54 +102,77 @@ export interface Issue {
|
|
|
55
102
|
detail: string;
|
|
56
103
|
}
|
|
57
104
|
|
|
58
|
-
export function audit(ref: string): Issue[] {
|
|
105
|
+
export async function audit(ref: string, dataDir = resolveDataDir()): Promise<Issue[]> {
|
|
106
|
+
// `:oldmode newmode oldsha newsha status\0path\0` per changed path (renames split into D + A).
|
|
107
|
+
// --relative: scope to dataDir and report paths relative to it (dataDir may be a subfolder of the repo).
|
|
108
|
+
const tokens = decoder.decode(await git(dataDir, ['diff', '--raw', '-z', '--no-renames', '--no-abbrev', '--relative', ref, 'HEAD'])).split('\0');
|
|
109
|
+
const changes: { file: string; oldMode: string; newMode: string; oldSha: string; newSha: string }[] = [];
|
|
110
|
+
for (let i = 0; i + 1 < tokens.length; i += 2) {
|
|
111
|
+
const [oldMode, newMode, oldSha, newSha] = tokens[i].slice(1).split(' ');
|
|
112
|
+
changes.push({ file: tokens[i + 1], oldMode, newMode, oldSha, newSha });
|
|
113
|
+
}
|
|
114
|
+
const isBlob = (mode: string) => mode !== GITLINK && mode !== ABSENT;
|
|
115
|
+
|
|
116
|
+
// Sizes for every changed blob (cheap: headers only); content ONLY for small .json that needs parsing.
|
|
117
|
+
// Loading every changed blob (media/binaries) into memory spiked RSS by GBs on large syncs.
|
|
118
|
+
const sizes = await blobSizes(dataDir, [...new Set(changes.flatMap(c => [
|
|
119
|
+
...(isBlob(c.oldMode) ? [c.oldSha] : []), ...(isBlob(c.newMode) ? [c.newSha] : []),
|
|
120
|
+
]))]);
|
|
121
|
+
const wanted = new Set<string>();
|
|
122
|
+
const small = (sha: string) => (sizes.get(sha) ?? Infinity) <= MAX_JSON_BYTES;
|
|
123
|
+
const skipped = (file: string, sha: string) =>
|
|
124
|
+
console.warn(`[integrity-audit] skipped ${file}: ${sizes.get(sha)}B exceeds the ${MAX_JSON_BYTES}B JSON cap — not validated`);
|
|
125
|
+
for (const c of changes) {
|
|
126
|
+
if (!c.file.endsWith('.json') || !isBlob(c.newMode)) continue;
|
|
127
|
+
if (!small(c.newSha)) { skipped(c.file, c.newSha); continue; }
|
|
128
|
+
wanted.add(c.newSha);
|
|
129
|
+
if (!isBlob(c.oldMode) || !STRUCTURAL_JSON.includes(c.file)) continue;
|
|
130
|
+
if (small(c.oldSha)) wanted.add(c.oldSha);
|
|
131
|
+
else skipped(c.file, c.oldSha);
|
|
132
|
+
}
|
|
133
|
+
const blobs = await readBlobs(dataDir, [...wanted]);
|
|
134
|
+
|
|
59
135
|
const issues: Issue[] = [];
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
.map(l => l.split('\t')[1]);
|
|
64
|
-
const refFiles = lsBlobs(ref);
|
|
65
|
-
const curSet = new Set(lsBlobs('HEAD'));
|
|
66
|
-
|
|
67
|
-
for (const f of refFiles) {
|
|
68
|
-
if (!curSet.has(f)) {
|
|
136
|
+
const jsonIssues: Issue[] = [];
|
|
137
|
+
for (const { file: f, oldMode, newMode, oldSha, newSha } of changes) {
|
|
138
|
+
if (isBlob(oldMode) && !isBlob(newMode)) {
|
|
69
139
|
issues.push({ file: f, kind: 'missing', detail: 'in reference but not HEAD' });
|
|
70
140
|
continue;
|
|
71
141
|
}
|
|
72
|
-
const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
142
|
+
const refSize = isBlob(oldMode) ? sizes.get(oldSha) : undefined;
|
|
143
|
+
const curSize = isBlob(newMode) ? sizes.get(newSha) : undefined;
|
|
144
|
+
const curContent = isBlob(newMode) ? blobs.get(newSha) : undefined;
|
|
145
|
+
const refContent = isBlob(oldMode) ? blobs.get(oldSha) : undefined;
|
|
146
|
+
|
|
147
|
+
// Size check (empty blobs are skipped, as before)
|
|
148
|
+
if (refSize && curSize && refSize > 200 && curSize < refSize * SIZE_RATIO) {
|
|
149
|
+
issues.push({ file: f, kind: 'truncated', detail: `${refSize}B → ${curSize}B (${Math.round(curSize / refSize * 100)}%)` });
|
|
79
150
|
}
|
|
80
151
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
152
|
+
if (refContent && curContent) {
|
|
153
|
+
// Structural JSON check
|
|
154
|
+
if (STRUCTURAL_JSON.includes(f)) {
|
|
155
|
+
const refCount = jsonEntryCount(refContent);
|
|
156
|
+
const curCount = jsonEntryCount(curContent);
|
|
157
|
+
if (refCount !== null && curCount !== null && curCount < refCount - COUNT_SLACK) {
|
|
158
|
+
issues.push({ file: f, kind: 'degraded', detail: `${refCount} → ${curCount} entries` });
|
|
159
|
+
}
|
|
87
160
|
}
|
|
88
161
|
}
|
|
89
|
-
}
|
|
90
162
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
try { JSON.parse(content); }
|
|
97
|
-
catch { issues.push({ file: f, kind: 'invalid-json', detail: 'parse error' }); }
|
|
163
|
+
// Invalid JSON check on added/modified .json files
|
|
164
|
+
if (curContent && f.endsWith('.json')) {
|
|
165
|
+
try { JSON.parse(curContent); }
|
|
166
|
+
catch { jsonIssues.push({ file: f, kind: 'invalid-json', detail: 'parse error' }); }
|
|
167
|
+
}
|
|
98
168
|
}
|
|
99
169
|
|
|
100
|
-
return issues;
|
|
170
|
+
return [...issues, ...jsonIssues];
|
|
101
171
|
}
|
|
102
172
|
|
|
103
|
-
export function findBaselineRef(): string {
|
|
173
|
+
export async function findBaselineRef(dataDir = resolveDataDir()): Promise<string> {
|
|
104
174
|
try {
|
|
105
|
-
const log = git('log
|
|
175
|
+
const log = decoder.decode(await git(dataDir, ['log', '-50', '--format=%H %s']));
|
|
106
176
|
for (const line of log.split('\n')) {
|
|
107
177
|
const [hash, ...rest] = line.split(' ');
|
|
108
178
|
const msg = rest.join(' ');
|
|
@@ -115,10 +185,11 @@ export function findBaselineRef(): string {
|
|
|
115
185
|
// ── CLI ────────────────────────────────────────────────────────────────────
|
|
116
186
|
|
|
117
187
|
if (import.meta.main) {
|
|
118
|
-
const
|
|
119
|
-
|
|
188
|
+
const dataDir = process.argv[3] || resolveDataDir();
|
|
189
|
+
const ref = process.argv[2] || await findBaselineRef(dataDir);
|
|
190
|
+
console.log(`data-audit: HEAD vs ${ref.slice(0, 8)} (${dataDir})\n`);
|
|
120
191
|
|
|
121
|
-
const issues = audit(ref);
|
|
192
|
+
const issues = await audit(ref, dataDir);
|
|
122
193
|
if (!issues.length) {
|
|
123
194
|
console.log('✓ No issues found');
|
|
124
195
|
process.exit(0);
|
package/src/server/sessions.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { homedir } from 'node:os';
|
|
|
5
5
|
import { DATA_DIR, dataPath } from './paths.ts';
|
|
6
6
|
import type { Directives } from './directives.ts';
|
|
7
7
|
import type { ClaudeAccountRef } from './claude-account.ts';
|
|
8
|
+
import { findClaudeTranscript, type ClaudeResumeState } from './engine/claude-resume.ts';
|
|
8
9
|
|
|
9
10
|
const SESSIONS_PATH = dataPath('sessions.json');
|
|
10
11
|
|
|
@@ -45,6 +46,8 @@ export interface SessionMeta {
|
|
|
45
46
|
/** Claude login the last turn ran on (claude-code engine, subscription auth only). `personal` = the
|
|
46
47
|
* requester's own routed login (claude-account.ts), false = the box's shared login. Never a token. */
|
|
47
48
|
lastAccount?: ClaudeAccountRef;
|
|
49
|
+
/** claude-code engine SDK-resume mapping (opt-in flag) — see engine/claude-resume.ts. */
|
|
50
|
+
claudeResume?: ClaudeResumeState;
|
|
48
51
|
forkedFrom?: string;
|
|
49
52
|
}
|
|
50
53
|
|
|
@@ -383,6 +386,18 @@ export function setSessionModel(sessionId: string, model: string, engine?: strin
|
|
|
383
386
|
}
|
|
384
387
|
}
|
|
385
388
|
|
|
389
|
+
/** Store (or clear, with undefined) the claude-code resume mapping. `patch` merges into the existing one. */
|
|
390
|
+
export function setClaudeResume(sessionId: string, state: ClaudeResumeState | undefined, patch?: Partial<ClaudeResumeState>): void {
|
|
391
|
+
const sessions = loadIndex();
|
|
392
|
+
const s = sessions.find((x) => x.sessionId === sessionId);
|
|
393
|
+
if (!s) return;
|
|
394
|
+
if (patch) { if (!s.claudeResume) return; s.claudeResume = { ...s.claudeResume, ...patch }; }
|
|
395
|
+
else if (state) s.claudeResume = state;
|
|
396
|
+
else if (s.claudeResume) delete s.claudeResume;
|
|
397
|
+
else return;
|
|
398
|
+
saveIndex(sessions);
|
|
399
|
+
}
|
|
400
|
+
|
|
386
401
|
export function getSessionsByScheduleId(scheduleId: string): SessionMeta[] {
|
|
387
402
|
return loadIndex()
|
|
388
403
|
.filter((s) => s.scheduleId === scheduleId)
|
|
@@ -528,16 +543,8 @@ export function getSessionAbortController(sessionId: string): AbortController |
|
|
|
528
543
|
return globalSessionLocks.get(sessionId)?.abortController;
|
|
529
544
|
}
|
|
530
545
|
|
|
531
|
-
const CLAUDE_PROJECTS_DIR = path.join(homedir(), '.claude', 'projects');
|
|
532
|
-
|
|
533
546
|
function findSessionFile(sessionId: string): string | null {
|
|
534
|
-
|
|
535
|
-
for (const dir of readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true })) {
|
|
536
|
-
if (!dir.isDirectory()) continue;
|
|
537
|
-
const file = path.join(CLAUDE_PROJECTS_DIR, dir.name, `${sessionId}.jsonl`);
|
|
538
|
-
if (existsSync(file)) return file;
|
|
539
|
-
}
|
|
540
|
-
return null;
|
|
547
|
+
return findClaudeTranscript(sessionId, path.join(homedir(), '.claude'));
|
|
541
548
|
}
|
|
542
549
|
|
|
543
550
|
// ── Our own conversation store ───────────────────────────────────────────────
|
|
@@ -52,6 +52,9 @@ export interface AgentSettings {
|
|
|
52
52
|
skillDiscovery?: boolean;
|
|
53
53
|
thinking?: 'adaptive' | 'enabled' | 'disabled';
|
|
54
54
|
effort?: 'low' | 'medium' | 'high' | 'max';
|
|
55
|
+
/** claude-code engine: resume the SDK session across turns instead of re-sending the history
|
|
56
|
+
* (prompt-cache savings). Default off; a session's `[resume:on|off]` directive wins. */
|
|
57
|
+
sdkResume?: boolean;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export interface ShragaConfig {
|