osborn 0.9.249 → 0.9.251

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 (62) hide show
  1. package/dist/claude-llm.js +13 -0
  2. package/dist/content/compaction-lens-worker.d.ts +23 -0
  3. package/dist/content/compaction-lens-worker.js +188 -0
  4. package/dist/content/compaction-lens.d.ts +106 -0
  5. package/dist/content/compaction-lens.js +222 -0
  6. package/dist/content/lens-audio.d.ts +38 -0
  7. package/dist/content/lens-audio.js +105 -0
  8. package/dist/content/lens-clip-arc.d.ts +80 -0
  9. package/dist/content/lens-clip-arc.js +148 -0
  10. package/dist/content/lens-clip-formats.d.ts +58 -0
  11. package/dist/content/lens-clip-formats.js +175 -0
  12. package/dist/content/lens-clip-search.d.ts +51 -0
  13. package/dist/content/lens-clip-search.js +138 -0
  14. package/dist/content/lens-clip-window.d.ts +72 -0
  15. package/dist/content/lens-clip-window.js +257 -0
  16. package/dist/content/lens-db.d.ts +70 -0
  17. package/dist/content/lens-db.js +170 -0
  18. package/dist/content/lens-hn.d.ts +19 -0
  19. package/dist/content/lens-hn.js +69 -0
  20. package/dist/content/lens-ingest.d.ts +67 -0
  21. package/dist/content/lens-ingest.js +155 -0
  22. package/dist/content/lens-launch.d.ts +46 -0
  23. package/dist/content/lens-launch.js +111 -0
  24. package/dist/content/lens-library-index.d.ts +30 -0
  25. package/dist/content/lens-library-index.js +79 -0
  26. package/dist/content/lens-library-select.d.ts +59 -0
  27. package/dist/content/lens-library-select.js +77 -0
  28. package/dist/content/lens-library.d.ts +92 -0
  29. package/dist/content/lens-library.js +179 -0
  30. package/dist/content/lens-model.d.ts +64 -0
  31. package/dist/content/lens-model.js +193 -0
  32. package/dist/content/lens-paths.d.ts +43 -0
  33. package/dist/content/lens-paths.js +166 -0
  34. package/dist/content/lens-period-block.d.ts +63 -0
  35. package/dist/content/lens-period-block.js +99 -0
  36. package/dist/content/lens-period-boundaries.d.ts +13 -0
  37. package/dist/content/lens-period-boundaries.js +40 -0
  38. package/dist/content/lens-period-render.d.ts +28 -0
  39. package/dist/content/lens-period-render.js +70 -0
  40. package/dist/content/lens-period.d.ts +106 -0
  41. package/dist/content/lens-period.js +205 -0
  42. package/dist/content/lens-profile.d.ts +47 -0
  43. package/dist/content/lens-profile.js +95 -0
  44. package/dist/content/lens-quotes.d.ts +103 -0
  45. package/dist/content/lens-quotes.js +186 -0
  46. package/dist/content/lens-redact.d.ts +61 -0
  47. package/dist/content/lens-redact.js +187 -0
  48. package/dist/content/lens-strip.d.ts +33 -0
  49. package/dist/content/lens-strip.js +132 -0
  50. package/dist/content/period-run.d.ts +30 -0
  51. package/dist/content/period-run.js +135 -0
  52. package/dist/content/render-ws-data.d.ts +8 -0
  53. package/dist/content/render-ws-data.js +108 -0
  54. package/dist/content/render-ws-screens.d.ts +25 -0
  55. package/dist/content/render-ws-screens.js +189 -0
  56. package/dist/content/render-ws-story.d.ts +1 -0
  57. package/dist/content/render-ws-story.js +233 -0
  58. package/dist/content/transcript-sanitizer.d.ts +195 -0
  59. package/dist/content/transcript-sanitizer.js +515 -0
  60. package/dist/index.js +34 -0
  61. package/package.json +1 -1
  62. package/test-results/.last-run.json +0 -4
@@ -14,6 +14,7 @@ import { statusManager } from './status-manager.js';
14
14
  import { getResearchSystemPrompt, getDirectModeResearchPrompt, getGroundingBlock, getRecalledContextBlock } from './prompts.js';
15
15
  import { openStore, recall, storeExists, updateSessionStore, getStorePath } from './session-store.js';
16
16
  import { getEmbedder } from './embedder.js';
17
+ import { launchCompactionLens } from './content/lens-launch.js';
17
18
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
18
19
  import { join, dirname, resolve, basename } from 'node:path';
19
20
  import { fileURLToPath } from 'node:url';
