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
@@ -0,0 +1,193 @@
1
+ /**
2
+ * lens-model.ts — OpenRouter model calls for the content lens (map-reduce).
3
+ * OpenRouter ONLY — never the Claude subscription / Anthropic SDK.
4
+ *
5
+ * Model: big-window, env-overridable (OSBORN_CONTENT_LENS_MODEL). Default
6
+ * minimax/minimax-m3 — verified 2026-10-05 via GET /api/v1/models:
7
+ * context_length 1,048,576, $0.30/M prompt, $1.20/M completion (top provider
8
+ * 524,288 ctx). Window = 25% of the model's context_length, looked up live so an
9
+ * override is sized correctly; the constants below are only the fallback.
10
+ *
11
+ * Credit is limited, so every run has a HARD cap on calls AND dollars
12
+ * (OSBORN_LENS_MAX_CALLS / OSBORN_LENS_MAX_COST_USD). A call is refused up front
13
+ * if its worst-case cost would cross the cap; actual cost comes from OpenRouter's
14
+ * usage accounting when present.
15
+ */
16
+ const OPENROUTER = 'https://openrouter.ai/api/v1';
17
+ export const DEFAULT_MODEL = 'minimax/minimax-m3';
18
+ const DEFAULT_MODEL_INFO = { contextLength: 1_048_576, promptPerTok: 0.3e-6, completionPerTok: 1.2e-6 };
19
+ export const WINDOW_FRACTION = 0.25;
20
+ const CALL_TIMEOUT_MS = 6 * 60_000;
21
+ const META_TIMEOUT_MS = 15_000;
22
+ const envNum = (k, d) => {
23
+ const n = Number(process.env[k]);
24
+ return Number.isFinite(n) && n > 0 ? n : d;
25
+ };
26
+ export const lensModel = () => (process.env.OSBORN_CONTENT_LENS_MODEL || '').trim() || DEFAULT_MODEL;
27
+ /** Live context length + price. Null for an unknown overridden model (can't enforce the cost cap). */
28
+ export async function getModelInfo(apiKey, id = lensModel()) {
29
+ const ctrl = new AbortController();
30
+ const timer = setTimeout(() => ctrl.abort(), META_TIMEOUT_MS);
31
+ try {
32
+ const resp = await fetch(`${OPENROUTER}/models`, { signal: ctrl.signal, headers: { Authorization: `Bearer ${apiKey}` } });
33
+ if (resp.ok) {
34
+ const data = await resp.json();
35
+ const m = (data?.data ?? []).find((x) => x?.id === id);
36
+ const p = Number(m?.pricing?.prompt);
37
+ const c = Number(m?.pricing?.completion);
38
+ if (m && m.context_length > 0 && p >= 0 && c >= 0) {
39
+ return { id, contextLength: m.context_length, promptPerTok: p, completionPerTok: c, verified: true };
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ /* fall through */
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ }
49
+ return id === DEFAULT_MODEL ? { id, ...DEFAULT_MODEL_INFO, verified: false } : null;
50
+ }
51
+ export const windowTokensFor = (info) => Math.floor(info.contextLength * WINDOW_FRACTION);
52
+ export class Budget {
53
+ info;
54
+ calls = 0;
55
+ failures = 0;
56
+ spentUsd = 0;
57
+ refused = 0;
58
+ maxCalls;
59
+ maxCostUsd;
60
+ /** caps override the env/default caps (e.g. the period map's own $0.50 hard cap). */
61
+ constructor(info, caps = {}) {
62
+ this.info = info;
63
+ this.maxCalls = caps.maxCalls ?? envNum('OSBORN_LENS_MAX_CALLS', 14);
64
+ this.maxCostUsd = caps.maxCostUsd ?? envNum('OSBORN_LENS_MAX_COST_USD', 1.25);
65
+ }
66
+ estimate(promptTokens, maxOut) {
67
+ return promptTokens * this.info.promptPerTok + maxOut * this.info.completionPerTok;
68
+ }
69
+ /** Reserve one call if both caps allow its worst case. */
70
+ take(promptTokens, maxOut) {
71
+ if (this.calls >= this.maxCalls || this.spentUsd + this.estimate(promptTokens, maxOut) > this.maxCostUsd) {
72
+ this.refused++;
73
+ return false;
74
+ }
75
+ this.calls++;
76
+ return true;
77
+ }
78
+ }
79
+ const SYSTEM = 'You read engineering work transcripts between a person (user) and their AI coding assistant (assistant). ' +
80
+ 'You never invent facts. You quote ONLY verbatim text that appears in the transcript. Reply with one JSON object only.';
81
+ const QUOTE_RULES = 'QUOTES: every item needs 1-3 quotes. Each quote = {"text": exact verbatim substring (12-300 chars) copied character-for-character ' +
82
+ 'from ONE record, "timestamp": that record\'s exact timestamp from its [#row | timestamp | speaker] header, "row": that record\'s row number ' +
83
+ '(digits only), "speaker": "user"|"assistant"}. Do not paraphrase, merge, or fix typos inside quotes. ' +
84
+ 'ONE contiguous span: no ellipses ("..."/"…"), no skipped words, no joined sentences — if it is too long, quote a shorter span. ' +
85
+ 'Never quote secrets, keys, tokens, emails, or private personal details.';
86
+ function itemRules(maxA, maxC) {
87
+ return (`ANGLES (0-${maxA}): things a builder could post publicly — {"title", "why" (one line: why postable), "quotes", ` +
88
+ '"queries": 1-2 short Hacker News search queries (2-5 words) to check if this is already covered}. ' +
89
+ 'NEVER produce items about personal invoicing, retainers, refunds owed, overdue payments, contract/rate negotiation, or money ' +
90
+ 'owed to or by the person — not content material. Billing as ENGINEERING is fine (Stripe/payment webhooks or API integrations, ' +
91
+ 'billing APIs, payment gateways, ads-API spend/budgets/spend caps/pricing, token pricing, rate limits, wallet holds).\n' +
92
+ 'Client/customer details are already masked as tags like [client], [founder], [brand], [ad account], [amount]: keep the tags, ' +
93
+ 'never guess or reintroduce real names, client companies, IDs or amounts. Refer to client companies as "[client]" or ' +
94
+ '"a marketing-platform client". Frame client work as the technical lesson.\n' +
95
+ `CAPABILITIES (0-${maxC}): what this person DEMONSTRABLY did. Two kinds count, tag each with "evidence":\n` +
96
+ ' - "shipped": built / deployed / merged / verified working — the quotes show it was DONE (tests passed, deployed, confirmed working).\n' +
97
+ ' - "root-caused": debugging where the root cause was actually FOUND or PROVEN (e.g. "found the missing x-forwarded-host header ' +
98
+ 'causing WS 1006"), even if no fix shipped yet.\n' +
99
+ ' EXCLUDE: plans, proposals, TODOs, "we could/should/will", ideas, unresolved speculation, things only discussed. ' +
100
+ 'If the quotes only show intent or a hypothesis, leave it out.\n' +
101
+ ' Each = {"name": SPECIFIC capability (what + where + the non-obvious detail), e.g. "debugged Meta IG ad-authorization failing ' +
102
+ 'silently unless page is business-owned" — NOT generic labels like "debugging", "api-integration", "node-development"; ' +
103
+ '"evidence": "shipped"|"root-caused", "did": one line on what was done/found, "proof": one line naming which quote shows it was ' +
104
+ 'done/found, "quotes"}.\nReturn empty arrays if nothing qualifies.');
105
+ }
106
+ /**
107
+ * Transcript FIRST, instructions AFTER: measured on minimax-m3 with a ~257k-token
108
+ * window, instructions-before-transcript returned {"angles":[],"capabilities":[]}
109
+ * (the task gets lost in a long context); the query belongs at the end.
110
+ */
111
+ export function mapPrompt(window, part, total, maxA, maxC) {
112
+ return [
113
+ `Below is transcript window ${part}/${total} of one long engineering work session (chronological, conversation only). ` +
114
+ 'Read all of it; your task follows after the transcript.',
115
+ '<transcript>',
116
+ window,
117
+ '</transcript>',
118
+ `TASK: extract candidate angles and capabilities from the transcript window above (${part}/${total}). ` +
119
+ 'A window this long normally contains several real accomplishments and root-caused bugs — scan the whole window, ' +
120
+ 'beginning to end, before answering. Return empty arrays only if there truly is nothing done/proven.',
121
+ itemRules(maxA, maxC),
122
+ QUOTE_RULES,
123
+ 'Output ONLY: {"angles": [...], "capabilities": [...]}',
124
+ ].join('\n\n');
125
+ }
126
+ export function reducePrompt(candidatesJson, maxA, maxC) {
127
+ return [
128
+ 'Below are candidate angles and capabilities extracted window-by-window from ONE work session (quotes already verified). ' +
129
+ `Merge duplicates (same work = one item, keep the most specific name), keep the strongest: at most ${maxA} angles and ${maxC} ` +
130
+ 'capabilities. Re-check every capability: drop it unless its quotes show it was shipped/verified or a root cause was proven.',
131
+ itemRules(maxA, maxC),
132
+ 'Keep each item\'s quotes EXACTLY as given (copy text, timestamp and row unchanged); you may drop quotes, never edit them.',
133
+ 'Output: {"angles": [...], "capabilities": [...]}',
134
+ '<candidates>',
135
+ candidatesJson,
136
+ '</candidates>',
137
+ ].join('\n\n');
138
+ }
139
+ /** One chat call. Returns null when the budget refuses; throws on HTTP/timeout errors. */
140
+ export async function chat(budget, user, apiKey, maxOut = 16_000) {
141
+ const estPrompt = Math.ceil((SYSTEM.length + user.length) / 4);
142
+ if (!budget.take(estPrompt, maxOut))
143
+ return null;
144
+ const ctrl = new AbortController();
145
+ const timer = setTimeout(() => ctrl.abort(), CALL_TIMEOUT_MS);
146
+ try {
147
+ const resp = await fetch(`${OPENROUTER}/chat/completions`, {
148
+ method: 'POST',
149
+ signal: ctrl.signal,
150
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
151
+ body: JSON.stringify({
152
+ model: budget.info.id,
153
+ temperature: 0.1,
154
+ max_tokens: maxOut,
155
+ // Reasoning OFF: measured on minimax-m3, effort:'low' still burned ~5k hidden
156
+ // tokens of a 6k cap (finish=length, truncated JSON); enabled:false → 0, finish=stop.
157
+ reasoning: { enabled: false },
158
+ usage: { include: true },
159
+ // Cheapest provider that fits the prompt (OpenRouter skips too-small contexts).
160
+ provider: { sort: 'price' },
161
+ messages: [
162
+ { role: 'system', content: SYSTEM },
163
+ { role: 'user', content: user },
164
+ ],
165
+ }),
166
+ });
167
+ if (!resp.ok)
168
+ throw new Error(`OpenRouter HTTP ${resp.status}`);
169
+ const data = await resp.json();
170
+ const pt = Number(data?.usage?.prompt_tokens) || estPrompt;
171
+ const ct = Number(data?.usage?.completion_tokens) || 0;
172
+ const reported = Number(data?.usage?.cost);
173
+ const cost = Number.isFinite(reported) && reported > 0 ? reported : pt * budget.info.promptPerTok + ct * budget.info.completionPerTok;
174
+ budget.spentUsd += cost;
175
+ return {
176
+ content: String(data?.choices?.[0]?.message?.content ?? ''),
177
+ finish: String(data?.choices?.[0]?.finish_reason ?? ''),
178
+ provider: String(data?.provider ?? ''),
179
+ promptTokens: pt,
180
+ completionTokens: ct,
181
+ costUsd: cost,
182
+ };
183
+ }
184
+ catch (err) {
185
+ budget.failures++;
186
+ // A failed/timed-out call may still bill: charge the worst case so the cap stays hard.
187
+ budget.spentUsd += budget.estimate(estPrompt, maxOut);
188
+ throw err;
189
+ }
190
+ finally {
191
+ clearTimeout(timer);
192
+ }
193
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * lens-paths.ts — LIGHT path + lock helpers shared by the hook-side launcher
3
+ * and the background worker. node: builtins only (safe to import in the live agent).
4
+ */
5
+ export declare const PROFILE_FILE = "content-profile.md";
6
+ export declare const LOCK_FILE = ".content-lens.lock";
7
+ export declare const LOG_FILE = "content-lens.log";
8
+ export declare const LOCK_TTL_MS: number;
9
+ export declare function claudeDir(): string;
10
+ /** Same slug rule as session-access.ts projectPathToSlug(). */
11
+ export declare function slugDir(cwd: string): string;
12
+ /** True if `dir` is a project dir: strictly under <claudeDir>/projects/ and not a subagents dir. */
13
+ export declare function isProjectDir(dir: string): boolean;
14
+ /**
15
+ * The transcript's project dir (guarded), else the cwd slug fallback, else null.
16
+ * A sub-agent transcript (…/<sid>/subagents/x.jsonl) never qualifies.
17
+ */
18
+ export declare function resolveProjectDir(transcriptPath?: string, cwd?: string): string | null;
19
+ /** Locate `<claudeDir>/projects/<any>/<sessionId>.jsonl` (project-scoped). */
20
+ export declare function findTranscriptBySessionId(sessionId: string, preferDir?: string | null): string | null;
21
+ /**
22
+ * Acquire the per-project lock. Returns false if a FRESH (<15 min) lock exists.
23
+ * Stale locks are replaced. Exclusive-create avoids a check-then-write race.
24
+ */
25
+ export declare function acquireLock(projectDir: string, now?: number): boolean;
26
+ export declare function releaseLock(projectDir: string): void;
27
+ /** Refresh the lock's mtime so a long (backfill) run isn't treated as stale. */
28
+ export declare function touchLock(projectDir: string): void;
29
+ export declare const sessionIdFromPath: (p: string) => string;
30
+ export declare const HWM_FILE = ".content-lens-hwm.json";
31
+ export interface HwmEntry {
32
+ lastRowId: number;
33
+ lastTs?: string;
34
+ updatedAt: string;
35
+ }
36
+ export declare function readHwm(projectDir: string | null, sessionId: string): number;
37
+ /**
38
+ * A review --out path must never be the profile or the HWM file (of this or any
39
+ * project; symlinks resolved). Returns a reason string when refused, else null.
40
+ */
41
+ export declare function refuseOutPath(outPath: string | undefined, projectDir?: string | null): string | null;
42
+ /** Monotonic: never moves the mark backwards. Atomic write via rename. */
43
+ export declare function writeHwm(projectDir: string, sessionId: string, lastRowId: number, lastTs?: string): void;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * lens-paths.ts — LIGHT path + lock helpers shared by the hook-side launcher
3
+ * and the background worker. node: builtins only (safe to import in the live agent).
4
+ */
5
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync, writeSync, } from 'node:fs';
6
+ import { basename, dirname, join, resolve, sep } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+ export const PROFILE_FILE = 'content-profile.md';
9
+ export const LOCK_FILE = '.content-lens.lock';
10
+ export const LOG_FILE = 'content-lens.log';
11
+ export const LOCK_TTL_MS = 15 * 60_000;
12
+ export function claudeDir() {
13
+ return process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
14
+ }
15
+ /** Same slug rule as session-access.ts projectPathToSlug(). */
16
+ export function slugDir(cwd) {
17
+ return join(claudeDir(), 'projects', cwd.replace(/\//g, '-'));
18
+ }
19
+ /** True if `dir` is a project dir: strictly under <claudeDir>/projects/ and not a subagents dir. */
20
+ export function isProjectDir(dir) {
21
+ const root = resolve(claudeDir(), 'projects') + sep;
22
+ const d = resolve(dir);
23
+ if (!d.startsWith(root))
24
+ return false;
25
+ return !d.slice(root.length).split(sep).includes('subagents');
26
+ }
27
+ /**
28
+ * The transcript's project dir (guarded), else the cwd slug fallback, else null.
29
+ * A sub-agent transcript (…/<sid>/subagents/x.jsonl) never qualifies.
30
+ */
31
+ export function resolveProjectDir(transcriptPath, cwd) {
32
+ if (transcriptPath && transcriptPath.endsWith('.jsonl')) {
33
+ const d = dirname(resolve(transcriptPath));
34
+ if (isProjectDir(d))
35
+ return d;
36
+ }
37
+ if (cwd)
38
+ return slugDir(cwd);
39
+ return null;
40
+ }
41
+ const SID_RE = /^[A-Za-z0-9._-]{1,128}$/;
42
+ /** Locate `<claudeDir>/projects/<any>/<sessionId>.jsonl` (project-scoped). */
43
+ export function findTranscriptBySessionId(sessionId, preferDir) {
44
+ if (!sessionId || !SID_RE.test(sessionId))
45
+ return null;
46
+ const name = `${sessionId}.jsonl`;
47
+ if (preferDir && existsSync(join(preferDir, name)))
48
+ return join(preferDir, name);
49
+ const root = join(claudeDir(), 'projects');
50
+ try {
51
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
52
+ if (!ent.isDirectory())
53
+ continue;
54
+ const p = join(root, ent.name, name);
55
+ if (existsSync(p))
56
+ return p;
57
+ }
58
+ }
59
+ catch {
60
+ /* no projects dir */
61
+ }
62
+ return null;
63
+ }
64
+ /**
65
+ * Acquire the per-project lock. Returns false if a FRESH (<15 min) lock exists.
66
+ * Stale locks are replaced. Exclusive-create avoids a check-then-write race.
67
+ */
68
+ export function acquireLock(projectDir, now = Date.now()) {
69
+ const p = join(projectDir, LOCK_FILE);
70
+ for (let attempt = 0; attempt < 2; attempt++) {
71
+ try {
72
+ const fd = openSync(p, 'wx', 0o600);
73
+ writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date(now).toISOString() }));
74
+ closeSync(fd);
75
+ return true;
76
+ }
77
+ catch (e) {
78
+ if (e?.code !== 'EEXIST')
79
+ return false;
80
+ try {
81
+ if (now - statSync(p).mtimeMs < LOCK_TTL_MS)
82
+ return false;
83
+ rmSync(p, { force: true }); // stale → replace once
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ }
89
+ }
90
+ return false;
91
+ }
92
+ export function releaseLock(projectDir) {
93
+ try {
94
+ rmSync(join(projectDir, LOCK_FILE), { force: true });
95
+ }
96
+ catch {
97
+ /* ignore */
98
+ }
99
+ }
100
+ /** Refresh the lock's mtime so a long (backfill) run isn't treated as stale. */
101
+ export function touchLock(projectDir) {
102
+ try {
103
+ const now = new Date();
104
+ utimesSync(join(projectDir, LOCK_FILE), now, now);
105
+ }
106
+ catch {
107
+ /* ignore */
108
+ }
109
+ }
110
+ export const sessionIdFromPath = (p) => basename(p, '.jsonl');
111
+ // ── Per-session high-water mark (last session.db content.id the profile covers) ──
112
+ export const HWM_FILE = '.content-lens-hwm.json';
113
+ function readHwmFile(projectDir) {
114
+ try {
115
+ const o = JSON.parse(readFileSync(join(projectDir, HWM_FILE), 'utf-8'));
116
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : {};
117
+ }
118
+ catch {
119
+ return {};
120
+ }
121
+ }
122
+ export function readHwm(projectDir, sessionId) {
123
+ if (!projectDir)
124
+ return 0;
125
+ const n = Number(readHwmFile(projectDir)[sessionId]?.lastRowId);
126
+ return Number.isFinite(n) && n > 0 ? n : 0;
127
+ }
128
+ /**
129
+ * A review --out path must never be the profile or the HWM file (of this or any
130
+ * project; symlinks resolved). Returns a reason string when refused, else null.
131
+ */
132
+ export function refuseOutPath(outPath, projectDir) {
133
+ if (typeof outPath !== 'string' || !outPath.trim() || outPath.startsWith('--'))
134
+ return '--out requires a file path';
135
+ const real = (p) => {
136
+ try {
137
+ return realpathSync(p);
138
+ }
139
+ catch {
140
+ return resolve(p);
141
+ }
142
+ };
143
+ const abs = resolve(outPath);
144
+ const targets = new Set([abs, real(abs)]);
145
+ for (const name of [PROFILE_FILE, HWM_FILE]) {
146
+ if ([...targets].some(t => basename(t) === name))
147
+ return `--out must not be ${name}`;
148
+ if (projectDir) {
149
+ const p = join(projectDir, name);
150
+ if (targets.has(resolve(p)) || targets.has(real(p)))
151
+ return `--out must not be ${name}`;
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+ /** Monotonic: never moves the mark backwards. Atomic write via rename. */
157
+ export function writeHwm(projectDir, sessionId, lastRowId, lastTs) {
158
+ const all = readHwmFile(projectDir);
159
+ if ((all[sessionId]?.lastRowId ?? 0) >= lastRowId)
160
+ return;
161
+ all[sessionId] = { lastRowId, lastTs, updatedAt: new Date().toISOString() };
162
+ const p = join(projectDir, HWM_FILE);
163
+ const tmp = `${p}.${process.pid}.tmp`;
164
+ writeFileSync(tmp, JSON.stringify(all, null, 2), { mode: 0o600 });
165
+ renameSync(tmp, p);
166
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * lens-period-block.ts — the PERIOD MAP's input: ONE raw, sequential block of
3
+ * conversation (no sweep of small windows, no summaries). Pure + deterministic.
4
+ *
5
+ * Default block = everything since the most recent compaction boundary of the
6
+ * session (the compaction trigger; boundaries from lens-period-boundaries'
7
+ * readCompactionBoundaries — the boundary row itself is kept, minus its stripped
8
+ * <session_tail> replay, because the user's words that followed the compaction
9
+ * live in it). Alternatively a time range (--since / --until).
10
+ *
11
+ * Cap = maxTokens (callers pass 25% of the model's live context length). When the
12
+ * block is larger, the MOST RECENT rows that fit are kept and the left-out
13
+ * stretch is reported.
14
+ *
15
+ * Records arrive already stripped of injected context (lens-strip.ts),
16
+ * sanitized and secret + client redacted (lens-db.ts loadConversationRows).
17
+ */
18
+ import { type DbRecord } from './lens-db.js';
19
+ export interface BlockOptions {
20
+ /** content.ids of compaction seams, ascending. */
21
+ boundaries: number[];
22
+ /** Explicit start row (inclusive); overrides the boundary default. */
23
+ fromRowId?: number;
24
+ /** Explicit end row (inclusive); only applies together with fromRowId. */
25
+ toRowId?: number;
26
+ /** Time range (ms epoch); when set, overrides the boundary default. */
27
+ sinceMs?: number;
28
+ untilMs?: number;
29
+ maxTokens: number;
30
+ }
31
+ export interface PeriodBlock {
32
+ records: DbRecord[];
33
+ text: string;
34
+ chars: number;
35
+ estTokens: number;
36
+ firstRowId: number;
37
+ lastRowId: number;
38
+ from: string;
39
+ to: string;
40
+ /** How the start was chosen. */
41
+ basis: string;
42
+ /** Rows in the selected period that did not fit the cap (oldest end), or null. */
43
+ leftOut: {
44
+ rows: number;
45
+ estTokens: number;
46
+ firstRowId: number;
47
+ lastRowId: number;
48
+ from: string;
49
+ to: string;
50
+ } | null;
51
+ /** Rows in the period before capping. */
52
+ periodRows: number;
53
+ }
54
+ /**
55
+ * Start row for the default block: the most recent compaction seam at or before
56
+ * the last loaded record (seams past the end of `records` are ignored), or null
57
+ * when there is none. The seam is returned however few rows follow it — there is
58
+ * no minimum-rows fallback to an earlier seam.
59
+ */
60
+ export declare function lastBoundaryStart(records: DbRecord[], boundaries: number[]): number | null;
61
+ /** "24h", "90m", "2d", or an ISO timestamp → ms epoch (relative to now). NaN when unparseable. */
62
+ export declare function parseSince(v: string, now?: number): number;
63
+ export declare function selectBlock(all: DbRecord[], o: BlockOptions): PeriodBlock | null;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * lens-period-block.ts — the PERIOD MAP's input: ONE raw, sequential block of
3
+ * conversation (no sweep of small windows, no summaries). Pure + deterministic.
4
+ *
5
+ * Default block = everything since the most recent compaction boundary of the
6
+ * session (the compaction trigger; boundaries from lens-period-boundaries'
7
+ * readCompactionBoundaries — the boundary row itself is kept, minus its stripped
8
+ * <session_tail> replay, because the user's words that followed the compaction
9
+ * live in it). Alternatively a time range (--since / --until).
10
+ *
11
+ * Cap = maxTokens (callers pass 25% of the model's live context length). When the
12
+ * block is larger, the MOST RECENT rows that fit are kept and the left-out
13
+ * stretch is reported.
14
+ *
15
+ * Records arrive already stripped of injected context (lens-strip.ts),
16
+ * sanitized and secret + client redacted (lens-db.ts loadConversationRows).
17
+ */
18
+ import { estimateTokens, recordView } from './lens-db.js';
19
+ /**
20
+ * Start row for the default block: the most recent compaction seam at or before
21
+ * the last loaded record (seams past the end of `records` are ignored), or null
22
+ * when there is none. The seam is returned however few rows follow it — there is
23
+ * no minimum-rows fallback to an earlier seam.
24
+ */
25
+ export function lastBoundaryStart(records, boundaries) {
26
+ const lastId = records.length ? records[records.length - 1].id : 0;
27
+ const seams = boundaries.filter(b => b <= lastId).sort((a, b) => a - b);
28
+ return seams.length ? seams[seams.length - 1] : null;
29
+ }
30
+ /** "24h", "90m", "2d", or an ISO timestamp → ms epoch (relative to now). NaN when unparseable. */
31
+ export function parseSince(v, now = Date.now()) {
32
+ const m = v.trim().match(/^(\d+(?:\.\d+)?)\s*(m|h|d)$/i);
33
+ if (m)
34
+ return now - Number(m[1]) * { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase()];
35
+ return Date.parse(v);
36
+ }
37
+ export function selectBlock(all, o) {
38
+ let period;
39
+ let basis;
40
+ if (o.sinceMs !== undefined || o.untilMs !== undefined) {
41
+ const lo = o.sinceMs ?? -Infinity;
42
+ const hi = o.untilMs ?? Infinity;
43
+ period = all.filter(r => {
44
+ const t = Date.parse(r.timestamp);
45
+ return t >= lo && t <= hi;
46
+ });
47
+ basis = `time range ${Number.isFinite(lo) ? new Date(lo).toISOString() : 'start'} → ${Number.isFinite(hi) ? new Date(hi).toISOString() : 'now'}`;
48
+ }
49
+ else if (o.fromRowId !== undefined) {
50
+ const to = o.toRowId ?? Infinity;
51
+ period = all.filter(r => r.id >= o.fromRowId && r.id <= to);
52
+ basis = Number.isFinite(to) ? `rows #${o.fromRowId}–#${to}` : `from row #${o.fromRowId}`;
53
+ }
54
+ else {
55
+ const start = lastBoundaryStart(all, o.boundaries);
56
+ period = start === null ? all : all.filter(r => r.id >= start);
57
+ basis = start === null ? 'whole session (no compaction boundary found)' : `since the last compaction boundary (row #${start})`;
58
+ }
59
+ if (!period.length)
60
+ return null;
61
+ const views = period.map(recordView);
62
+ const maxChars = o.maxTokens * 4;
63
+ // Walk back from the newest row; keep what fits.
64
+ let used = 0;
65
+ let firstKept = period.length;
66
+ for (let i = period.length - 1; i >= 0; i--) {
67
+ const c = views[i].length + 2;
68
+ if (used + c > maxChars && firstKept < period.length)
69
+ break;
70
+ used += c;
71
+ firstKept = i;
72
+ }
73
+ const kept = period.slice(firstKept);
74
+ const dropped = period.slice(0, firstKept);
75
+ const text = views.slice(firstKept).join('\n\n');
76
+ const droppedChars = views.slice(0, firstKept).reduce((s, v) => s + v.length + 2, 0);
77
+ return {
78
+ records: kept,
79
+ text,
80
+ chars: text.length,
81
+ estTokens: estimateTokens(text.length),
82
+ firstRowId: kept[0].id,
83
+ lastRowId: kept[kept.length - 1].id,
84
+ from: kept[0].timestamp,
85
+ to: kept[kept.length - 1].timestamp,
86
+ basis,
87
+ periodRows: period.length,
88
+ leftOut: dropped.length
89
+ ? {
90
+ rows: dropped.length,
91
+ estTokens: estimateTokens(droppedChars),
92
+ firstRowId: dropped[0].id,
93
+ lastRowId: dropped[dropped.length - 1].id,
94
+ from: dropped[0].timestamp,
95
+ to: dropped[dropped.length - 1].timestamp,
96
+ }
97
+ : null,
98
+ };
99
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * lens-period-boundaries.ts — compaction seams in the raw session.db store
3
+ * (read-only). Owned by the period map; the clipper re-exports it.
4
+ *
5
+ * A seam is a main-session user row that STARTS with compaction evidence: a
6
+ * <session_tail …> replay block, "Conversation compacted", or the
7
+ * "(This session is being) continued from a previous conversation" lead.
8
+ * A mention of those phrases later in a genuine utterance is not a seam.
9
+ */
10
+ /** True when a user row's text begins with compaction evidence. Pure. */
11
+ export declare function isCompactionBoundaryHead(text: string): boolean;
12
+ /** content.ids of compaction seams, ascending. */
13
+ export declare function readCompactionBoundaries(dbPath: string): number[];
@@ -0,0 +1,40 @@
1
+ /**
2
+ * lens-period-boundaries.ts — compaction seams in the raw session.db store
3
+ * (read-only). Owned by the period map; the clipper re-exports it.
4
+ *
5
+ * A seam is a main-session user row that STARTS with compaction evidence: a
6
+ * <session_tail …> replay block, "Conversation compacted", or the
7
+ * "(This session is being) continued from a previous conversation" lead.
8
+ * A mention of those phrases later in a genuine utterance is not a seam.
9
+ */
10
+ import { brotliDecompressSync } from 'node:zlib';
11
+ import Database from 'better-sqlite3';
12
+ const BOUNDARY_HEAD_RE = /^\s*(?:\[time:[^\]]*\]\s*)?(?:<session_tail\b[^>]*>|Conversation compacted\b|(?:This session is being )?continued from a previous conversation)/i;
13
+ /** True when a user row's text begins with compaction evidence. Pure. */
14
+ export function isCompactionBoundaryHead(text) {
15
+ return BOUNDARY_HEAD_RE.test(text ?? '');
16
+ }
17
+ /** content.ids of compaction seams, ascending. */
18
+ export function readCompactionBoundaries(dbPath) {
19
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
20
+ try {
21
+ db.pragma('busy_timeout = 5000');
22
+ const out = [];
23
+ const stmt = db.prepare(`SELECT id, blob FROM content WHERE msg_type = 'user' AND source = 'main' ORDER BY id`);
24
+ for (const r of stmt.iterate()) {
25
+ let head = '';
26
+ try {
27
+ head = brotliDecompressSync(r.blob).toString('utf-8').slice(0, 400);
28
+ }
29
+ catch {
30
+ continue;
31
+ }
32
+ if (isCompactionBoundaryHead(head))
33
+ out.push(r.id);
34
+ }
35
+ return out;
36
+ }
37
+ finally {
38
+ db.close();
39
+ }
40
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * lens-period-render.ts — Markdown review output for a period-map run. Pure.
3
+ * high_leverage is rendered FIRST (strongest content candidates). The caller
4
+ * redacts the final string again (secrets + client) before writing it.
5
+ */
6
+ import type { PeriodBlock } from './lens-period-block.js';
7
+ import type { PeriodMap } from './lens-period.js';
8
+ export interface PeriodRunReport {
9
+ sessionId: string;
10
+ model: string;
11
+ contextLength: number;
12
+ capTokens: number;
13
+ block: Omit<PeriodBlock, 'records' | 'text'> | null;
14
+ stripped: Record<string, number>;
15
+ rowsRead: number;
16
+ rowsKeptInSession: number;
17
+ map: PeriodMap | null;
18
+ calls: number;
19
+ promptTokens: number;
20
+ completionTokens: number;
21
+ costUsd: number;
22
+ capUsd: number;
23
+ finish: string;
24
+ provider: string;
25
+ audio: string;
26
+ errors: string[];
27
+ }
28
+ export declare function renderPeriodMd(r: PeriodRunReport): string;