@faithfulalabi/agent-lens 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +124 -0
  3. package/bin/agent-lens.js +40 -0
  4. package/bin/package.json +4 -0
  5. package/dist/src/archive/cron-log.js +41 -0
  6. package/dist/src/archive/discover.js +89 -0
  7. package/dist/src/archive/index.js +7 -0
  8. package/dist/src/archive/lock.js +119 -0
  9. package/dist/src/archive/log.js +15 -0
  10. package/dist/src/archive/mirror.js +366 -0
  11. package/dist/src/archive/paths.js +159 -0
  12. package/dist/src/archive/read.js +133 -0
  13. package/dist/src/archive/report.js +272 -0
  14. package/dist/src/archive/seal.js +76 -0
  15. package/dist/src/archive/sidecar.js +39 -0
  16. package/dist/src/cli/args.js +47 -0
  17. package/dist/src/cli/commands/archive.js +65 -0
  18. package/dist/src/cli/commands/doctor.js +191 -0
  19. package/dist/src/cli/commands/prune.js +159 -0
  20. package/dist/src/cli/commands/rebuild.js +98 -0
  21. package/dist/src/cli/commands/schedule.js +325 -0
  22. package/dist/src/cli/commands/start.js +83 -0
  23. package/dist/src/cli/commands/warm.js +96 -0
  24. package/dist/src/cli/index.js +102 -0
  25. package/dist/src/content/resolve.js +163 -0
  26. package/dist/src/corpus/env.js +32 -0
  27. package/dist/src/corpus/paths.js +70 -0
  28. package/dist/src/corpus/scan.js +85 -0
  29. package/dist/src/corpus/watch.js +189 -0
  30. package/dist/src/db/freshness.js +82 -0
  31. package/dist/src/db/open.js +74 -0
  32. package/dist/src/db/read.js +266 -0
  33. package/dist/src/db/schema.js +312 -0
  34. package/dist/src/db/sidecars.js +216 -0
  35. package/dist/src/db/spill-index.js +68 -0
  36. package/dist/src/db/write.js +279 -0
  37. package/dist/src/project/pipeline.js +307 -0
  38. package/dist/src/project/subagents.js +41 -0
  39. package/dist/src/project/tools.js +94 -0
  40. package/dist/src/server/api.js +249 -0
  41. package/dist/src/server/app.js +28 -0
  42. package/dist/src/server/config.js +24 -0
  43. package/dist/src/server/drift-report.js +35 -0
  44. package/dist/src/server/index.js +1 -0
  45. package/dist/src/server/live.js +109 -0
  46. package/dist/src/server/middleware/host-guard.js +42 -0
  47. package/dist/src/server/middleware/token-auth.js +19 -0
  48. package/dist/src/server/start.js +150 -0
  49. package/dist/src/server/static-ui.js +97 -0
  50. package/dist/src/server/stream.js +50 -0
  51. package/dist/src/server/warm.js +48 -0
  52. package/dist/src/shared/api.js +1 -0
  53. package/dist/src/shared/entities.js +1 -0
  54. package/dist/src/shared/index.js +2 -0
  55. package/dist/src/shared/pricing.js +68 -0
  56. package/dist/src/shared/token.js +39 -0
  57. package/dist/src/transcript/accessors.js +28 -0
  58. package/dist/src/transcript/agents.js +44 -0
  59. package/dist/src/transcript/blocks.js +75 -0
  60. package/dist/src/transcript/drift.js +42 -0
  61. package/dist/src/transcript/human.js +65 -0
  62. package/dist/src/transcript/line.js +251 -0
  63. package/dist/src/transcript/raw-types.js +1 -0
  64. package/dist/src/transcript/spill.js +122 -0
  65. package/dist/src/transcript/usage.js +63 -0
  66. package/dist/src/transcript/version.js +1 -0
  67. package/package.json +70 -0
  68. package/ui/dist/assets/index-CKKoKUCq.js +254 -0
  69. package/ui/dist/assets/index-Chza4fL6.css +1 -0
  70. package/ui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  71. package/ui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  72. package/ui/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  73. package/ui/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  74. package/ui/dist/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  75. package/ui/dist/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  76. package/ui/dist/index.html +21 -0