@@ -2389,6 +2390,18 @@ class ClaudeLLMStream extends llm.LLMStream {
2389
2390
  catch (ucErr) {
2390
2391
  console.error('⚠️ PostCompact: USER_CONTEXT write failed:', ucErr instanceof Error ? ucErr.message : ucErr);
2391
2392
  }
2393
+ // ── Section 6: CONTENT_LENS — detached background worker appends to <project>/content-profile.md ──
2394
+ // Passes ONLY the session key (+ transcript_path/cwd); the worker derives everything else.
2395
+ // ON by default; OSBORN_CONTENT_LENS=0|off|false|no disables. Spawn is sync + unref'd: never awaited.
2396
+ try {
2397
+ if (!['0', 'off', 'false', 'no'].includes((process.env.OSBORN_CONTENT_LENS || '').trim().toLowerCase())) {
2398
+ const cl = launchCompactionLens({ sessionId: input?.session_id || sessionId, transcriptPath: input?.transcript_path, cwd: this.#opts.workingDirectory });
2399
+ progress('Content lens', cl.status === 'spawned' ? 'background worker started' : cl.status);
2400
+ }
2401
+ }
2402
+ catch (clErr) {
2403
+ console.error('⚠️ PostCompact: CONTENT_LENS launch failed:', clErr instanceof Error ? clErr.message : clErr);
2404
+ }
2392
2405
  this.#opts.onCompactionEvent?.({ type: 'compaction_complete', skillsWritten, skillNames });
2393
2406
  console.log(`🧠 PostCompact: complete — ${skillsWritten} skill file(s) written: [${skillNames.join(', ')}]`);
2394
2407
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * compaction-lens-worker.ts — DETACHED worker entry for the content lens.
3
+ *
4
+ * Two entries:
5
+ * node compaction-lens-worker.js <argfile>
6
+ * per-compaction (spawned by lens-launch.ts). The argfile (mode 0600,
7
+ * deleted immediately after reading) carries only
8
+ * { sessionId, transcriptPath?, cwd?, lockDir? }. INCREMENTAL: only
9
+ * session.db rows newer than the per-session high-water mark.
10
+ * node compaction-lens-worker.js --backfill <sessionId> [--cwd <dir>] [--max-windows N] [--out <path>]
11
+ * BACKFILL: pages the whole session from row 0 (bounded by the per-run
12
+ * cost/call cap). Takes the per-project lock itself. --out writes a review
13
+ * copy to <path> instead of content-profile.md and leaves the HWM alone.
14
+ * node compaction-lens-worker.js --library <sessionId> [--cwd <dir>] [--dry] [--out-dir <dir>]
15
+ * LIBRARY step only (manual / dry run). Takes the per-project lock itself.
16
+ * Per-compaction runs do TWO independent, fail-open steps under one lock:
17
+ * 1. the content lens (content-profile.md + HWM), unchanged;
18
+ * 2. the library step (lens-library.ts): one period-map page for the period
19
+ * that just ended + INDEX.md. A failure in either never stops the other.
20
+ * Fully self-contained: top-level try/catch, redacted log in the project dir,
21
+ * refreshes + releases the per-project lock, always exits.
22
+ */
23
+ export {};
@@ -0,0 +1,188 @@
1
+ /**
2
+ * compaction-lens-worker.ts — DETACHED worker entry for the content lens.
3
+ *
4
+ * Two entries:
5
+ * node compaction-lens-worker.js <argfile>
6
+ * per-compaction (spawned by lens-launch.ts). The argfile (mode 0600,
7
+ * deleted immediately after reading) carries only
8
+ * { sessionId, transcriptPath?, cwd?, lockDir? }. INCREMENTAL: only
9
+ * session.db rows newer than the per-session high-water mark.
10
+ * node compaction-lens-worker.js --backfill <sessionId> [--cwd <dir>] [--max-windows N] [--out <path>]
11
+ * BACKFILL: pages the whole session from row 0 (bounded by the per-run
12
+ * cost/call cap). Takes the per-project lock itself. --out writes a review
13
+ * copy to <path> instead of content-profile.md and leaves the HWM alone.
14
+ * node compaction-lens-worker.js --library <sessionId> [--cwd <dir>] [--dry] [--out-dir <dir>]
15
+ * LIBRARY step only (manual / dry run). Takes the per-project lock itself.
16
+ * Per-compaction runs do TWO independent, fail-open steps under one lock:
17
+ * 1. the content lens (content-profile.md + HWM), unchanged;
18
+ * 2. the library step (lens-library.ts): one period-map page for the period
19
+ * that just ended + INDEX.md. A failure in either never stops the other.
20
+ * Fully self-contained: top-level try/catch, redacted log in the project dir,
21
+ * refreshes + releases the per-project lock, always exits.
22
+ */
23
+ import { readFileSync, rmSync } from 'node:fs';
24
+ import { dirname } from 'node:path';
25
+ import { runCompactionLens } from './compaction-lens.js';
26
+ import { resolveSessionDb } from './lens-db.js';
27
+ import { acquireLock, refuseOutPath, releaseLock, resolveProjectDir, touchLock } from './lens-paths.js';
28
+ import { logLine } from './lens-profile.js';
29
+ import { runLibraryStep } from './lens-library.js';
30
+ // ≤2 big windows + reduce at ≤6 min/call, then the library step (1 call ≤6 min + model meta).
31
+ const INCREMENTAL_TIMEOUT_MS = 35 * 60_000;
32
+ const BACKFILL_TIMEOUT_MS = 3 * 60 * 60_000;
33
+ const LIBRARY_TIMEOUT_MS = 15 * 60_000;
34
+ function argVal(argv, flag) {
35
+ const i = argv.indexOf(flag);
36
+ return i >= 0 ? argv[i + 1] : undefined;
37
+ }
38
+ function parseJob(argv) {
39
+ if (argv[0] === '--library') {
40
+ const sessionId = argv[1];
41
+ if (!sessionId || sessionId.startsWith('--'))
42
+ throw new Error('usage: --library <sessionId> [--cwd <dir>] [--dry] [--out-dir <dir>]');
43
+ const cwd = argVal(argv, '--cwd');
44
+ const db = resolveSessionDb(sessionId, null, cwd);
45
+ const projectDir = db ? dirname(dirname(dirname(db))) : null;
46
+ const outDir = argv.includes('--out-dir') ? argVal(argv, '--out-dir') : undefined;
47
+ if (argv.includes('--out-dir') && (!outDir || outDir.startsWith('--')))
48
+ throw new Error('library: --out-dir requires a directory');
49
+ if (!projectDir || !acquireLock(projectDir))
50
+ throw new Error(`library: project dir ${projectDir ?? '(none)'} missing or locked`);
51
+ return {
52
+ opts: { sessionId, cwd, mode: 'incremental' },
53
+ kind: 'library',
54
+ library: { dry: argv.includes('--dry'), outDir },
55
+ lockDir: projectDir,
56
+ logDir: projectDir,
57
+ timeoutMs: LIBRARY_TIMEOUT_MS,
58
+ };
59
+ }
60
+ if (argv[0] === '--backfill') {
61
+ const sessionId = argv[1];
62
+ if (!sessionId)
63
+ throw new Error('usage: --backfill <sessionId> [--cwd <dir>] [--max-windows N] [--out <path>]');
64
+ const cwd = argVal(argv, '--cwd');
65
+ const db = resolveSessionDb(sessionId, null, cwd);
66
+ const projectDir = db ? dirname(dirname(dirname(db))) : cwd ? resolveProjectDir(undefined, cwd) : null;
67
+ // review copy: overwrite this file, never the profile, never move the HWM.
68
+ // Validated BEFORE the lock so a bad --out aborts without leaving a lock behind.
69
+ const outPath = argv.includes('--out') ? argVal(argv, '--out') : undefined;
70
+ if (argv.includes('--out')) {
71
+ const why = refuseOutPath(outPath, projectDir);
72
+ if (why)
73
+ throw Object.assign(new Error(`backfill: ${why}`), { logDir: projectDir });
74
+ }
75
+ if (!projectDir || !acquireLock(projectDir))
76
+ throw new Error(`backfill: project dir ${projectDir ?? '(none)'} missing or locked`);
77
+ const mw = Number(argVal(argv, '--max-windows'));
78
+ return {
79
+ opts: {
80
+ sessionId, cwd, mode: 'backfill', maxWindows: Number.isFinite(mw) && mw > 0 ? mw : undefined,
81
+ ...(outPath ? { outPath, updateHwm: false } : {}),
82
+ },
83
+ lockDir: projectDir,
84
+ logDir: projectDir,
85
+ timeoutMs: BACKFILL_TIMEOUT_MS,
86
+ };
87
+ }
88
+ const argFile = argv[0];
89
+ let payload = {};
90
+ try {
91
+ payload = JSON.parse(readFileSync(argFile, 'utf-8'));
92
+ }
93
+ finally {
94
+ try {
95
+ rmSync(argFile, { force: true });
96
+ }
97
+ catch {
98
+ /* ignore */
99
+ }
100
+ }
101
+ const lockDir = payload.lockDir || null;
102
+ const logDir = lockDir || resolveProjectDir(payload.transcriptPath, payload.cwd);
103
+ if (!payload.sessionId)
104
+ throw Object.assign(new Error('payload missing sessionId'), { lockDir, logDir });
105
+ return {
106
+ opts: { sessionId: payload.sessionId, transcriptPath: payload.transcriptPath, cwd: payload.cwd, mode: 'incremental' },
107
+ lockDir,
108
+ logDir,
109
+ timeoutMs: INCREMENTAL_TIMEOUT_MS,
110
+ };
111
+ }
112
+ async function main() {
113
+ let job = null;
114
+ let logDir = null;
115
+ let lockDir = null;
116
+ try {
117
+ try {
118
+ job = parseJob(process.argv.slice(2));
119
+ }
120
+ catch (err) {
121
+ lockDir = err?.lockDir ?? null;
122
+ logDir = err?.logDir ?? null;
123
+ throw err;
124
+ }
125
+ lockDir = job.lockDir;
126
+ logDir = job.logDir;
127
+ const sid = job.opts.sessionId.substring(0, 8);
128
+ const killer = setTimeout(() => {
129
+ logLine(logDir, `session ${sid}: hard timeout — exiting`);
130
+ if (lockDir)
131
+ releaseLock(lockDir);
132
+ process.exit(2);
133
+ }, job.timeoutMs);
134
+ killer.unref();
135
+ const t0 = Date.now();
136
+ let lensErr = null;
137
+ // Step 1 — content lens (skipped by the --library entry).
138
+ if (job.kind !== 'library') {
139
+ try {
140
+ const r = await runCompactionLens({
141
+ ...job.opts,
142
+ onWindow: () => lockDir && touchLock(lockDir),
143
+ log: m => logLine(logDir, `session ${sid} [${job.opts.mode}]: ${m}`),
144
+ });
145
+ logDir = r.projectDir || logDir;
146
+ logLine(logDir, `session ${sid}: done status=${r.status} mode=${r.mode} rows=${r.records} windows=${r.windowsProcessed}/${r.windowsTotal} ` +
147
+ `calls=${r.modelCalls} cost=$${r.costUsd.toFixed(4)} kept=${r.kept.angles}A/${r.kept.capabilities}C ` +
148
+ `dropped=${r.mapDropped.angles + r.dropped.angles}A/${r.mapDropped.capabilities + r.dropped.capabilities}C ` +
149
+ `notDone=${r.notDone} billing=${r.billingDropped} hwm=${r.hwmBefore}→${r.hwmAfter} ${Date.now() - t0}ms`);
150
+ }
151
+ catch (err) {
152
+ lensErr = err;
153
+ logLine(logDir, `worker error: ${err instanceof Error ? err.stack || err.message : String(err)}`);
154
+ }
155
+ }
156
+ // Step 2 — library page for the period that just ended (per-compaction + --library only; never backfill).
157
+ // runLibraryStep never throws; any failure is logged and the worker still exits cleanly.
158
+ if (job.opts.mode === 'incremental') {
159
+ if (lockDir)
160
+ touchLock(lockDir);
161
+ const lib = await runLibraryStep({
162
+ sessionId: job.opts.sessionId,
163
+ projectDir: lockDir,
164
+ cwd: job.opts.cwd,
165
+ ...job.library,
166
+ keepAlive: () => lockDir && touchLock(lockDir),
167
+ log: m => logLine(logDir, `session ${sid} [library]: ${m}`),
168
+ });
169
+ logLine(logDir, `session ${sid}: library status=${lib.status} cost=$${lib.costUsd.toFixed(4)}${lib.page ? ` page=${lib.page}` : ''}`);
170
+ if (job.kind === 'library')
171
+ process.stdout.write(`${JSON.stringify({ status: lib.status, page: lib.page, index: lib.index, period: lib.period, cost: lib.costUsd, errors: lib.errors })}\n`);
172
+ }
173
+ return lensErr ? 1 : 0;
174
+ }
175
+ catch (err) {
176
+ logLine(logDir, `worker error: ${err instanceof Error ? err.stack || err.message : String(err)}`);
177
+ if (err instanceof Error && /^(backfill|library):/.test(err.message))
178
+ process.stderr.write(`${err.message}\n`);
179
+ return 1;
180
+ }
181
+ finally {
182
+ if (lockDir)
183
+ releaseLock(lockDir);
184
+ }
185
+ }
186
+ main()
187
+ .then(code => process.exit(code))
188
+ .catch(() => process.exit(1));
@@ -0,0 +1,106 @@
1
+ /**
2
+ * compaction-lens.ts — the content-lens PIPELINE (runs inside the detached
3
+ * worker process, never in the live agent).
4
+ *
5
+ * 1. SOURCE = the session's recall store (session.db), conversation rows only
6
+ * (user + assistant text), secret-sanitized + client-redacted (lens-db.ts)
7
+ * 2. incremental: rows newer than the per-session high-water mark;
8
+ * backfill: the whole session from row 0
9
+ * 3. page IN ORDER in windows of ~25% of the model's context; MAP each window
10
+ * 4. extract ANGLES + CAPABILITIES (shipped | root-caused), verbatim quotes;
11
+ * plans/speculation and billing angles dropped
12
+ * 5. DETERMINISTIC grounding check against the rows — unverifiable items dropped
13
+ * 6. REDUCE/merge across windows; grounding check again
14
+ * 7. HN gap check (fail-open), optional recording, client-redact the entry,
15
+ * APPEND to <project>/content-profile.md (or a scratch file); advance HWM
16
+ */
17
+ import { type QuoteIndex, type RawAngle, type RawCapability } from './lens-quotes.js';
18
+ import { type FinalAngle, type FinalCapability } from './lens-profile.js';
19
+ export interface LensRunOptions {
20
+ sessionId: string;
21
+ transcriptPath?: string;
22
+ cwd?: string;
23
+ /** incremental (default, per compaction) = rows after the HWM; backfill = whole session. */
24
+ mode?: 'incremental' | 'backfill';
25
+ /** Cap on windows processed this run (incremental default OSBORN_LENS_MAX_WINDOWS || 2). */
26
+ maxWindows?: number;
27
+ /** Override the starting row (exclusive). */
28
+ startAfterRowId?: number;
29
+ /** Write here (overwrite) instead of appending to content-profile.md. Never the profile/HWM file; forces no HWM update. */
30
+ outPath?: string;
31
+ /** Ignored (always false) when outPath is set. */
32
+ updateHwm?: boolean;
33
+ /** Called after each window (worker refreshes its lock). */
34
+ onWindow?: () => void;
35
+ /** Harness-only hook to tamper with candidates before the final grounding check. */
36
+ mutateCandidates?: (c: Candidates) => void;
37
+ log?: (msg: string) => void;
38
+ }
39
+ export interface WindowStat {
40
+ rows: number;
41
+ chars: number;
42
+ estTokens: number;
43
+ firstRowId: number;
44
+ lastRowId: number;
45
+ }
46
+ export interface LensRunReport {
47
+ status: 'no-key' | 'no-db' | 'model-unknown' | 'no-input' | 'nothing-survived' | 'written';
48
+ mode: 'incremental' | 'backfill';
49
+ projectDir: string | null;
50
+ dbPath: string | null;
51
+ model?: {
52
+ id: string;
53
+ contextLength: number;
54
+ promptPerM: number;
55
+ completionPerM: number;
56
+ verified: boolean;
57
+ };
58
+ windowTokens: number;
59
+ rowsRead: number;
60
+ records: number;
61
+ sanitizerDropped: number;
62
+ windowsTotal: number;
63
+ windowsProcessed: number;
64
+ windows: WindowStat[];
65
+ modelCalls: number;
66
+ modelFailures: number;
67
+ refused: number;
68
+ costUsd: number;
69
+ mapDropped: {
70
+ angles: number;
71
+ capabilities: number;
72
+ };
73
+ dropped: {
74
+ angles: number;
75
+ capabilities: number;
76
+ };
77
+ notDone: number;
78
+ billingDropped: number;
79
+ kept: {
80
+ angles: number;
81
+ capabilities: number;
82
+ };
83
+ hwmBefore: number;
84
+ hwmAfter: number;
85
+ redactTerms: number;
86
+ denylistLoaded: boolean;
87
+ recording: string | null;
88
+ angles: FinalAngle[];
89
+ capabilities: FinalCapability[];
90
+ outPath?: string;
91
+ entry?: string;
92
+ }
93
+ export type Candidates = {
94
+ angles: RawAngle[];
95
+ capabilities: RawCapability[];
96
+ };
97
+ /** Parse + done-vs-planned gate + billing exclusion (angles: title/why; capabilities: name/did/proof). */
98
+ export declare function readCandidates(raw: string, maxA: number, maxC: number, r: Pick<LensRunReport, 'billingDropped' | 'notDone'>): Candidates;
99
+ /** Grounding check; afterwards drops any item whose VERIFIED quote text is billing talk (counted in billingDropped). */
100
+ export declare function ground(c: Candidates, idx: QuoteIndex, r: Pick<LensRunReport, 'billingDropped'>): {
101
+ c: Candidates;
102
+ dA: number;
103
+ dC: number;
104
+ };
105
+ /** Run the pipeline once. Throws only on unexpected errors (worker catches). */
106
+ export declare function runCompactionLens(opts: LensRunOptions): Promise<LensRunReport>;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * compaction-lens.ts — the content-lens PIPELINE (runs inside the detached
3
+ * worker process, never in the live agent).
4
+ *
5
+ * 1. SOURCE = the session's recall store (session.db), conversation rows only
6
+ * (user + assistant text), secret-sanitized + client-redacted (lens-db.ts)
7
+ * 2. incremental: rows newer than the per-session high-water mark;
8
+ * backfill: the whole session from row 0
9
+ * 3. page IN ORDER in windows of ~25% of the model's context; MAP each window
10
+ * 4. extract ANGLES + CAPABILITIES (shipped | root-caused), verbatim quotes;
11
+ * plans/speculation and billing angles dropped
12
+ * 5. DETERMINISTIC grounding check against the rows — unverifiable items dropped
13
+ * 6. REDUCE/merge across windows; grounding check again
14
+ * 7. HN gap check (fail-open), optional recording, client-redact the entry,
15
+ * APPEND to <project>/content-profile.md (or a scratch file); advance HWM
16
+ */
17
+ import { dirname } from 'node:path';
18
+ import { loadConversationRows, packWindows, resolveSessionDb } from './lens-db.js';
19
+ import { buildRecordIndex, explainQuote, parseJsonObject, readAngles, readCapabilities, splitByEvidence, verifyItems, } from './lens-quotes.js';
20
+ import { Budget, chat, getModelInfo, mapPrompt, reducePrompt, windowTokensFor } from './lens-model.js';
21
+ import { DENYLIST_FILE, hasBillingQuote, isBillingAngle, isBillingCapability, loadClientRedactor } from './lens-redact.js';
22
+ import { gapCheck } from './lens-hn.js';
23
+ import { alignRecording, findSessionRecording } from './lens-audio.js';
24
+ import { appendProfile, formatEntry, writeScratch } from './lens-profile.js';
25
+ import { findTranscriptBySessionId, isProjectDir, readHwm, refuseOutPath, resolveProjectDir, writeHwm } from './lens-paths.js';
26
+ const LIMITS = {
27
+ map: { a: 4, c: 8 },
28
+ incremental: { a: 3, c: 6 },
29
+ backfill: { a: 8, c: 20 },
30
+ };
31
+ const envInt = (k, d) => {
32
+ const n = Math.floor(Number(process.env[k]));
33
+ return Number.isFinite(n) && n > 0 ? n : d;
34
+ };
35
+ /** Parse + done-vs-planned gate + billing exclusion (angles: title/why; capabilities: name/did/proof). */
36
+ export function readCandidates(raw, maxA, maxC, r) {
37
+ const o = parseJsonObject(raw);
38
+ const angles = readAngles(o?.angles, maxA);
39
+ const { done, notDone } = splitByEvidence(readCapabilities(o?.capabilities, maxC));
40
+ r.notDone += notDone;
41
+ const keptA = angles.filter(a => !isBillingAngle(a));
42
+ const keptC = done.filter(c => !isBillingCapability(c));
43
+ r.billingDropped += angles.length - keptA.length + done.length - keptC.length;
44
+ return { angles: keptA, capabilities: keptC };
45
+ }
46
+ /** Grounding check; afterwards drops any item whose VERIFIED quote text is billing talk (counted in billingDropped). */
47
+ export function ground(c, idx, r) {
48
+ const a = verifyItems(c.angles, idx);
49
+ const k = verifyItems(c.capabilities, idx);
50
+ const angles = a.kept.filter(x => !hasBillingQuote(x));
51
+ const capabilities = k.kept.filter(x => !hasBillingQuote(x));
52
+ r.billingDropped += a.kept.length - angles.length + k.kept.length - capabilities.length;
53
+ return { c: { angles, capabilities }, dA: a.dropped, dC: k.dropped };
54
+ }
55
+ /** Run the pipeline once. Throws only on unexpected errors (worker catches). */
56
+ export async function runCompactionLens(opts) {
57
+ const log = opts.log ?? (() => { });
58
+ const mode = opts.mode ?? 'incremental';
59
+ let projectDir = resolveProjectDir(opts.transcriptPath, opts.cwd);
60
+ if (!projectDir) {
61
+ const t = findTranscriptBySessionId(opts.sessionId);
62
+ if (t && isProjectDir(dirname(t)))
63
+ projectDir = dirname(t);
64
+ }
65
+ const r = {
66
+ status: 'no-input', mode, projectDir, dbPath: null, windowTokens: 0, rowsRead: 0, records: 0, sanitizerDropped: 0,
67
+ windowsTotal: 0, windowsProcessed: 0, windows: [], modelCalls: 0, modelFailures: 0, refused: 0, costUsd: 0,
68
+ mapDropped: { angles: 0, capabilities: 0 }, dropped: { angles: 0, capabilities: 0 }, notDone: 0, billingDropped: 0,
69
+ kept: { angles: 0, capabilities: 0 }, hwmBefore: 0, hwmAfter: 0, redactTerms: 0, denylistLoaded: false, recording: null, angles: [], capabilities: [],
70
+ };
71
+ if (opts.outPath !== undefined) {
72
+ const why = refuseOutPath(opts.outPath, projectDir);
73
+ if (why)
74
+ throw new Error(`content lens: ${why}`);
75
+ }
76
+ const apiKey = process.env.OPENROUTER_API_KEY;
77
+ if (!apiKey)
78
+ return { ...r, status: 'no-key' };
79
+ r.dbPath = resolveSessionDb(opts.sessionId, projectDir, opts.cwd);
80
+ if (!r.dbPath)
81
+ return { ...r, status: 'no-db' };
82
+ if (!projectDir)
83
+ projectDir = r.projectDir = dirname(dirname(dirname(r.dbPath)));
84
+ const info = await getModelInfo(apiKey);
85
+ if (!info)
86
+ return { ...r, status: 'model-unknown' };
87
+ r.model = { id: info.id, contextLength: info.contextLength, promptPerM: info.promptPerTok * 1e6, completionPerM: info.completionPerTok * 1e6, verified: info.verified };
88
+ r.windowTokens = windowTokensFor(info);
89
+ r.hwmBefore = r.hwmAfter = readHwm(projectDir, opts.sessionId);
90
+ const start = opts.startAfterRowId ?? (mode === 'backfill' ? 0 : r.hwmBefore);
91
+ const redactor = loadClientRedactor(projectDir);
92
+ r.redactTerms = redactor.terms;
93
+ r.denylistLoaded = !!redactor.source;
94
+ if (!redactor.source)
95
+ log(`WARNING: no client denylist loaded (${DENYLIST_FILE} in project dir or OSBORN_LENS_DENYLIST) — shape-based redaction only`);
96
+ const load = loadConversationRows(r.dbPath, start, redactor.redact);
97
+ r.rowsRead = load.stats.rowsRead;
98
+ r.records = load.records.length;
99
+ r.sanitizerDropped = load.stats.dropped;
100
+ // A review copy (outPath) NEVER moves the HWM, whatever updateHwm says.
101
+ const updateHwm = opts.outPath !== undefined ? false : (opts.updateHwm ?? true);
102
+ const bumpHwm = (rowId) => {
103
+ if (!updateHwm || rowId <= r.hwmAfter)
104
+ return;
105
+ writeHwm(projectDir, opts.sessionId, rowId);
106
+ r.hwmAfter = rowId;
107
+ };
108
+ const all = packWindows(load.records, r.windowTokens);
109
+ r.windowsTotal = all.length;
110
+ const maxWindows = opts.maxWindows ?? (mode === 'backfill' ? all.length : envInt('OSBORN_LENS_MAX_WINDOWS', 2));
111
+ const todo = all.slice(0, maxWindows);
112
+ log(`db ${r.dbPath}: rows>${start} read=${r.rowsRead} kept=${r.records} → ${all.length} window(s) of ≤${r.windowTokens} tok; running ${todo.length}`);
113
+ if (!todo.length) {
114
+ bumpHwm(load.maxRowId); // everything new was sanitizer-dropped noise
115
+ return r;
116
+ }
117
+ // MAP — in order; stop at the first failure/refusal so the HWM never skips a window.
118
+ const budget = new Budget(info);
119
+ const pool = { angles: [], capabilities: [] };
120
+ const seen = [];
121
+ for (const w of todo) {
122
+ r.windows.push({ rows: w.records.length, chars: w.chars, estTokens: w.estTokens, firstRowId: w.firstRowId, lastRowId: w.lastRowId });
123
+ let res;
124
+ try {
125
+ res = await chat(budget, mapPrompt(w.text, w.index + 1, all.length, LIMITS.map.a, LIMITS.map.c), apiKey);
126
+ }
127
+ catch (err) {
128
+ log(`map ${w.index + 1}/${all.length} failed: ${err instanceof Error ? err.message : err}`);
129
+ break;
130
+ }
131
+ if (!res) {
132
+ log(`map ${w.index + 1}/${all.length}: budget cap reached ($${budget.spentUsd.toFixed(4)}/${budget.maxCostUsd}, ${budget.calls}/${budget.maxCalls} calls) — stopping`);
133
+ break;
134
+ }
135
+ seen.push(...w.records);
136
+ r.windowsProcessed++;
137
+ const parsed = readCandidates(res.content, LIMITS.map.a, LIMITS.map.c, r);
138
+ if (parsed.angles.length + parsed.capabilities.length === 0) {
139
+ log(`map ${w.index + 1}: no items parsed (provider=${res.provider}); reply head: ${res.content.slice(0, 300)}`);
140
+ }
141
+ const widx = buildRecordIndex(w.records);
142
+ if (process.env.OSBORN_LENS_DEBUG) {
143
+ for (const it of [...parsed.angles, ...parsed.capabilities]) {
144
+ for (const q of it.quotes) {
145
+ const why = explainQuote(q, widx);
146
+ if (why !== 'ok')
147
+ log(` quote fail [${why}] @${q?.timestamp} #${q?.row}: ${String(q?.text).slice(0, 120)}`);
148
+ }
149
+ }
150
+ }
151
+ const g = ground(parsed, widx, r);
152
+ r.mapDropped.angles += g.dA;
153
+ r.mapDropped.capabilities += g.dC;
154
+ pool.angles.push(...g.c.angles);
155
+ pool.capabilities.push(...g.c.capabilities);
156
+ log(`map ${w.index + 1}/${all.length}: ${w.records.length} rows ~${w.estTokens} tok, ${res.promptTokens}+${res.completionTokens} tok finish=${res.finish} $${res.costUsd.toFixed(4)}; kept ${g.c.angles.length}A/${g.c.capabilities.length}C dropped ${g.dA}A/${g.dC}C`);
157
+ opts.onWindow?.();
158
+ }
159
+ r.modelCalls = budget.calls;
160
+ if (!r.windowsProcessed) {
161
+ r.modelFailures = budget.failures;
162
+ r.refused = budget.refused;
163
+ r.costUsd = budget.spentUsd;
164
+ return { ...r, status: 'nothing-survived' };
165
+ }
166
+ const lim = LIMITS[mode];
167
+ const idx = buildRecordIndex(seen);
168
+ // REDUCE — merge across windows (skipped when one window already fits the caps).
169
+ let cands = pool;
170
+ const needReduce = r.windowsProcessed > 1 || pool.angles.length > lim.a || pool.capabilities.length > lim.c;
171
+ if (needReduce && pool.angles.length + pool.capabilities.length > 0) {
172
+ try {
173
+ const res = await chat(budget, reducePrompt(JSON.stringify(pool), lim.a, lim.c), apiKey, 12_000);
174
+ const out = res ? readCandidates(res.content, lim.a, lim.c, r) : null;
175
+ if (out && out.angles.length + out.capabilities.length > 0)
176
+ cands = out;
177
+ else
178
+ log(res ? 'reduce returned nothing — using grounded pool' : 'reduce refused by budget — using grounded pool');
179
+ }
180
+ catch (err) {
181
+ log(`reduce failed: ${err instanceof Error ? err.message : err}`);
182
+ }
183
+ }
184
+ cands = { angles: cands.angles.slice(0, lim.a), capabilities: cands.capabilities.slice(0, lim.c) };
185
+ r.modelCalls = budget.calls;
186
+ r.modelFailures = budget.failures;
187
+ r.refused = budget.refused;
188
+ r.costUsd = budget.spentUsd;
189
+ opts.mutateCandidates?.(cands);
190
+ // Final deterministic grounding check (no model).
191
+ const fin = ground(cands, idx, r);
192
+ r.dropped = { angles: fin.dA, capabilities: fin.dC };
193
+ r.kept = { angles: fin.c.angles.length, capabilities: fin.c.capabilities.length };
194
+ log(`calls ${r.modelCalls} $${r.costUsd.toFixed(4)}; final kept ${r.kept.angles}A/${r.kept.capabilities}C, dropped ${fin.dA}A/${fin.dC}C, notDone ${r.notDone}, billing ${r.billingDropped}`);
195
+ const covered = r.windowsProcessed === all.length ? load.maxRowId : todo[r.windowsProcessed - 1].lastRowId;
196
+ if (r.kept.angles + r.kept.capabilities === 0) {
197
+ bumpHwm(covered);
198
+ return { ...r, status: 'nothing-survived' };
199
+ }
200
+ for (const a of fin.c.angles) {
201
+ r.angles.push({ title: a.title, why: a.why, quotes: a.quotes, gap: await gapCheck(a.queries) });
202
+ }
203
+ r.capabilities = fin.c.capabilities.map(c => ({
204
+ name: c.name, did: c.did, evidence: c.evidence, proof: c.proof, quotes: c.quotes,
205
+ }));
206
+ const rec = findSessionRecording(opts.sessionId, projectDir);
207
+ const align = rec ? alignRecording(rec, seen[0]?.timestamp) : null;
208
+ r.recording = align?.path ?? null;
209
+ const entry = formatEntry(r.angles, r.capabilities, {
210
+ sessionId: opts.sessionId, source: 'recall-db', mode, model: info.id, records: seen.length,
211
+ rowRange: [seen[0].id, seen[seen.length - 1].id], windows: r.windowsProcessed, windowsTotal: all.length,
212
+ modelCalls: r.modelCalls, costUsd: r.costUsd, notDone: r.notDone, billingDropped: r.billingDropped, recording: align,
213
+ noDenylist: !r.denylistLoaded,
214
+ dropped: { angles: r.mapDropped.angles + r.dropped.angles, capabilities: r.mapDropped.capabilities + r.dropped.capabilities },
215
+ });
216
+ r.entry = redactor.redact(entry); // client redaction again on everything written
217
+ r.outPath = opts.outPath ? writeScratch(opts.outPath, r.entry) : appendProfile(projectDir, r.entry);
218
+ bumpHwm(covered);
219
+ r.status = 'written';
220
+ log(`wrote ${r.outPath}; hwm ${r.hwmBefore}→${r.hwmAfter}`);
221
+ return r;
222
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * lens-audio.ts — OPTIONAL, pluggable session-recording lookup (no paid calls).
3
+ *
4
+ * LiveKit egress is not enabled today, so this is a convention-based local
5
+ * lookup. Checks, in order:
6
+ * 1. <projectDir>/recordings/<sessionId>.<media-ext>
7
+ * 2. $OSBORN_RECORDINGS_DIR/<sessionId>.<media-ext>
8
+ * An optional sidecar <sessionId>.json ({ "startedAt": ISO | epoch-ms | "started_at": ns })
9
+ * pins the recording start exactly; without it the start is approximated by the
10
+ * first transcript record's timestamp and offsets are marked approximate.
11
+ */
12
+ export interface SessionRecording {
13
+ path: string;
14
+ /** Recording start (epoch ms) if known from a sidecar; else null. */
15
+ startedAtMs: number | null;
16
+ }
17
+ export interface RecordingAlignment {
18
+ path: string;
19
+ startIso: string;
20
+ approximate: boolean;
21
+ basis: string;
22
+ }
23
+ /** Pluggable lookup. Returns null when no recording exists (the normal case today). */
24
+ export declare function findSessionRecording(sessionId: string, projectDir: string | null): SessionRecording | null;
25
+ /** Choose the alignment origin: sidecar start (exact) or first transcript record (approximate). */
26
+ export declare function alignRecording(rec: SessionRecording, firstRecordIso: string | undefined): RecordingAlignment | null;
27
+ /**
28
+ * OPTIONAL input adapter for recordings. Every lens stage (period map, clip,
29
+ * library, formats) works on the transcript alone; when an adapter returns an
30
+ * alignment, row ranges additionally get clip offsets, otherwise they stay rows only.
31
+ */
32
+ export interface AudioAdapter {
33
+ align(sessionId: string, projectDir: string | null, firstRecordIso: string | undefined): RecordingAlignment | null;
34
+ }
35
+ /** Default adapter: the local convention lookup above (null today — no egress). */
36
+ export declare const localRecordingAdapter: AudioAdapter;
37
+ /** Clip offset "H:MM:SS" of a quote relative to the recording start; null if before start. */
38
+ export declare function clipOffset(quoteIso: string, startIso: string): string | null;