@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,96 @@
1
+ import { resolveArchiveRoot, resolveDataDir, resolveTranscriptRoot } from '../../archive/index.js';
2
+ import { createArchiveReader } from '../../archive/read.js';
3
+ import { createProjectionEnv } from '../../corpus/env.js';
4
+ import { createCorpusSweep, createSpillIndexEnv } from '../../corpus/watch.js';
5
+ import { DbLockedError, openDb } from '../../db/open.js';
6
+ import { readWarmableIds } from '../../db/read.js';
7
+ import { indexSpills } from '../../db/spill-index.js';
8
+ import { createWarmQueue } from '../../server/warm.js';
9
+ import { EXIT_INCOMPLETE, EXIT_OK, parseStringFlag } from './archive.js';
10
+ const MAX_PASSES = 12;
11
+ const POLL_MS = 10;
12
+ export function printingHub(write = console.log) {
13
+ let published = 0;
14
+ return {
15
+ get published() {
16
+ return published;
17
+ },
18
+ attach: () => Promise.resolve(),
19
+ publish: (event, data) => {
20
+ if (event === 'warm_progress') {
21
+ const frame = data;
22
+ published += 1;
23
+ write(` warmed ${frame.done}/${frame.total}`);
24
+ }
25
+ return Promise.resolve();
26
+ },
27
+ beat: () => Promise.resolve(),
28
+ drain: () => Promise.resolve(),
29
+ size: () => 0,
30
+ };
31
+ }
32
+ async function waitForFrames(hub, target) {
33
+ while (hub.published < target) {
34
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
35
+ }
36
+ }
37
+ function summarize(perPass, residual, spills) {
38
+ const total = perPass.reduce((sum, n) => sum + n, 0);
39
+ const spillTail = spills.indexed === 0 && spills.skipped.length === 0
40
+ ? ''
41
+ : `; ${spills.indexed} spilled output(s) indexed` +
42
+ (spills.skipped.length === 0 ? '' : `, ${spills.skipped.length} skipped`);
43
+ if (total === 0 && residual === 0) {
44
+ return `agent-lens warm: nothing to warm — every indexed session is already projected${spillTail}`;
45
+ }
46
+ const tail = residual === 0 ? '' : `; ${residual} still unprojected — see \`agent-lens doctor\``;
47
+ return `agent-lens warm: ${total} warmed over ${perPass.length} pass(es) [${perPass.join(', ')}]${tail}${spillTail}`;
48
+ }
49
+ async function drainCorpus(dataDir, transcriptRoot) {
50
+ const opened = openDb({ dataDir });
51
+ const hub = printingHub();
52
+ const reader = createArchiveReader();
53
+ const roots = {
54
+ archiveRoot: resolveArchiveRoot(dataDir),
55
+ transcriptRoot: resolveTranscriptRoot(transcriptRoot),
56
+ };
57
+ const env = createProjectionEnv(reader, roots);
58
+ const queue = createWarmQueue({ db: opened.db, env, hub });
59
+ try {
60
+ createCorpusSweep({ db: opened.db, dataDir, transcriptRoot }).wave1();
61
+ const perPass = [];
62
+ let previousKey = '';
63
+ for (let pass = 0; pass < MAX_PASSES; pass += 1) {
64
+ const remaining = readWarmableIds(opened.db);
65
+ const key = remaining.join(',');
66
+ if (remaining.length === 0 || key === previousKey)
67
+ break;
68
+ previousKey = key;
69
+ const before = hub.published;
70
+ const started = queue.start();
71
+ perPass.push(started);
72
+ await waitForFrames(hub, before + started);
73
+ await new Promise((resolve) => setImmediate(resolve));
74
+ }
75
+ const spills = indexSpills(opened.db, createSpillIndexEnv(opened.db, reader, [roots.archiveRoot, roots.transcriptRoot]));
76
+ console.log(summarize(perPass, readWarmableIds(opened.db).length, spills));
77
+ return EXIT_OK;
78
+ }
79
+ finally {
80
+ queue.close();
81
+ opened.close();
82
+ }
83
+ }
84
+ export async function warm(args = []) {
85
+ try {
86
+ return await drainCorpus(resolveDataDir(parseStringFlag(args, 'dataDir')), parseStringFlag(args, 'transcriptRoot'));
87
+ }
88
+ catch (error) {
89
+ if (error instanceof DbLockedError) {
90
+ console.error(`${error.message} — warm it through the running server instead: the UI's warm button, or POST /api/warm`);
91
+ return EXIT_INCOMPLETE;
92
+ }
93
+ console.error(`agent-lens warm: ${String(error.message ?? error)}`);
94
+ return EXIT_INCOMPLETE;
95
+ }
96
+ }
@@ -0,0 +1,102 @@
1
+ import { argv, exit } from 'node:process';
2
+ import { validateArgs } from './args.js';
3
+ export const COMMANDS = [
4
+ {
5
+ name: 'start',
6
+ summary: 'Start the local tracing server + UI (default)',
7
+ flags: { '--port': 'value', '--host': 'value' },
8
+ positional: 'none',
9
+ run: (args) => import('./commands/start.js').then((m) => m.start(args)),
10
+ },
11
+ {
12
+ name: 'doctor',
13
+ summary: 'Report archive coverage, integrity and retention',
14
+ flags: {
15
+ '--dataDir': 'value',
16
+ '--transcriptRoot': 'value',
17
+ '--settingsPath': 'value',
18
+ '--verify': 'boolean',
19
+ '--json': 'boolean',
20
+ },
21
+ positional: 'none',
22
+ run: (args) => import('./commands/doctor.js').then((m) => m.doctor(args)),
23
+ },
24
+ {
25
+ name: 'archive',
26
+ summary: 'Mirror Claude Code transcripts into the durable archive',
27
+ flags: {
28
+ '--dataDir': 'value',
29
+ '--transcriptRoot': 'value',
30
+ '--verify': 'boolean',
31
+ '--json': 'boolean',
32
+ },
33
+ positional: 'none',
34
+ run: (args) => import('./commands/archive.js').then((m) => m.archive(args)),
35
+ },
36
+ {
37
+ name: 'rebuild',
38
+ summary: 'Drop the disposable cache, or one session’s projection',
39
+ flags: { '--dataDir': 'value', '--transcriptRoot': 'value' },
40
+ positional: 'anywhere',
41
+ run: (args) => import('./commands/rebuild.js').then((m) => m.rebuild(args)),
42
+ },
43
+ {
44
+ name: 'warm',
45
+ summary: 'Project every indexed session, printing progress to completion',
46
+ flags: { '--dataDir': 'value', '--transcriptRoot': 'value' },
47
+ positional: 'none',
48
+ run: (args) => import('./commands/warm.js').then((m) => m.warm(args)),
49
+ },
50
+ {
51
+ name: 'schedule',
52
+ summary: 'Manage the recurring archive job (turn on, report, turn off)',
53
+ flags: { '--dataDir': 'value' },
54
+ positional: 'anywhere',
55
+ run: (args) => import('./commands/schedule.js').then((m) => m.schedule(args)),
56
+ },
57
+ {
58
+ name: 'prune',
59
+ summary: 'Permanently delete archived transcripts — asks first, no undo',
60
+ flags: { '--dataDir': 'value', '--transcriptRoot': 'value', '--settingsPath': 'value' },
61
+ positional: 'anywhere',
62
+ run: (args) => import('./commands/prune.js').then((m) => m.prune(args)),
63
+ },
64
+ ];
65
+ export function printHelp() {
66
+ const width = Math.max(...COMMANDS.map((c) => c.name.length));
67
+ console.log('agent-lens — local-first agentic tracing platform\n');
68
+ console.log('Usage: agent-lens <command> [options]\n');
69
+ console.log('Commands:');
70
+ for (const cmd of COMMANDS) {
71
+ console.log(` ${cmd.name.padEnd(width)} ${cmd.summary}`);
72
+ }
73
+ }
74
+ export async function main(argv) {
75
+ const [command, ...rest] = argv;
76
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
77
+ printHelp();
78
+ return 0;
79
+ }
80
+ const match = COMMANDS.find((c) => c.name === command);
81
+ if (!match) {
82
+ console.error(`Unknown command: ${command}\n`);
83
+ printHelp();
84
+ return 1;
85
+ }
86
+ const checked = validateArgs(match, rest);
87
+ if (!checked.ok) {
88
+ console.error(`agent-lens ${command}: ${checked.message}`);
89
+ return 1;
90
+ }
91
+ try {
92
+ const code = await match.run(rest);
93
+ return code ?? 0;
94
+ }
95
+ catch (error) {
96
+ console.error(`agent-lens ${command}: ${String(error?.message ?? error)}`);
97
+ return 1;
98
+ }
99
+ }
100
+ if (import.meta.url === `file://${argv[1]}`) {
101
+ main(argv.slice(2)).then(exit);
102
+ }
@@ -0,0 +1,163 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { isUnderAnyRoot } from '../archive/paths.js';
4
+ import { toolResultsDirOf } from '../corpus/paths.js';
5
+ import { contentBlocks } from '../transcript/blocks.js';
6
+ import { DriftCounter } from '../transcript/drift.js';
7
+ import { classifyLine } from '../transcript/line.js';
8
+ function byteLength(text) {
9
+ return Buffer.byteLength(text, 'utf8');
10
+ }
11
+ function resultText(children) {
12
+ return children.flatMap((child) => (child.kind === 'text' ? [child.text] : [])).join('\n');
13
+ }
14
+ function blockAt(env, archivePath, byteOffset, len, blockIndex) {
15
+ let bytes;
16
+ try {
17
+ bytes = env.reader.read(archivePath, byteOffset, len);
18
+ }
19
+ catch {
20
+ return undefined;
21
+ }
22
+ if (bytes.length === 0)
23
+ return undefined;
24
+ try {
25
+ const line = classifyLine(JSON.parse(bytes.toString('utf8')), {
26
+ byteOffset,
27
+ byteLength: len,
28
+ drift: new DriftCounter(),
29
+ });
30
+ return contentBlocks(line)[blockIndex];
31
+ }
32
+ catch {
33
+ return undefined;
34
+ }
35
+ }
36
+ function stored(row, field) {
37
+ const text = (field === 'text' ? row.text : row.input) ?? '';
38
+ return { text, bytes: field === 'text' ? row.text_bytes : row.input_bytes };
39
+ }
40
+ function fromColumn(row, field, storage) {
41
+ const { text, bytes } = stored(row, field);
42
+ return { storage, content: text, byte_size: bytes ?? byteLength(text) };
43
+ }
44
+ function empty(storage) {
45
+ return { storage, content: '', byte_size: 0 };
46
+ }
47
+ function fromLineRef(row, field, text) {
48
+ if (text === undefined)
49
+ return fromColumn(row, field, 'line_ref');
50
+ const { bytes } = stored(row, field);
51
+ return { storage: 'line_ref', content: text, byte_size: bytes ?? byteLength(text) };
52
+ }
53
+ function probe(env, path) {
54
+ try {
55
+ return env.exists(path) === true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ function within(env, path) {
62
+ if (env.withinRoots === undefined)
63
+ return true;
64
+ try {
65
+ return env.withinRoots(path) === true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ function spillSource(row, archivePath, env) {
72
+ if (row.spill_path === null)
73
+ return undefined;
74
+ const name = basename(row.spill_path);
75
+ if (archivePath !== undefined && name !== '') {
76
+ const mirrored = join(toolResultsDirOf(archivePath), name);
77
+ if (probe(env, mirrored))
78
+ return mirrored;
79
+ }
80
+ return within(env, row.spill_path) && probe(env, row.spill_path) ? row.spill_path : undefined;
81
+ }
82
+ function fromSpill(row, archivePath, env) {
83
+ const path = spillSource(row, archivePath, env);
84
+ if (path === undefined)
85
+ return empty('missing');
86
+ try {
87
+ const size = env.reader.size(path);
88
+ return {
89
+ storage: 'spill',
90
+ content: env.reader.read(path, 0, size).toString('utf8'),
91
+ byte_size: size,
92
+ spill_path: path,
93
+ };
94
+ }
95
+ catch {
96
+ return empty('missing');
97
+ }
98
+ }
99
+ export function resolveContent(row, field, archivePath, env) {
100
+ return field === 'input'
101
+ ? resolveInput(row, archivePath, env)
102
+ : resolveText(row, archivePath, env);
103
+ }
104
+ function resolveInput(row, archivePath, env) {
105
+ switch (row.input_storage) {
106
+ case 'inline':
107
+ return fromColumn(row, 'input', 'inline');
108
+ case 'line_ref': {
109
+ const block = archivePath === undefined || row.block_index === null
110
+ ? undefined
111
+ : blockAt(env, archivePath, row.src_offset, row.src_len, row.block_index);
112
+ const input = block?.kind === 'tool_use' ? block.input : undefined;
113
+ return fromLineRef(row, 'input', input === undefined ? undefined : JSON.stringify(input));
114
+ }
115
+ case 'absent':
116
+ case null:
117
+ return empty('absent');
118
+ default:
119
+ return fromColumn(row, 'input', row.input_storage);
120
+ }
121
+ }
122
+ function resolveText(row, archivePath, env) {
123
+ switch (row.output_storage) {
124
+ case null: {
125
+ const text = row.text ?? '';
126
+ return { storage: 'inline', content: text, byte_size: byteLength(text) };
127
+ }
128
+ case 'inline':
129
+ return fromColumn(row, 'text', 'inline');
130
+ case 'line_ref': {
131
+ const { result_offset, result_len, result_block } = row;
132
+ const reachable = archivePath !== undefined &&
133
+ result_offset !== null &&
134
+ result_len !== null &&
135
+ result_block !== null;
136
+ const block = reachable
137
+ ? blockAt(env, archivePath, result_offset, result_len, result_block)
138
+ : undefined;
139
+ return fromLineRef(row, 'text', block?.kind === 'tool_result' ? resultText(block.children) : undefined);
140
+ }
141
+ case 'spill':
142
+ return fromSpill(row, archivePath, env);
143
+ case 'missing':
144
+ return empty('missing');
145
+ case 'absent':
146
+ return empty('absent');
147
+ default:
148
+ return fromColumn(row, 'text', row.output_storage);
149
+ }
150
+ }
151
+ export function createContentEnv(reader, roots) {
152
+ return {
153
+ reader,
154
+ exists: (path) => existsSync(path) || existsSync(`${path}.zst`),
155
+ withinRoots: (path) => isUnderAnyRoot(path, roots),
156
+ };
157
+ }
158
+ export function createContentResolver(archivePathOf, env) {
159
+ return (row, field) => resolveContent(row, field, archivePathOf(row.session_id), env);
160
+ }
161
+ export function createSpillLocator(archivePathOf, env) {
162
+ return (row) => spillSource(row, archivePathOf(row.session_id), env);
163
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isUnderAnyRoot } from '../archive/paths.js';
3
+ import { readSidecars } from '../db/sidecars.js';
4
+ import { DriftCounter } from '../transcript/drift.js';
5
+ import { classifyLine } from '../transcript/line.js';
6
+ import { sessionRootOf } from './paths.js';
7
+ function readLines(reader, archivePath) {
8
+ const text = reader.read(archivePath, 0, reader.size(archivePath)).toString('utf8');
9
+ const drift = new DriftCounter();
10
+ const lines = [];
11
+ let byteOffset = 0;
12
+ for (const line of text.split('\n')) {
13
+ const byteLength = Buffer.byteLength(line, 'utf8');
14
+ if (line !== '') {
15
+ lines.push(classifyLine(JSON.parse(line), { byteOffset, byteLength, drift }));
16
+ }
17
+ byteOffset += byteLength + 1;
18
+ }
19
+ return { lines, drift };
20
+ }
21
+ export function createProjectionEnv(reader, roots) {
22
+ return {
23
+ readLines: (archivePath) => readLines(reader, archivePath),
24
+ spillEnv: (archivePath) => ({
25
+ exists: (path) => existsSync(path) || existsSync(`${path}.zst`),
26
+ sessionRoot: sessionRootOf(archivePath),
27
+ archiveRoot: roots.archiveRoot,
28
+ withinRoots: (path) => isUnderAnyRoot(path, [roots.archiveRoot, roots.transcriptRoot, sessionRootOf(archivePath)]),
29
+ }),
30
+ sidecars: (archivePath, sourcePath, toolUseIds) => readSidecars(archivePath, sourcePath, toolUseIds, reader),
31
+ };
32
+ }
@@ -0,0 +1,70 @@
1
+ import { join } from 'node:path';
2
+ const TRANSCRIPT_EXT = '.jsonl';
3
+ const SEALED_SUFFIX = '.zst';
4
+ const SUBAGENTS_DIR = 'subagents';
5
+ const TOOL_RESULTS_DIR = 'tool-results';
6
+ const WORKFLOWS_DIR = 'workflows';
7
+ const AGENT_PREFIX = 'agent-';
8
+ const JOURNAL_NAME = 'journal.jsonl';
9
+ function segments(relPath) {
10
+ return relPath
11
+ .split('\\')
12
+ .join('/')
13
+ .split('/')
14
+ .filter((part) => part !== '' && part !== '.');
15
+ }
16
+ export function encodeProjectDir(projectPath) {
17
+ return projectPath.split('/').join('-');
18
+ }
19
+ export function decodeProjectDir(slug) {
20
+ return slug.split('-').join('/');
21
+ }
22
+ export function logicalPathOf(physicalPath) {
23
+ return physicalPath.endsWith(SEALED_SUFFIX)
24
+ ? physicalPath.slice(0, -SEALED_SUFFIX.length)
25
+ : physicalPath;
26
+ }
27
+ export function sessionDirOf(archivePath) {
28
+ return archivePath.endsWith(TRANSCRIPT_EXT)
29
+ ? archivePath.slice(0, -TRANSCRIPT_EXT.length)
30
+ : archivePath;
31
+ }
32
+ export function subagentsDirOf(archivePath) {
33
+ return join(sessionDirOf(archivePath), SUBAGENTS_DIR);
34
+ }
35
+ export function sessionRootOf(archivePath) {
36
+ const cut = archivePath.lastIndexOf(`/${SUBAGENTS_DIR}/`);
37
+ return cut === -1 ? sessionDirOf(archivePath) : archivePath.slice(0, cut);
38
+ }
39
+ export function toolResultsDirOf(archivePath) {
40
+ return join(sessionRootOf(archivePath), TOOL_RESULTS_DIR);
41
+ }
42
+ export function classifyCorpusPath(relPath) {
43
+ const parts = segments(relPath);
44
+ const leaf = parts[parts.length - 1] ?? '';
45
+ if (parts.length === 2)
46
+ return leaf.endsWith(TRANSCRIPT_EXT) ? 'session' : 'ignored';
47
+ if (parts.length >= 4 && parts[2] === SUBAGENTS_DIR) {
48
+ if (leaf === JOURNAL_NAME)
49
+ return 'excluded';
50
+ return leaf.startsWith(AGENT_PREFIX) && leaf.endsWith(TRANSCRIPT_EXT) ? 'sidecar' : 'ignored';
51
+ }
52
+ return 'ignored';
53
+ }
54
+ export function workflowParentOf(relPath) {
55
+ const parts = segments(relPath);
56
+ if (parts.length < 5)
57
+ return undefined;
58
+ if (parts[2] !== SUBAGENTS_DIR || parts[3] !== WORKFLOWS_DIR)
59
+ return undefined;
60
+ return parts[1];
61
+ }
62
+ export function projectSlugOf(relPath) {
63
+ return segments(relPath)[0] ?? '';
64
+ }
65
+ export function rowIdOf(relPath) {
66
+ const parts = segments(relPath);
67
+ const leaf = parts[parts.length - 1] ?? '';
68
+ const stem = leaf.endsWith(TRANSCRIPT_EXT) ? leaf.slice(0, -TRANSCRIPT_EXT.length) : leaf;
69
+ return stem.startsWith(AGENT_PREFIX) ? stem.slice(AGENT_PREFIX.length) : stem;
70
+ }
@@ -0,0 +1,85 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { relativeUnder } from '../archive/paths.js';
4
+ import { foldArchive } from '../db/freshness.js';
5
+ import { readIndexedFolds } from '../db/read.js';
6
+ import { classifyCorpusPath, logicalPathOf, workflowParentOf } from './paths.js';
7
+ export function scanCorpus(db, archiveRoot, sourceRoot) {
8
+ const result = {
9
+ changed: [],
10
+ unchanged: 0,
11
+ deferred: 0,
12
+ excluded: [],
13
+ ignored: 0,
14
+ unkeyable: [],
15
+ walked: 0,
16
+ };
17
+ walk(archiveRoot, {
18
+ archiveRoot,
19
+ sourceRoot,
20
+ indexed: readIndexedFolds(db),
21
+ seen: new Set(),
22
+ result,
23
+ });
24
+ return result;
25
+ }
26
+ function walk(dir, state) {
27
+ let entries;
28
+ try {
29
+ entries = readdirSync(dir, { withFileTypes: true });
30
+ }
31
+ catch {
32
+ return;
33
+ }
34
+ for (const entry of entries) {
35
+ const path = join(dir, entry.name);
36
+ if (entry.isDirectory()) {
37
+ walk(path, state);
38
+ continue;
39
+ }
40
+ if (entry.isFile())
41
+ visitFile(path, state);
42
+ }
43
+ }
44
+ function visitFile(path, state) {
45
+ const { result } = state;
46
+ result.walked += 1;
47
+ const archivePath = logicalPathOf(path);
48
+ const relPath = relativeUnder(state.archiveRoot, archivePath);
49
+ if (relPath === undefined) {
50
+ result.ignored += 1;
51
+ return;
52
+ }
53
+ const kind = classifyCorpusPath(relPath);
54
+ if (kind === 'excluded') {
55
+ result.excluded.push(relPath);
56
+ return;
57
+ }
58
+ if (kind === 'ignored' || state.seen.has(relPath)) {
59
+ result.ignored += 1;
60
+ return;
61
+ }
62
+ state.seen.add(relPath);
63
+ if (kind === 'sidecar' && workflowParentOf(relPath) === undefined) {
64
+ result.deferred += 1;
65
+ return;
66
+ }
67
+ const fold = foldArchive(archivePath);
68
+ if (fold === undefined) {
69
+ result.unkeyable.push(relPath);
70
+ return;
71
+ }
72
+ const row = state.indexed.get(archivePath);
73
+ if (row !== undefined && row.file_mtime_ms === fold.mtime_ms && row.file_size === fold.size) {
74
+ result.unchanged += 1;
75
+ return;
76
+ }
77
+ result.changed.push({
78
+ relPath,
79
+ archivePath,
80
+ sourcePath: join(state.sourceRoot, relPath),
81
+ kind,
82
+ sealed: path !== archivePath,
83
+ fold,
84
+ });
85
+ }