@bli-cockpit/cli 0.2.252 → 0.2.253
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/dist/adapters/claude-attribution-memoized-reads.js +85 -0
- package/dist/adapters/claude-attribution-score.js +23 -34
- package/dist/adapters/claude-attribution.js +1 -1
- package/dist/adapters/raw-evidence-collection-state.js +2 -0
- package/dist/adapters/raw-evidence-image-reader.js +30 -6
- package/dist/adapters/raw-evidence-prepare-memo.js +5 -5
- package/dist/adapters/raw-evidence.js +1 -0
- package/dist/adapters/transcript-read-memo.js +109 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-scan.js +22 -11
- package/package.json +4 -4
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { captureSessionFactsDelta, observeSessionFactsContent, observeSessionFactsFile, parseSessionFactsDelta, SESSION_FACTS_VERSION, SKILL_USE_VERSION, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { extractClaudeSessionSignals } from "./claude-attribution-signals.js";
|
|
5
|
+
import { statSourceForMemo } from "./transcript-read-memo.js";
|
|
6
|
+
/**
|
|
7
|
+
* The reads attribution does of one Claude file, remembered by the file's stamp (BLI-22797;
|
|
8
|
+
* `transcript-read-memo.ts`). Every read goes through the same capture-then-merge path whether the file was
|
|
9
|
+
* opened or recalled, so a recalled file leaves the session's facts exactly as a fresh read does by construction
|
|
10
|
+
* (`session-facts-delta.ts`). Bump SIGNALS_VERSION when `claude-attribution-signals.ts` changes what it extracts.
|
|
11
|
+
*/
|
|
12
|
+
const SIGNALS_VERSION = "signals.v1";
|
|
13
|
+
export const ATTRIBUTION_READ_RECIPE = ["attribution-reads.v1", SIGNALS_VERSION, SESSION_FACTS_VERSION, SKILL_USE_VERSION].join("|");
|
|
14
|
+
const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
15
|
+
const isStrings = (v) => Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
16
|
+
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
17
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
function parseSidecarRead(data) {
|
|
19
|
+
if (!isObj(data) || !isStrings(data["cwds"]) || !isNum(data["byte_size"]) || typeof data["sha256"] !== "string")
|
|
20
|
+
return null;
|
|
21
|
+
const facts = parseSessionFactsDelta(data["facts"]);
|
|
22
|
+
return facts ? { cwds: data["cwds"], byte_size: data["byte_size"], sha256: data["sha256"], facts } : null;
|
|
23
|
+
}
|
|
24
|
+
function parseMainRead(data) {
|
|
25
|
+
if (!isObj(data) || !isNum(data["byte_size"]) || typeof data["sha256"] !== "string" || !isObj(data["signals"]))
|
|
26
|
+
return null;
|
|
27
|
+
const s = data["signals"];
|
|
28
|
+
if (!isStrings(s["session_ids"]) || !isStrings(s["cwds"]) || !isStrings(s["branches"]) || !isStrings(s["repository_urls"]))
|
|
29
|
+
return null;
|
|
30
|
+
if (!isNum(s["line_count"]) || !isNum(s["parse_error_count"]) || !isNum(s["content_record_count"]) || !isNum(s["envelope_field_hit_rate"]))
|
|
31
|
+
return null;
|
|
32
|
+
const facts = parseSessionFactsDelta(data["facts"]);
|
|
33
|
+
return facts ? { signals: s, byte_size: data["byte_size"], sha256: data["sha256"], facts } : null;
|
|
34
|
+
}
|
|
35
|
+
/** The sidecar's cwds, size, hash and facts contribution: recalled when its stamp is unchanged, else read (a read failure throws). */
|
|
36
|
+
export async function readSidecar(memo, localPath) {
|
|
37
|
+
const stamp = memo ? await statSourceForMemo(localPath) : null;
|
|
38
|
+
if (memo && stamp) {
|
|
39
|
+
const hit = memo.recall(localPath, stamp, ATTRIBUTION_READ_RECIPE, stamp.size, parseSidecarRead);
|
|
40
|
+
if (hit)
|
|
41
|
+
return hit;
|
|
42
|
+
}
|
|
43
|
+
else
|
|
44
|
+
memo?.note("stamp_missing");
|
|
45
|
+
const raw = await fs.readFile(localPath);
|
|
46
|
+
const content = raw.toString("utf8");
|
|
47
|
+
const read = {
|
|
48
|
+
cwds: extractClaudeSessionSignals(content).cwds,
|
|
49
|
+
byte_size: raw.byteLength,
|
|
50
|
+
sha256: sha256(raw),
|
|
51
|
+
facts: captureSessionFactsDelta("claude_code", (acc) => void observeSessionFactsContent(acc, content)),
|
|
52
|
+
};
|
|
53
|
+
if (memo && stamp) {
|
|
54
|
+
memo.note("fresh");
|
|
55
|
+
memo.remember(localPath, stamp, ATTRIBUTION_READ_RECIPE, read);
|
|
56
|
+
}
|
|
57
|
+
return read;
|
|
58
|
+
}
|
|
59
|
+
/** The main transcript's signals, size, hash and facts contribution. `{ empty: true }` when it holds only whitespace (never remembered). */
|
|
60
|
+
export async function readMain(memo, filePath) {
|
|
61
|
+
const stamp = memo ? await statSourceForMemo(filePath) : null;
|
|
62
|
+
if (memo && stamp) {
|
|
63
|
+
const hit = memo.recall(filePath, stamp, ATTRIBUTION_READ_RECIPE, stamp.size, parseMainRead);
|
|
64
|
+
if (hit)
|
|
65
|
+
return hit;
|
|
66
|
+
}
|
|
67
|
+
else
|
|
68
|
+
memo?.note("stamp_missing");
|
|
69
|
+
const raw = await fs.readFile(filePath);
|
|
70
|
+
const content = raw.toString("utf8");
|
|
71
|
+
const hash = sha256(raw);
|
|
72
|
+
if (content.trim() === "")
|
|
73
|
+
return { empty: true, byte_size: raw.byteLength, sha256: hash };
|
|
74
|
+
let signals;
|
|
75
|
+
const facts = captureSessionFactsDelta("claude_code", (acc) => {
|
|
76
|
+
signals = extractClaudeSessionSignals(content, acc);
|
|
77
|
+
observeSessionFactsFile(acc, { bytes: raw.byteLength, lines: signals.line_count });
|
|
78
|
+
});
|
|
79
|
+
const read = { signals: signals, byte_size: raw.byteLength, sha256: hash, facts };
|
|
80
|
+
if (memo && stamp) {
|
|
81
|
+
memo.note("fresh");
|
|
82
|
+
memo.remember(filePath, stamp, ATTRIBUTION_READ_RECIPE, read);
|
|
83
|
+
}
|
|
84
|
+
return read;
|
|
85
|
+
}
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { finalizeSessionFacts,
|
|
2
|
-
import crypto from "node:crypto";
|
|
1
|
+
import { finalizeSessionFacts, mergeSessionFactsDelta, newSessionFactsAccumulator, observeSessionFactsFile, } from "@bli-cockpit/telemetry-core";
|
|
3
2
|
import { existsSync } from "node:fs";
|
|
4
|
-
import fs from "node:fs/promises";
|
|
5
3
|
import path from "node:path";
|
|
6
4
|
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
7
5
|
import { describeError } from "../health-detail.js";
|
|
8
6
|
import { isPathWithin, sanitizeSessionId, scoreSignalsAgainstWorktrees, sessionIdFromFileName, shortHash, } from "./attribution-core.js";
|
|
9
|
-
import {
|
|
7
|
+
import { isSchemaDriftSuspected, streamMainSignals } from "./claude-attribution-signals.js";
|
|
8
|
+
import { readMain, readSidecar } from "./claude-attribution-memoized-reads.js";
|
|
10
9
|
import { CLAUDE_SESSION_MAX_FILE_BYTES, } from "./claude-attribution-types.js";
|
|
11
10
|
/**
|
|
12
11
|
* Turns one discovered session into an attribution verdict: read (or, for an
|
|
@@ -18,7 +17,7 @@ import { CLAUDE_SESSION_MAX_FILE_BYTES, } from "./claude-attribution-types.js";
|
|
|
18
17
|
* are counted: a sidecar rejected for a cwd mismatch is another worktree's
|
|
19
18
|
* work and must not inflate this session's numbers.
|
|
20
19
|
*/
|
|
21
|
-
export async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
20
|
+
export async function attributeOneSession(session, worktrees, collectionRoots, readMemo) {
|
|
22
21
|
const factsAcc = newSessionFactsAccumulator("claude_code");
|
|
23
22
|
const base = createSessionBase(session);
|
|
24
23
|
if (session.mainByteSize === 0) {
|
|
@@ -26,7 +25,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
|
26
25
|
}
|
|
27
26
|
const read = session.mainByteSize > CLAUDE_SESSION_MAX_FILE_BYTES
|
|
28
27
|
? await streamOversizedMain(session, base, factsAcc)
|
|
29
|
-
: await readMainSignals(session, base, factsAcc);
|
|
28
|
+
: await readMainSignals(session, base, factsAcc, readMemo);
|
|
30
29
|
if (typeof read === "string")
|
|
31
30
|
return skippedResult(base, read);
|
|
32
31
|
const signals = read;
|
|
@@ -54,7 +53,7 @@ export async function attributeOneSession(session, worktrees, collectionRoots) {
|
|
|
54
53
|
extraSignals.push("main_file_oversized");
|
|
55
54
|
if (isSchemaDriftSuspected(signals))
|
|
56
55
|
extraSignals.push("schema_drift_suspected");
|
|
57
|
-
const sidecarFiles = await collectSessionSidecars(session, outcome, factsAcc);
|
|
56
|
+
const sidecarFiles = await collectSessionSidecars(session, outcome, factsAcc, readMemo);
|
|
58
57
|
return {
|
|
59
58
|
...base,
|
|
60
59
|
session_facts: finalizeSessionFacts(factsAcc),
|
|
@@ -116,10 +115,10 @@ async function streamOversizedMain(session, base, factsAcc) {
|
|
|
116
115
|
base.oversized_lines_skipped = streamed.oversizedLinesSkipped;
|
|
117
116
|
return streamed.signals;
|
|
118
117
|
}
|
|
119
|
-
async function readMainSignals(session, base, factsAcc) {
|
|
120
|
-
let
|
|
118
|
+
async function readMainSignals(session, base, factsAcc, readMemo) {
|
|
119
|
+
let read;
|
|
121
120
|
try {
|
|
122
|
-
|
|
121
|
+
read = await readMain(readMemo, session.mainFile);
|
|
123
122
|
}
|
|
124
123
|
catch (error) {
|
|
125
124
|
console.error("[claude-attribution] session file unreadable, skipping it", JSON.stringify({
|
|
@@ -130,18 +129,12 @@ async function readMainSignals(session, base, factsAcc) {
|
|
|
130
129
|
}));
|
|
131
130
|
return "file_read_failed";
|
|
132
131
|
}
|
|
133
|
-
|
|
134
|
-
base.
|
|
135
|
-
|
|
136
|
-
if (content.trim() === "") {
|
|
132
|
+
base.content_hash_sha256 = read.sha256;
|
|
133
|
+
base.byte_size = read.byte_size;
|
|
134
|
+
if ("empty" in read)
|
|
137
135
|
return "empty_file";
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
observeSessionFactsFile(factsAcc, {
|
|
141
|
-
bytes: raw.byteLength,
|
|
142
|
-
lines: signals.line_count,
|
|
143
|
-
});
|
|
144
|
-
return signals;
|
|
136
|
+
mergeSessionFactsDelta(factsAcc, read.facts);
|
|
137
|
+
return read.signals;
|
|
145
138
|
}
|
|
146
139
|
function applySessionMetadata(base, signals) {
|
|
147
140
|
const metaSessionId = sanitizeSessionId(signals.session_ids[0]);
|
|
@@ -155,9 +148,9 @@ function applySessionMetadata(base, signals) {
|
|
|
155
148
|
base.cwd_hash = shortHash(primaryCwd);
|
|
156
149
|
}
|
|
157
150
|
}
|
|
158
|
-
async function collectSessionSidecars(session, outcome, factsAcc) {
|
|
151
|
+
async function collectSessionSidecars(session, outcome, factsAcc, readMemo) {
|
|
159
152
|
return isRawEvidenceUploadableAttributionState(outcome.state, outcome.worktree !== null) && outcome.worktree
|
|
160
|
-
? await collectSidecarDiagnostics(session.sidecars, outcome.worktree, factsAcc)
|
|
153
|
+
? await collectSidecarDiagnostics(session.sidecars, outcome.worktree, factsAcc, readMemo)
|
|
161
154
|
: session.sidecars.map((sidecar) => ({
|
|
162
155
|
local_path: sidecar.local_path,
|
|
163
156
|
file_name: sidecar.file_name,
|
|
@@ -174,7 +167,7 @@ async function collectSessionSidecars(session, outcome, factsAcc) {
|
|
|
174
167
|
* oversized sidecar is recorded with a reason and the session stays
|
|
175
168
|
* harvestable.
|
|
176
169
|
*/
|
|
177
|
-
async function collectSidecarDiagnostics(sidecars, worktree, facts) {
|
|
170
|
+
async function collectSidecarDiagnostics(sidecars, worktree, facts, readMemo) {
|
|
178
171
|
const out = [];
|
|
179
172
|
for (const sidecar of sidecars) {
|
|
180
173
|
const entry = {
|
|
@@ -188,9 +181,9 @@ async function collectSidecarDiagnostics(sidecars, worktree, facts) {
|
|
|
188
181
|
out.push({ ...entry, skipped_reason: "file_too_large" });
|
|
189
182
|
continue;
|
|
190
183
|
}
|
|
191
|
-
let
|
|
184
|
+
let read;
|
|
192
185
|
try {
|
|
193
|
-
|
|
186
|
+
read = await readSidecar(readMemo, sidecar.local_path);
|
|
194
187
|
}
|
|
195
188
|
catch (error) {
|
|
196
189
|
// `file_read_failed` stays on the sidecar row. Beside it: a subagent
|
|
@@ -205,8 +198,7 @@ async function collectSidecarDiagnostics(sidecars, worktree, facts) {
|
|
|
205
198
|
out.push({ ...entry, skipped_reason: "file_read_failed" });
|
|
206
199
|
continue;
|
|
207
200
|
}
|
|
208
|
-
const
|
|
209
|
-
const sidecarCwds = extractClaudeSessionSignals(content).cwds;
|
|
201
|
+
const sidecarCwds = read.cwds;
|
|
210
202
|
if (sidecarCwds.length > 0 &&
|
|
211
203
|
!sidecarCwds.some((cwd) => isPathWithin(cwd, worktree.repo_root))) {
|
|
212
204
|
out.push({ ...entry, skipped_reason: "sidecar_cwd_mismatch" });
|
|
@@ -214,11 +206,11 @@ async function collectSidecarDiagnostics(sidecars, worktree, facts) {
|
|
|
214
206
|
}
|
|
215
207
|
// Accepted: this subagent's turns are part of this session, so its tokens
|
|
216
208
|
// are too. Counted only now, after the cwd check has cleared it.
|
|
217
|
-
|
|
209
|
+
mergeSessionFactsDelta(facts, read.facts);
|
|
218
210
|
out.push({
|
|
219
211
|
...entry,
|
|
220
|
-
byte_size:
|
|
221
|
-
content_hash_sha256: sha256
|
|
212
|
+
byte_size: read.byte_size,
|
|
213
|
+
content_hash_sha256: read.sha256,
|
|
222
214
|
});
|
|
223
215
|
}
|
|
224
216
|
return out;
|
|
@@ -233,7 +225,4 @@ function skippedResult(base, reason) {
|
|
|
233
225
|
path_score: 0,
|
|
234
226
|
worktree: null,
|
|
235
227
|
};
|
|
236
|
-
}
|
|
237
|
-
function sha256(value) {
|
|
238
|
-
return crypto.createHash("sha256").update(value).digest("hex");
|
|
239
228
|
}
|
|
@@ -37,7 +37,7 @@ export async function scanAndAttributeClaudeSessions(options) {
|
|
|
37
37
|
.slice(0, limit);
|
|
38
38
|
const results = [];
|
|
39
39
|
for (const session of sessions) {
|
|
40
|
-
results.push(await attributeOneSession(session, options.worktrees, options.collectionRoots ?? []));
|
|
40
|
+
results.push(await attributeOneSession(session, options.worktrees, options.collectionRoots ?? [], options.readMemo));
|
|
41
41
|
}
|
|
42
42
|
const sumBy = (pick) => results.reduce((total, result) => total + pick(result), 0);
|
|
43
43
|
const countState = (state) => results.filter((result) => result.state === state).length;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
* collection is done.
|
|
25
25
|
*/
|
|
26
26
|
import { openPrepareMemo } from "./raw-evidence-prepare-memo.js";
|
|
27
|
+
import { openTranscriptReadMemo } from "./transcript-read-memo.js";
|
|
27
28
|
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
28
29
|
import crypto from "node:crypto";
|
|
29
30
|
import fs from "node:fs/promises";
|
|
@@ -65,6 +66,7 @@ export async function openCollection(context, options, places) {
|
|
|
65
66
|
budget.images ??= options.images;
|
|
66
67
|
const stagingAllowance = (budget.staging ??= await openStagingAllowance(options.stateDir, options.env ?? process.env, { statfs: options.statfs }));
|
|
67
68
|
budget.prepareMemo ??= await openPrepareMemo(options.stateDir);
|
|
69
|
+
budget.readMemo ??= await openTranscriptReadMemo(options.stateDir, "image_scan").catch(() => undefined);
|
|
68
70
|
return {
|
|
69
71
|
context,
|
|
70
72
|
filesDir: path.join(places.stagingDir, "files"),
|
|
@@ -9,18 +9,42 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { collectAgentImageEvidenceFromJsonlFile, } from "./agent-image-evidence.js";
|
|
12
|
+
import { statSourceForMemo } from "./transcript-read-memo.js";
|
|
13
|
+
import { AGENT_IMAGE_MAX_BYTES } from "./agent-image-validation.js";
|
|
12
14
|
import { evidenceSourceKey } from "../raw-evidence-staging.js";
|
|
13
15
|
import { sha256 } from "./raw-evidence-keys.js";
|
|
14
16
|
import { markBudgetCapApplied, recordScanned, } from "./raw-evidence-completeness.js";
|
|
15
17
|
import { evidenceEntry } from "./raw-evidence-manifest.js";
|
|
16
18
|
import { admitEvidence, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
|
|
19
|
+
/**
|
|
20
|
+
* BLI-22797: a transcript with no image candidate is remembered by its stamp, so the next tick does not open it.
|
|
21
|
+
* Only that answer is remembered: an image whose bytes live in another file can change while the transcript does
|
|
22
|
+
* not, so a transcript that offered images is always read again. Bump the version when
|
|
23
|
+
* `agent-image-records.ts` changes what counts as a candidate.
|
|
24
|
+
*/
|
|
25
|
+
const IMAGE_SCAN_RECIPE = ["image-scan.v1", String(AGENT_IMAGE_MAX_BYTES)].join("|");
|
|
17
26
|
export async function collectAgentImagesFromTranscript(collection, options) {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
const memo = collection.budget.readMemo;
|
|
28
|
+
const stamp = memo ? await statSourceForMemo(options.filePath) : null;
|
|
29
|
+
const remembered = memo && stamp
|
|
30
|
+
? memo.recall(options.filePath, stamp, IMAGE_SCAN_RECIPE, stamp.size, (data) => (data === "no_image_candidates" ? true : null))
|
|
31
|
+
: null;
|
|
32
|
+
if (!remembered && memo && !stamp)
|
|
33
|
+
memo.note("stamp_missing");
|
|
34
|
+
const result = remembered
|
|
35
|
+
? { images: [], skipped: [] }
|
|
36
|
+
: await collectAgentImageEvidenceFromJsonlFile({
|
|
37
|
+
filePath: options.filePath,
|
|
38
|
+
source: options.source,
|
|
39
|
+
sessionId: options.sessionId,
|
|
40
|
+
sidecarId: options.sidecarId,
|
|
41
|
+
});
|
|
42
|
+
if (!remembered && memo && stamp) {
|
|
43
|
+
memo.note("fresh");
|
|
44
|
+
if (result.images.length === 0 && result.skipped.length === 0) {
|
|
45
|
+
memo.remember(options.filePath, stamp, IMAGE_SCAN_RECIPE, "no_image_candidates");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
24
48
|
recordScanned(collection, options.kind, result.images.length + result.skipped.length);
|
|
25
49
|
for (const skipped of result.skipped) {
|
|
26
50
|
collection.skipped.push({
|
|
@@ -40,9 +40,9 @@ const MEMO_FORMAT = "prepare-memo.v1";
|
|
|
40
40
|
/** About 700 bytes an entry, so this is at most a few MB; the oldest leave first. */
|
|
41
41
|
export const PREPARE_MEMO_MAX_ENTRIES = 20_000;
|
|
42
42
|
/** A source not seen for this long is a deleted or rotated session. */
|
|
43
|
-
const FORGET_AFTER_MS = 14 * 24 * 3_600_000;
|
|
43
|
+
export const FORGET_AFTER_MS = 14 * 24 * 3_600_000;
|
|
44
44
|
/** Refresh `seen_ms` at most this often, so an unchanged tick does not rewrite the file. */
|
|
45
|
-
const SEEN_REFRESH_MS = 24 * 3_600_000;
|
|
45
|
+
export const SEEN_REFRESH_MS = 24 * 3_600_000;
|
|
46
46
|
const TAG = "[raw-evidence-memo]";
|
|
47
47
|
export function sourceStampOf(stat) {
|
|
48
48
|
return {
|
|
@@ -146,7 +146,7 @@ export async function openPrepareMemo(stateDir) {
|
|
|
146
146
|
* use. Only those: an entry nobody recalls (a deleted or rotated session) ages out after
|
|
147
147
|
* FORGET_AFTER_MS.
|
|
148
148
|
*/
|
|
149
|
-
function refreshSeen(entries, recalled, now) {
|
|
149
|
+
export function refreshSeen(entries, recalled, now) {
|
|
150
150
|
let touched = false;
|
|
151
151
|
for (const key of recalled) {
|
|
152
152
|
const entry = entries.get(key);
|
|
@@ -157,13 +157,13 @@ function refreshSeen(entries, recalled, now) {
|
|
|
157
157
|
}
|
|
158
158
|
return touched;
|
|
159
159
|
}
|
|
160
|
-
function trimEntries(entries, now) {
|
|
160
|
+
export function trimEntries(entries, now, maxEntries = PREPARE_MEMO_MAX_ENTRIES) {
|
|
161
161
|
for (const [key, entry] of entries) {
|
|
162
162
|
if (now - entry.seen_ms > FORGET_AFTER_MS)
|
|
163
163
|
entries.delete(key);
|
|
164
164
|
}
|
|
165
165
|
const newestFirst = [...entries.entries()].sort((a, b) => b[1].seen_ms - a[1].seen_ms);
|
|
166
|
-
const kept = newestFirst.slice(0,
|
|
166
|
+
const kept = newestFirst.slice(0, maxEntries);
|
|
167
167
|
if (kept.length < entries.size) {
|
|
168
168
|
entries.clear();
|
|
169
169
|
for (const [key, entry] of kept)
|
|
@@ -106,6 +106,7 @@ export async function collectRawEvidencePack(context, options) {
|
|
|
106
106
|
* made it possible. Counts and bytes only. Never throws: the memo is a cache.
|
|
107
107
|
*/
|
|
108
108
|
async function reportPrepareMemo(collection) {
|
|
109
|
+
await collection.budget.readMemo?.settle();
|
|
109
110
|
const counts = await collection.budget.prepareMemo?.settle();
|
|
110
111
|
if (!counts || counts.skipped_count + counts.rebuilt_count + counts.volatile_reused_count === 0)
|
|
111
112
|
return;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remember what a transcript file said, so an unchanged file is not opened again (BLI-22797).
|
|
3
|
+
*
|
|
4
|
+
* A sync tick with nothing new still re-read every attributed session, every subagent sidecar and every
|
|
5
|
+
* transcript the image step scans: on the reference Mac 33 of the 37 seconds of main-thread JavaScript went to
|
|
6
|
+
* reading, parsing and hashing files whose size and mtime had not moved since the tick before. This is the
|
|
7
|
+
* sibling of the prepare memo (`raw-evidence-prepare-memo.ts`) and uses ITS stamp: size plus inode, mtime and
|
|
8
|
+
* ctime in nanoseconds, so an append, rewrite or replace is never mistaken for "unchanged". The entry cap, the
|
|
9
|
+
* `seen_ms` ageing and the atomic write are the prepare memo's own helpers.
|
|
10
|
+
*
|
|
11
|
+
* An entry is trusted only when the stamp AND the recipe match. The recipe names the rules that produced the
|
|
12
|
+
* remembered answer, so changing a rule rebuilds the memo once. What an entry holds is decided by its caller
|
|
13
|
+
* (`data`); this file never sees a byte of transcript content, and neither does the file on disk: signals,
|
|
14
|
+
* hashes, sizes and counts only. A memo that is lost, corrupt or stale in the safe direction costs one slow
|
|
15
|
+
* tick, never a wrong answer. The file path is hashed, so no path reaches the memo either.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { isMissingFileFailure } from "../health-detail.js";
|
|
20
|
+
import { readJsonFile, writeJsonFileAtomic } from "../local-state-files.js";
|
|
21
|
+
import { PREPARE_MEMO_MAX_ENTRIES, refreshSeen, trimEntries, } from "./raw-evidence-prepare-memo.js";
|
|
22
|
+
export { sourceStampOf, statSourceForMemo } from "./raw-evidence-prepare-memo.js";
|
|
23
|
+
const MEMO_FORMAT = "transcript-read-memo.v1";
|
|
24
|
+
const TAG = "[transcript-reads]";
|
|
25
|
+
export function transcriptReadMemoPath(stateDir, name) {
|
|
26
|
+
return path.join(stateDir, `transcript-read-memo-${name}.json`);
|
|
27
|
+
}
|
|
28
|
+
const keyOf = (filePath) => createHash("sha256").update(filePath).digest("hex").slice(0, 32);
|
|
29
|
+
export async function openTranscriptReadMemo(stateDir, name) {
|
|
30
|
+
const entries = await readEntries(stateDir, name);
|
|
31
|
+
let dirty = false;
|
|
32
|
+
let counts = { reused: 0, fresh: 0, stamp_missing: 0, bytes_not_read: 0 };
|
|
33
|
+
const recalled = new Set();
|
|
34
|
+
return {
|
|
35
|
+
recall(filePath, stamp, recipe, bytes, parse) {
|
|
36
|
+
const key = keyOf(filePath);
|
|
37
|
+
const entry = entries.get(key);
|
|
38
|
+
if (!entry || entry.recipe !== recipe || entry.size !== stamp.size || entry.identity !== stamp.identity)
|
|
39
|
+
return null;
|
|
40
|
+
const data = parse(entry.data);
|
|
41
|
+
if (data === null)
|
|
42
|
+
return null;
|
|
43
|
+
recalled.add(key);
|
|
44
|
+
counts.reused += 1;
|
|
45
|
+
counts.bytes_not_read += bytes;
|
|
46
|
+
return data;
|
|
47
|
+
},
|
|
48
|
+
remember(filePath, stamp, recipe, data, now = Date.now()) {
|
|
49
|
+
entries.set(keyOf(filePath), { ...stamp, recipe, data, seen_ms: now });
|
|
50
|
+
dirty = true;
|
|
51
|
+
},
|
|
52
|
+
note(outcome) {
|
|
53
|
+
if (outcome === "fresh")
|
|
54
|
+
counts.fresh += 1;
|
|
55
|
+
else
|
|
56
|
+
counts.stamp_missing += 1;
|
|
57
|
+
},
|
|
58
|
+
async settle(now = Date.now()) {
|
|
59
|
+
const done = counts;
|
|
60
|
+
counts = { reused: 0, fresh: 0, stamp_missing: 0, bytes_not_read: 0 };
|
|
61
|
+
dirty = refreshSeen(entries, recalled, now) || dirty;
|
|
62
|
+
recalled.clear();
|
|
63
|
+
let written = false;
|
|
64
|
+
let reason = done.reused > 0 ? "stamp_unchanged" : done.fresh > 0 ? "no_reusable_entry" : "stamp_missing";
|
|
65
|
+
if (dirty) {
|
|
66
|
+
const before = entries.size;
|
|
67
|
+
const kept = trimEntries(entries, now, PREPARE_MEMO_MAX_ENTRIES);
|
|
68
|
+
try {
|
|
69
|
+
await writeJsonFileAtomic(transcriptReadMemoPath(stateDir, name), { schema_version: MEMO_FORMAT, entries: Object.fromEntries(kept) });
|
|
70
|
+
dirty = false;
|
|
71
|
+
written = true;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
reason = "memo_write_failed";
|
|
75
|
+
console.error(TAG + " memo not written; the next tick reads these files again", JSON.stringify({ memo: name, reason, entries: before, error_name: error instanceof Error ? error.name : typeof error }));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (done.reused + done.fresh + done.stamp_missing === 0)
|
|
79
|
+
return;
|
|
80
|
+
console.error(TAG + " tick", JSON.stringify({ memo: name, reason, reused: done.reused, read_fresh: done.fresh, stamp_missing: done.stamp_missing, bytes_not_read: done.bytes_not_read, entries: entries.size, memo_written: written }));
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
async function readEntries(stateDir, name) {
|
|
85
|
+
const entries = new Map();
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = await readJsonFile(transcriptReadMemoPath(stateDir, name));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (!isMissingFileFailure(error))
|
|
92
|
+
console.error(TAG + " memo unreadable; every file is read once", JSON.stringify({ memo: name, reason: "read_memo_unreadable" }));
|
|
93
|
+
return entries;
|
|
94
|
+
}
|
|
95
|
+
const stored = raw;
|
|
96
|
+
if (stored?.schema_version !== MEMO_FORMAT || !stored.entries || typeof stored.entries !== "object")
|
|
97
|
+
return entries;
|
|
98
|
+
let dropped = 0;
|
|
99
|
+
for (const [key, value] of Object.entries(stored.entries)) {
|
|
100
|
+
const e = value;
|
|
101
|
+
if (e && typeof e.size === "number" && typeof e.identity === "string" && typeof e.recipe === "string" && typeof e.seen_ms === "number" && e.data !== undefined)
|
|
102
|
+
entries.set(key, e);
|
|
103
|
+
else
|
|
104
|
+
dropped += 1;
|
|
105
|
+
}
|
|
106
|
+
if (dropped > 0)
|
|
107
|
+
console.error(TAG + " memo entries dropped as malformed", JSON.stringify({ memo: name, reason: "read_memo_entries_malformed", dropped, kept: entries.size }));
|
|
108
|
+
return entries;
|
|
109
|
+
}
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.253");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -15,6 +15,7 @@ import { CLAUDE_CURSOR_FILENAME } from "../cursors/raw-evidence-cursor.js";
|
|
|
15
15
|
import { clearSourceRetryFailure, recordSourceRetryFailure, } from "../spool/local-spool.js";
|
|
16
16
|
import { claudeAttributionReadFailureCount, codexAttributionReadFailureCount, } from "./agent-session-report.js";
|
|
17
17
|
import { widenForCatchUp } from "./session-sync-outside-roots.js";
|
|
18
|
+
import { openTranscriptReadMemo } from "../adapters/transcript-read-memo.js";
|
|
18
19
|
const CLAUDE_FIRST_RUN_BACKFILL_MINUTES = 14 * 24 * 60;
|
|
19
20
|
/** The Claude scan's own default window when nothing widens it: the last 24 hours. */
|
|
20
21
|
const CLAUDE_DEFAULT_WINDOW_MINUTES = 24 * 60;
|
|
@@ -51,6 +52,7 @@ export async function scanAndAttributeBothSources(options) {
|
|
|
51
52
|
retrySinceMinutes: plan.claudeRetrySinceMinutes,
|
|
52
53
|
firstRunBackfill,
|
|
53
54
|
outsideRootCatchUpPending: plan.outsideRootCatchUpPending,
|
|
55
|
+
stateDir: options.paths.state_dir,
|
|
54
56
|
});
|
|
55
57
|
if (plan.codexEnabled) {
|
|
56
58
|
await reconcileSourceScanRetry({
|
|
@@ -125,24 +127,33 @@ async function scanClaudeSessionsForSync(options) {
|
|
|
125
127
|
? CLAUDE_FIRST_RUN_BACKFILL_MINUTES
|
|
126
128
|
: undefined, options.outsideRootCatchUpPending, CLAUDE_DEFAULT_WINDOW_MINUTES);
|
|
127
129
|
const projectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
collectionRoots: options.collectionRoots,
|
|
133
|
-
sinceMinutes,
|
|
134
|
-
});
|
|
135
|
-
if (scan.session_limit_applied) {
|
|
136
|
-
scan = await scanAndAttributeClaudeSessions({
|
|
130
|
+
// BLI-22797: one memo for both passes, settled once, so an unchanged file is never opened.
|
|
131
|
+
const readMemo = await openTranscriptReadMemo(options.stateDir, "attribution").catch(() => undefined);
|
|
132
|
+
try {
|
|
133
|
+
let scan = await scanAndAttributeClaudeSessions({
|
|
137
134
|
projectsDir,
|
|
138
135
|
worktrees: options.worktrees,
|
|
139
136
|
now: options.now,
|
|
140
137
|
collectionRoots: options.collectionRoots,
|
|
141
138
|
sinceMinutes,
|
|
142
|
-
|
|
139
|
+
readMemo,
|
|
143
140
|
});
|
|
141
|
+
if (scan.session_limit_applied) {
|
|
142
|
+
scan = await scanAndAttributeClaudeSessions({
|
|
143
|
+
projectsDir,
|
|
144
|
+
worktrees: options.worktrees,
|
|
145
|
+
now: options.now,
|
|
146
|
+
collectionRoots: options.collectionRoots,
|
|
147
|
+
sinceMinutes,
|
|
148
|
+
limit: scan.discovered_session_count,
|
|
149
|
+
readMemo,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return scan;
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await readMemo?.settle();
|
|
144
156
|
}
|
|
145
|
-
return scan;
|
|
146
157
|
}
|
|
147
158
|
export function sourceScanRetryReason(source, scan) {
|
|
148
159
|
const reasons = new Set();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.253",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.75",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.156",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.82"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|