@@ -0,0 +1,272 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, openSync, readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ import { discover } from './discover.js';
6
+ import { detectDivergence } from './mirror.js';
7
+ import { canonicalizeTranscriptPath, lstatSafe, resolveArchiveRoot, resolveDataDir, resolveTranscriptRoot, statSafe, } from './paths.js';
8
+ import { DEFAULT_MAX_BYTES, declaredContentSize, decompressSealedFrame } from './read.js';
9
+ import { readSidecar, sidecarPath } from './sidecar.js';
10
+ const CHUNK_BYTES = 1024 * 1024;
11
+ const SEALED_SUFFIX = '.zst';
12
+ export const NO_LIVE_SOURCE_REASON = 'no live source — no stored hash exists';
13
+ export const SEALED_LEGACY_REASON = 'sealed — no stored hash on disk: sealed before sidecars existed, and there is nothing honest to backfill';
14
+ export const SEALED_UNCHECKED_REASON = 'sealed — stored hash present but not read: pass --verify to re-hash the archived bytes';
15
+ export function resolveClaudeSettingsPath(path) {
16
+ return (path ?? process.env.AGENT_LENS_CLAUDE_SETTINGS ?? join(homedir(), '.claude', 'settings.json'));
17
+ }
18
+ export function readRetentionSetting(path) {
19
+ let text;
20
+ try {
21
+ text = readFileSync(path, 'utf8');
22
+ }
23
+ catch (error) {
24
+ if (error.code === 'ENOENT')
25
+ return { state: 'absent' };
26
+ return { state: 'unreadable', message: String(error.message ?? error) };
27
+ }
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(text);
31
+ }
32
+ catch (error) {
33
+ return { state: 'unreadable', message: String(error.message ?? error) };
34
+ }
35
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
36
+ return { state: 'unreadable', message: 'settings.json is not a JSON object' };
37
+ }
38
+ const days = parsed.cleanupPeriodDays;
39
+ if (typeof days !== 'number' || !Number.isFinite(days))
40
+ return { state: 'unset' };
41
+ return { state: 'set', days };
42
+ }
43
+ function archiveDiskPath(archivePath, sealed) {
44
+ return sealed ? `${archivePath}${SEALED_SUFFIX}` : archivePath;
45
+ }
46
+ function compareToSource(params) {
47
+ const sourceFd = openSync(params.sourcePath, 'r');
48
+ try {
49
+ const archiveFd = openSync(params.diskPath, 'r');
50
+ try {
51
+ return detectDivergence({
52
+ sourceFd,
53
+ archiveFd,
54
+ sourceSize: params.sourceSize,
55
+ archiveSize: params.archiveSize,
56
+ verify: params.verify,
57
+ buffer: params.buffer,
58
+ });
59
+ }
60
+ finally {
61
+ closeSync(archiveFd);
62
+ }
63
+ }
64
+ finally {
65
+ closeSync(sourceFd);
66
+ }
67
+ }
68
+ function sha256(buf) {
69
+ return createHash('sha256').update(buf).digest('hex');
70
+ }
71
+ function loadSealedFrame(diskPath) {
72
+ const frame = readFileSync(diskPath);
73
+ const declared = declaredContentSize(frame, diskPath);
74
+ if (declared > DEFAULT_MAX_BYTES)
75
+ return { state: 'over-bound', declared };
76
+ const buf = decompressSealedFrame(frame, declared, diskPath, DEFAULT_MAX_BYTES);
77
+ return { state: 'loaded', buf, declared };
78
+ }
79
+ function classifySealedThrow(error) {
80
+ const code = error.code;
81
+ if (typeof code === 'string' && /^E[A-Z0-9]+$/.test(code)) {
82
+ return {
83
+ state: 'unverifiable',
84
+ reason: `unreadable — ${String(error.message ?? error)}`,
85
+ };
86
+ }
87
+ return { state: 'diverged', reason: 'sealed-frame' };
88
+ }
89
+ function checkSealed(params) {
90
+ const legacy = {
91
+ verdict: { state: 'unverifiable', reason: SEALED_LEGACY_REASON },
92
+ bytesRead: 0,
93
+ };
94
+ const sidecar = sidecarPath(params.diskPath);
95
+ if (lstatSafe(sidecar)?.isFile() !== true)
96
+ return legacy;
97
+ const record = readSidecar(sidecar);
98
+ if (record === undefined)
99
+ return legacy;
100
+ if (record.file !== params.logicalName) {
101
+ return { verdict: { state: 'diverged', reason: 'sealed-attribution' }, bytesRead: 0 };
102
+ }
103
+ if (record.sealed_size !== params.archiveSize) {
104
+ return { verdict: { state: 'diverged', reason: 'sealed-size' }, bytesRead: 0 };
105
+ }
106
+ if (!params.verify) {
107
+ return {
108
+ verdict: { state: 'unverifiable', reason: SEALED_UNCHECKED_REASON },
109
+ bytesRead: 0,
110
+ };
111
+ }
112
+ let frame;
113
+ try {
114
+ frame = loadSealedFrame(params.diskPath);
115
+ }
116
+ catch (error) {
117
+ return { verdict: classifySealedThrow(error), bytesRead: 0 };
118
+ }
119
+ if (frame.state === 'over-bound') {
120
+ return {
121
+ verdict: {
122
+ state: 'unverifiable',
123
+ reason: `unreadable — ${params.diskPath} declares ${frame.declared} bytes, over the ${DEFAULT_MAX_BYTES}-byte bound`,
124
+ },
125
+ bytesRead: 0,
126
+ };
127
+ }
128
+ const bytesRead = frame.buf.length;
129
+ if (sha256(frame.buf) !== record.sha256) {
130
+ return { verdict: { state: 'diverged', reason: 'sealed-hash' }, bytesRead };
131
+ }
132
+ return { verdict: { state: 'verified' }, bytesRead };
133
+ }
134
+ export function buildDoctorReport(options = {}) {
135
+ const dataDir = resolveDataDir(options.dataDir);
136
+ const sourceRoot = canonicalizeTranscriptPath(resolveTranscriptRoot(options.transcriptRoot));
137
+ const archiveRoot = canonicalizeTranscriptPath(resolveArchiveRoot(dataDir));
138
+ const settingsPath = resolveClaudeSettingsPath(options.settingsPath);
139
+ const verify = options.verify === true;
140
+ const coverage = { found: 0, mirrored: 0, unmirrored: [], archiveOnly: 0 };
141
+ const bytes = {
142
+ hotFiles: 0,
143
+ hotBytes: 0,
144
+ sealedFiles: 0,
145
+ sealedBytes: 0,
146
+ totalBytes: 0,
147
+ };
148
+ const integrity = {
149
+ verify,
150
+ bytesRead: 0,
151
+ archivedFileCount: 0,
152
+ verified: [],
153
+ diverged: [],
154
+ unverifiable: [],
155
+ };
156
+ const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
157
+ for (const entry of discover(sourceRoot, archiveRoot)) {
158
+ const diskPath = archiveDiskPath(entry.archivePath, entry.sealed);
159
+ const archiveStat = lstatSafe(diskPath);
160
+ const archiveSize = archiveStat?.isFile() === true ? archiveStat.size : 0;
161
+ const archived = archiveSize > 0;
162
+ if (entry.presence === 'archive-only') {
163
+ coverage.archiveOnly += 1;
164
+ }
165
+ else {
166
+ coverage.found += 1;
167
+ if (archived)
168
+ coverage.mirrored += 1;
169
+ else
170
+ coverage.unmirrored.push(entry.relPath);
171
+ }
172
+ if (!archived)
173
+ continue;
174
+ if (entry.sealed) {
175
+ bytes.sealedFiles += 1;
176
+ bytes.sealedBytes += archiveSize;
177
+ }
178
+ else {
179
+ bytes.hotFiles += 1;
180
+ bytes.hotBytes += archiveSize;
181
+ }
182
+ bytes.totalBytes += archiveSize;
183
+ integrity.archivedFileCount += 1;
184
+ if (entry.sealed) {
185
+ const sealed = checkSealed({
186
+ diskPath,
187
+ logicalName: basename(entry.archivePath),
188
+ archiveSize,
189
+ verify,
190
+ });
191
+ integrity.bytesRead += sealed.bytesRead;
192
+ if (sealed.verdict.state === 'verified') {
193
+ integrity.verified.push(entry.relPath);
194
+ }
195
+ else if (sealed.verdict.state === 'diverged') {
196
+ integrity.diverged.push({
197
+ relPath: entry.relPath,
198
+ archivePath: diskPath,
199
+ reason: sealed.verdict.reason,
200
+ });
201
+ }
202
+ else {
203
+ integrity.unverifiable.push({
204
+ relPath: entry.relPath,
205
+ archivePath: diskPath,
206
+ reason: sealed.verdict.reason,
207
+ });
208
+ }
209
+ continue;
210
+ }
211
+ const sourceStat = entry.presence === 'both' ? statSafe(entry.sourcePath) : undefined;
212
+ if (sourceStat === undefined || !sourceStat.isFile()) {
213
+ integrity.unverifiable.push({
214
+ relPath: entry.relPath,
215
+ archivePath: diskPath,
216
+ reason: NO_LIVE_SOURCE_REASON,
217
+ });
218
+ continue;
219
+ }
220
+ let compared;
221
+ try {
222
+ compared = compareToSource({
223
+ sourcePath: entry.sourcePath,
224
+ diskPath,
225
+ sourceSize: sourceStat.size,
226
+ archiveSize,
227
+ verify,
228
+ buffer,
229
+ });
230
+ }
231
+ catch (error) {
232
+ integrity.unverifiable.push({
233
+ relPath: entry.relPath,
234
+ archivePath: diskPath,
235
+ reason: error.code === 'ENOENT'
236
+ ? NO_LIVE_SOURCE_REASON
237
+ : `unreadable — ${String(error.message ?? error)}`,
238
+ });
239
+ continue;
240
+ }
241
+ integrity.bytesRead += compared.bytesRead;
242
+ if (compared.reason === undefined) {
243
+ integrity.verified.push(entry.relPath);
244
+ }
245
+ else {
246
+ integrity.diverged.push({
247
+ relPath: entry.relPath,
248
+ sourcePath: entry.sourcePath,
249
+ archivePath: diskPath,
250
+ reason: compared.reason,
251
+ });
252
+ }
253
+ }
254
+ assertPopulationsPartition(integrity);
255
+ return {
256
+ dataDir,
257
+ sourceRoot,
258
+ archiveRoot,
259
+ settingsPath,
260
+ coverage,
261
+ bytes,
262
+ integrity,
263
+ retention: readRetentionSetting(settingsPath),
264
+ };
265
+ }
266
+ function assertPopulationsPartition(integrity) {
267
+ const counted = integrity.verified.length + integrity.diverged.length + integrity.unverifiable.length;
268
+ if (counted !== integrity.archivedFileCount) {
269
+ throw new Error(`doctor integrity accounting is broken: ${counted} classified but ` +
270
+ `${integrity.archivedFileCount} archived files exist`);
271
+ }
272
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { closeSync, constants, fchmodSync, fstatSync, openSync, readFileSync, renameSync, unlinkSync, writeSync, } from 'node:fs';
3
+ import { basename, dirname } from 'node:path';
4
+ import { constants as zlibConstants, zstdCompressSync, zstdDecompressSync } from 'node:zlib';
5
+ import { assertUnderArchiveRoot, canonicalizeTranscriptPath, refuseSymlinkedLeaf, } from './paths.js';
6
+ import { serializeSidecar, sidecarPath, SIDECAR_VERSION } from './sidecar.js';
7
+ const { O_NOFOLLOW, O_RDONLY } = constants;
8
+ const SEALED_SUFFIX = '.zst';
9
+ const ZSTD_PARAMS = {
10
+ [zlibConstants.ZSTD_c_compressionLevel]: 3,
11
+ [zlibConstants.ZSTD_c_checksumFlag]: 1,
12
+ [zlibConstants.ZSTD_c_contentSizeFlag]: 1,
13
+ };
14
+ function sha256(buf) {
15
+ return createHash('sha256').update(buf).digest('hex');
16
+ }
17
+ export function sealArchiveFile(archivePath, archiveRoot) {
18
+ const fd = refuseSymlinkedLeaf(archivePath, () => openSync(archivePath, O_RDONLY | O_NOFOLLOW));
19
+ let bytes;
20
+ let hotSize;
21
+ try {
22
+ hotSize = fstatSync(fd).size;
23
+ bytes = readFileSync(fd);
24
+ }
25
+ finally {
26
+ closeSync(fd);
27
+ }
28
+ if (bytes.length !== hotSize) {
29
+ throw new Error(`refusing to seal ${archivePath}: it holds ${bytes.length} bytes but stat said ${hotSize}`);
30
+ }
31
+ const archiveSha256 = sha256(bytes);
32
+ const compressed = zstdCompressSync(bytes, { params: ZSTD_PARAMS });
33
+ const roundTrip = zstdDecompressSync(compressed);
34
+ if (roundTrip.length !== hotSize || sha256(roundTrip) !== archiveSha256) {
35
+ throw new Error(`seal round-trip verify failed for ${archivePath}: ${roundTrip.length} bytes back from ${hotSize}`);
36
+ }
37
+ assertUnderArchiveRoot(canonicalizeTranscriptPath(dirname(archivePath)), archiveRoot);
38
+ const temp = `${archivePath}${SEALED_SUFFIX}.tmp.${process.pid}`;
39
+ const tempFd = refuseSymlinkedLeaf(temp, () => openSync(temp, 'wx', 0o600));
40
+ try {
41
+ fchmodSync(tempFd, 0o600);
42
+ writeSync(tempFd, compressed, 0, compressed.length, 0);
43
+ }
44
+ finally {
45
+ closeSync(tempFd);
46
+ }
47
+ const sealedAt = new Date().toISOString();
48
+ const sealedPath = `${archivePath}${SEALED_SUFFIX}`;
49
+ const sidecar = sidecarPath(sealedPath);
50
+ const sidecarTemp = `${sidecar}.tmp.${process.pid}`;
51
+ const sidecarBytes = Buffer.from(serializeSidecar({
52
+ v: SIDECAR_VERSION,
53
+ file: basename(archivePath),
54
+ sha256: archiveSha256,
55
+ hot_size: hotSize,
56
+ sealed_size: compressed.length,
57
+ sealed_at: sealedAt,
58
+ }), 'utf8');
59
+ const sidecarFd = refuseSymlinkedLeaf(sidecarTemp, () => openSync(sidecarTemp, 'wx', 0o600));
60
+ try {
61
+ fchmodSync(sidecarFd, 0o600);
62
+ writeSync(sidecarFd, sidecarBytes, 0, sidecarBytes.length, 0);
63
+ }
64
+ finally {
65
+ closeSync(sidecarFd);
66
+ }
67
+ renameSync(sidecarTemp, sidecar);
68
+ renameSync(temp, sealedPath);
69
+ unlinkSync(archivePath);
70
+ return {
71
+ archive_sha256: archiveSha256,
72
+ sealed_at: sealedAt,
73
+ sealed_size: compressed.length,
74
+ hot_size: hotSize,
75
+ };
76
+ }
@@ -0,0 +1,39 @@
1
+ import { readFileSync } from 'node:fs';
2
+ const SIDECAR_SUFFIX = '.sha256';
3
+ export const SIDECAR_VERSION = 1;
4
+ export function sidecarPath(sealedPath) {
5
+ return `${sealedPath}${SIDECAR_SUFFIX}`;
6
+ }
7
+ export function serializeSidecar(record) {
8
+ return `${JSON.stringify(record)}\n`;
9
+ }
10
+ export function readSidecar(path) {
11
+ let text;
12
+ try {
13
+ text = readFileSync(path, 'utf8');
14
+ }
15
+ catch {
16
+ return undefined;
17
+ }
18
+ let parsed;
19
+ try {
20
+ parsed = JSON.parse(text);
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ return isSealSidecar(parsed) ? parsed : undefined;
26
+ }
27
+ const HEX_SHA256 = /^[0-9a-f]{64}$/;
28
+ function isSealSidecar(value) {
29
+ if (typeof value !== 'object' || value === null)
30
+ return false;
31
+ const record = value;
32
+ return (record.v === SIDECAR_VERSION &&
33
+ typeof record.file === 'string' &&
34
+ typeof record.sha256 === 'string' &&
35
+ HEX_SHA256.test(record.sha256) &&
36
+ Number.isInteger(record.hot_size) &&
37
+ Number.isInteger(record.sealed_size) &&
38
+ typeof record.sealed_at === 'string');
39
+ }
@@ -0,0 +1,47 @@
1
+ function normalise(flag) {
2
+ return flag.toLowerCase().replace(/[-_]/g, '');
3
+ }
4
+ function didYouMean(spec, name) {
5
+ const target = normalise(name);
6
+ return Object.keys(spec.flags).find((known) => normalise(known) === target);
7
+ }
8
+ export function validateArgs(spec, args) {
9
+ let seenPositional = false;
10
+ for (let i = 0; i < args.length; i += 1) {
11
+ const arg = args[i];
12
+ if (arg.startsWith('-')) {
13
+ const eq = arg.indexOf('=');
14
+ const name = eq === -1 ? arg : arg.slice(0, eq);
15
+ const kind = spec.flags[name];
16
+ if (kind === undefined) {
17
+ const meant = didYouMean(spec, name);
18
+ return {
19
+ ok: false,
20
+ message: `unknown argument ${arg}${meant === undefined ? '' : ` — did you mean ${meant}?`}`,
21
+ };
22
+ }
23
+ if (kind === 'boolean')
24
+ continue;
25
+ if (eq === -1) {
26
+ if (i + 1 >= args.length)
27
+ return { ok: false, message: `${name} requires a value` };
28
+ i += 1;
29
+ continue;
30
+ }
31
+ if (eq === arg.length - 1)
32
+ return { ok: false, message: `${name} requires a value` };
33
+ continue;
34
+ }
35
+ if (spec.positional === 'none') {
36
+ return { ok: false, message: `unexpected argument ${arg}` };
37
+ }
38
+ if (seenPositional) {
39
+ return {
40
+ ok: false,
41
+ message: `unexpected argument ${arg} — ${spec.name} takes at most one session id`,
42
+ };
43
+ }
44
+ seenPositional = true;
45
+ }
46
+ return { ok: true };
47
+ }
@@ -0,0 +1,65 @@
1
+ import { archiveOnce } from '../../archive/index.js';
2
+ export const EXIT_OK = 0;
3
+ export const EXIT_INCOMPLETE = 1;
4
+ export const EXIT_ARCHIVE_ERRORS = 3;
5
+ export function exitCodeFor(result) {
6
+ return result.errors.some((e) => e.origin === 'archive') ? EXIT_ARCHIVE_ERRORS : EXIT_OK;
7
+ }
8
+ export function parseStringFlag(args, name) {
9
+ const long = `--${name}`;
10
+ const prefix = `${long}=`;
11
+ for (let i = 0; i < args.length; i++) {
12
+ const arg = args[i];
13
+ if (arg === long) {
14
+ const value = args[i + 1];
15
+ if (value === undefined || value === '')
16
+ throw new Error(`${long} requires a value`);
17
+ return value;
18
+ }
19
+ if (arg.startsWith(prefix)) {
20
+ const value = arg.slice(prefix.length);
21
+ if (value === '')
22
+ throw new Error(`${long} requires a value`);
23
+ return value;
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+ export function formatSummary(result) {
29
+ const diverged = result.files.filter((f) => f.source_state === 'diverged');
30
+ const expired = result.files.filter((f) => f.source_state === 'expired');
31
+ const leader = result.lock.state === 'held'
32
+ ? `another pass holds the lock (pid ${result.lock.holder_pid ?? '?'}) — copied nothing`
33
+ : `${result.filesSeen} files, ${result.bytesCopied} bytes copied`;
34
+ const count = result.errors.length;
35
+ const errorSuffix = count === 0 ? '' : `, ${count} error${count === 1 ? '' : 's'}`;
36
+ const lines = [`agent-lens archive: ${leader}${errorSuffix}`];
37
+ if (result.lock.state === 'reclaimed') {
38
+ lines.push(` reclaimed a stale lock (${result.lock.reclaim_reason})`);
39
+ }
40
+ if (diverged.length > 0) {
41
+ lines.push(` ${diverged.length} diverged (archived bytes kept, nothing overwritten):`);
42
+ for (const file of diverged)
43
+ lines.push(` ${file.reason} ${file.source_path}`);
44
+ }
45
+ if (expired.length > 0) {
46
+ lines.push(` ${expired.length} expired at the source — the archive is now the only copy`);
47
+ }
48
+ for (const error of result.errors)
49
+ lines.push(` error ${error.path}: ${error.message}`);
50
+ return lines.join('\n');
51
+ }
52
+ export async function archive(args = []) {
53
+ const result = archiveOnce({
54
+ dataDir: parseStringFlag(args, 'dataDir'),
55
+ transcriptRoot: parseStringFlag(args, 'transcriptRoot'),
56
+ verify: args.includes('--verify'),
57
+ });
58
+ if (args.includes('--json')) {
59
+ console.log(JSON.stringify(result));
60
+ }
61
+ else {
62
+ console.log(formatSummary(result));
63
+ }
64
+ return exitCodeFor(result);
65
+ }
@@ -0,0 +1,191 @@
1
+ import { join } from 'node:path';
2
+ import { buildDoctorReport, readCronLogStatus, resolveDataDir, } from '../../archive/index.js';
3
+ import { CACHE_DB_FILE, openReadOnlyDb } from '../../db/open.js';
4
+ import { readDriftRows, readHealthCounts, readMeta } from '../../db/read.js';
5
+ import { aggregateDrift } from '../../server/drift-report.js';
6
+ import { parseStringFlag } from './archive.js';
7
+ const MAX_LISTED = 20;
8
+ export const COVERAGE_GAP_STATEMENT = 'agent-lens can only archive what exists while it runs — a gap in uptime is a gap in the record.';
9
+ export const DURABILITY_STATEMENT = 'rm cache.db loses nothing. rm -rf ~/.agent-lens/archive loses data permanently.';
10
+ function listCapped(items, indent) {
11
+ const shown = items.slice(0, MAX_LISTED).map((item) => `${indent}${item}`);
12
+ if (items.length > MAX_LISTED) {
13
+ shown.push(`${indent}… and ${items.length - MAX_LISTED} more (${items.length} in total)`);
14
+ }
15
+ return shown;
16
+ }
17
+ function files(n) {
18
+ return n === 1 ? '1 file' : `${n} files`;
19
+ }
20
+ export function readCacheStats(dataDir) {
21
+ const dir = resolveDataDir(dataDir);
22
+ const path = join(dir, CACHE_DB_FILE);
23
+ let db;
24
+ try {
25
+ db = openReadOnlyDb(dir);
26
+ if (db === undefined)
27
+ return { state: 'absent', path };
28
+ const counts = readHealthCounts(db);
29
+ const drift = aggregateDrift(readDriftRows(db));
30
+ const drifting = new Map();
31
+ for (const session of drift.sessions_with_drift) {
32
+ const version = session.harness_version ?? 'unknown';
33
+ drifting.set(version, (drifting.get(version) ?? 0) + 1);
34
+ }
35
+ return {
36
+ state: 'ready',
37
+ path,
38
+ stats: {
39
+ db_bytes: counts.db_bytes,
40
+ sessions_indexed: counts.sessions_indexed,
41
+ sessions_projected: counts.sessions_projected,
42
+ schema_version: readMeta(db, 'schema_version'),
43
+ projector_version: readMeta(db, 'projector_version'),
44
+ harness: Object.entries(drift.harness_versions)
45
+ .map(([version, projected]) => ({
46
+ version,
47
+ projected,
48
+ drifting: drifting.get(version) ?? 0,
49
+ }))
50
+ .sort((a, b) => (a.version < b.version ? -1 : 1)),
51
+ },
52
+ };
53
+ }
54
+ catch (error) {
55
+ return { state: 'unreadable', path, message: String(error.message ?? error) };
56
+ }
57
+ finally {
58
+ if (db?.isOpen === true)
59
+ db.close();
60
+ }
61
+ }
62
+ export function formatCacheSection(cache) {
63
+ if (cache.state === 'absent') {
64
+ return ['', `cache: ${cache.path} — not created yet; \`agent-lens start\` builds it`];
65
+ }
66
+ if (cache.state === 'unreadable') {
67
+ return [
68
+ '',
69
+ `cache: ${cache.path} — stats unavailable: ${cache.message}`,
70
+ ' nothing is lost either way — `agent-lens rebuild` recreates the whole file',
71
+ ];
72
+ }
73
+ const { stats } = cache;
74
+ const lines = [
75
+ '',
76
+ `cache: ${cache.path}`,
77
+ ` ${stats.db_bytes} bytes`,
78
+ ` ${stats.sessions_indexed} sessions indexed, ${stats.sessions_projected} projected`,
79
+ ` schema_version ${stats.schema_version ?? 'unstamped'}, ` +
80
+ `projector_version ${stats.projector_version ?? 'unstamped'}`,
81
+ ];
82
+ if (stats.harness.length === 0) {
83
+ lines.push(' no projected session carries a harness version yet');
84
+ return lines;
85
+ }
86
+ lines.push(' drift by harness version (the census counts clean rows too):');
87
+ for (const row of stats.harness) {
88
+ lines.push(` ${row.version}: ${row.projected} projected, ${row.drifting} drifting`);
89
+ }
90
+ return lines;
91
+ }
92
+ function formatAgo(elapsedMs) {
93
+ const minutes = Math.max(0, Math.floor(elapsedMs / 60_000));
94
+ if (minutes < 60)
95
+ return `${minutes}m ago`;
96
+ const hours = Math.floor(minutes / 60);
97
+ if (hours < 24)
98
+ return `${hours}h ${minutes % 60}m ago`;
99
+ return `${Math.floor(hours / 24)}d ${hours % 24}h ago`;
100
+ }
101
+ export function formatLastPassSection(report, now = Date.now()) {
102
+ if (report.state === 'absent') {
103
+ return ['', `archive job: ${report.path} — no cron.log; the job has never run here`];
104
+ }
105
+ if (report.state === 'empty') {
106
+ return ['', `archive job: ${report.path} — cron.log exists but records no pass`];
107
+ }
108
+ const lines = ['', `archive job: ${report.path}`];
109
+ lines.push(report.lastOk === undefined
110
+ ? ' no successful pass on record'
111
+ : ` last successful pass: ${formatAgo(now - report.lastOk.epochMs)}`);
112
+ if (report.lastEntry.status !== 'ok') {
113
+ lines.push(` most recent attempt: ${report.lastEntry.status}, ` +
114
+ `${formatAgo(now - report.lastEntry.epochMs)} — ${report.lastEntry.summary}`);
115
+ }
116
+ return lines;
117
+ }
118
+ export function formatRetention(retention) {
119
+ switch (retention.state) {
120
+ case 'set':
121
+ return `Claude Code retention (cleanupPeriodDays): ${retention.days} days`;
122
+ case 'unset':
123
+ return 'Claude Code retention (cleanupPeriodDays): unset — default applies';
124
+ case 'absent':
125
+ return 'Claude Code retention: no settings file — unset — default applies';
126
+ case 'unreadable':
127
+ return `Claude Code retention: unreadable — ${retention.message}`;
128
+ }
129
+ }
130
+ export function formatDoctorReport(report, cache, lastPass) {
131
+ const { coverage, bytes, integrity, retention } = report;
132
+ const lines = [
133
+ 'agent-lens doctor',
134
+ ` transcripts ${report.sourceRoot}`,
135
+ ` archive ${report.archiveRoot}`,
136
+ '',
137
+ `coverage: ${coverage.mirrored} of ${coverage.found} source files mirrored`,
138
+ ];
139
+ if (coverage.unmirrored.length > 0) {
140
+ lines.push(` not yet mirrored (${coverage.unmirrored.length}):`);
141
+ lines.push(...listCapped(coverage.unmirrored, ' '));
142
+ }
143
+ if (coverage.archiveOnly > 0) {
144
+ lines.push(` ${files(coverage.archiveOnly)} with no live source — the archive is the only copy left`);
145
+ }
146
+ lines.push('', `archive bytes: ${bytes.totalBytes} total — ${bytes.hotBytes} hot in ${files(bytes.hotFiles)}, ` +
147
+ `${bytes.sealedBytes} sealed in ${files(bytes.sealedFiles)}`, '', integrity.verify
148
+ ? 'integrity (--verify: full prefix hash over every checkable file):'
149
+ : 'integrity (head+seam probe, roughly 1% of bytes — pass --verify for the full prefix hash):', ` ${files(integrity.archivedFileCount)} archived = ${integrity.verified.length} verified + ` +
150
+ `${integrity.diverged.length} diverged + ${integrity.unverifiable.length} unverifiable`);
151
+ if (integrity.diverged.length > 0) {
152
+ lines.push(` diverged (${integrity.diverged.length}) — the archived bytes and their reference disagree.`, ' Consistent with two readings, and this check cannot tell them apart: the', ' source was rewritten, or the archived bytes were corrupted. Nothing was', ' overwritten either way.');
153
+ if (integrity.diverged.some((file) => file.reason.startsWith('sealed-'))) {
154
+ lines.push(' A sealed row is narrower than that: its source is already gone, so it was', ' compared against the hash the seal recorded and one reading is left —', ' the archived bytes changed.');
155
+ }
156
+ for (const file of integrity.diverged) {
157
+ lines.push(` ${file.reason} ${file.archivePath}`);
158
+ }
159
+ }
160
+ if (integrity.unverifiable.length > 0) {
161
+ lines.push(` unverifiable (${integrity.unverifiable.length}) — NOT checked, and NOT verified.`, ' Each row names its own reason. Nothing here was compared against anything.');
162
+ if (!integrity.verify) {
163
+ lines.push(' Pass --verify to re-hash every sealed file that has a stored hash.');
164
+ }
165
+ lines.push(...listCapped(integrity.unverifiable.map((file) => `${file.reason} ${file.archivePath}`), ' '));
166
+ }
167
+ lines.push('', formatRetention(retention), ` read from ${report.settingsPath} — reported only; doctor never writes it`);
168
+ if (cache !== undefined)
169
+ lines.push(...formatCacheSection(cache));
170
+ if (lastPass !== undefined)
171
+ lines.push(...formatLastPassSection(lastPass));
172
+ lines.push('', COVERAGE_GAP_STATEMENT, DURABILITY_STATEMENT);
173
+ return lines.join('\n');
174
+ }
175
+ export async function doctor(args = []) {
176
+ const dataDir = parseStringFlag(args, 'dataDir');
177
+ const report = buildDoctorReport({
178
+ dataDir,
179
+ transcriptRoot: parseStringFlag(args, 'transcriptRoot'),
180
+ settingsPath: parseStringFlag(args, 'settingsPath'),
181
+ verify: args.includes('--verify'),
182
+ });
183
+ const cache = readCacheStats(dataDir);
184
+ const lastPass = readCronLogStatus(dataDir);
185
+ if (args.includes('--json')) {
186
+ console.log(JSON.stringify({ ...report, cache, lastPass }));
187
+ }
188
+ else {
189
+ console.log(formatDoctorReport(report, cache, lastPass));
190
+ }
191
+ }