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.
- package/dist/claude-llm.js +13 -0
- package/dist/content/compaction-lens-worker.d.ts +23 -0
- package/dist/content/compaction-lens-worker.js +188 -0
- package/dist/content/compaction-lens.d.ts +106 -0
- package/dist/content/compaction-lens.js +222 -0
- package/dist/content/lens-audio.d.ts +38 -0
- package/dist/content/lens-audio.js +105 -0
- package/dist/content/lens-clip-arc.d.ts +80 -0
- package/dist/content/lens-clip-arc.js +148 -0
- package/dist/content/lens-clip-formats.d.ts +58 -0
- package/dist/content/lens-clip-formats.js +175 -0
- package/dist/content/lens-clip-search.d.ts +51 -0
- package/dist/content/lens-clip-search.js +138 -0
- package/dist/content/lens-clip-window.d.ts +72 -0
- package/dist/content/lens-clip-window.js +257 -0
- package/dist/content/lens-db.d.ts +70 -0
- package/dist/content/lens-db.js +170 -0
- package/dist/content/lens-hn.d.ts +19 -0
- package/dist/content/lens-hn.js +69 -0
- package/dist/content/lens-ingest.d.ts +67 -0
- package/dist/content/lens-ingest.js +155 -0
- package/dist/content/lens-launch.d.ts +46 -0
- package/dist/content/lens-launch.js +111 -0
- package/dist/content/lens-library-index.d.ts +30 -0
- package/dist/content/lens-library-index.js +79 -0
- package/dist/content/lens-library-select.d.ts +59 -0
- package/dist/content/lens-library-select.js +77 -0
- package/dist/content/lens-library.d.ts +92 -0
- package/dist/content/lens-library.js +179 -0
- package/dist/content/lens-model.d.ts +64 -0
- package/dist/content/lens-model.js +193 -0
- package/dist/content/lens-paths.d.ts +43 -0
- package/dist/content/lens-paths.js +166 -0
- package/dist/content/lens-period-block.d.ts +63 -0
- package/dist/content/lens-period-block.js +99 -0
- package/dist/content/lens-period-boundaries.d.ts +13 -0
- package/dist/content/lens-period-boundaries.js +40 -0
- package/dist/content/lens-period-render.d.ts +28 -0
- package/dist/content/lens-period-render.js +70 -0
- package/dist/content/lens-period.d.ts +106 -0
- package/dist/content/lens-period.js +205 -0
- package/dist/content/lens-profile.d.ts +47 -0
- package/dist/content/lens-profile.js +95 -0
- package/dist/content/lens-quotes.d.ts +103 -0
- package/dist/content/lens-quotes.js +186 -0
- package/dist/content/lens-redact.d.ts +61 -0
- package/dist/content/lens-redact.js +187 -0
- package/dist/content/lens-strip.d.ts +33 -0
- package/dist/content/lens-strip.js +132 -0
- package/dist/content/period-run.d.ts +30 -0
- package/dist/content/period-run.js +135 -0
- package/dist/content/render-ws-data.d.ts +8 -0
- package/dist/content/render-ws-data.js +108 -0
- package/dist/content/render-ws-screens.d.ts +25 -0
- package/dist/content/render-ws-screens.js +189 -0
- package/dist/content/render-ws-story.d.ts +1 -0
- package/dist/content/render-ws-story.js +233 -0
- package/dist/content/transcript-sanitizer.d.ts +195 -0
- package/dist/content/transcript-sanitizer.js +515 -0
- package/dist/index.js +34 -0
- package/package.json +1 -1
- package/test-results/.last-run.json +0 -4
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lens-library-index.ts — rebuild a session library's INDEX.md from the period
|
|
3
|
+
* pages on disk. The pages are the source of truth (renderPeriodMd format), so
|
|
4
|
+
* pages written by the manual backfill are listed alongside auto-written ones,
|
|
5
|
+
* and a rebuild is idempotent: same pages in → same INDEX out.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
export const INDEX_FILE = 'INDEX.md';
|
|
10
|
+
export const PAGE_RE = /^(\d{4}-\d{2}-\d{2}|undated)-period-(\d+)\.md$/;
|
|
11
|
+
function section(md, heading) {
|
|
12
|
+
const at = md.indexOf(`\n## ${heading}\n`);
|
|
13
|
+
if (at < 0)
|
|
14
|
+
return '';
|
|
15
|
+
const rest = md.slice(at + heading.length + 5);
|
|
16
|
+
const end = rest.search(/\n## /);
|
|
17
|
+
return end < 0 ? rest : rest.slice(0, end);
|
|
18
|
+
}
|
|
19
|
+
/** Parse one period page (renderPeriodMd output). Pure. */
|
|
20
|
+
export function parsePage(file, md) {
|
|
21
|
+
const m = file.match(PAGE_RE);
|
|
22
|
+
const rows = md.match(/^- Rows in block: #(\d+)–#(\d+)/m);
|
|
23
|
+
const time = md.match(/^- Time: (\S+) → (\S+)/m);
|
|
24
|
+
const goalLines = section(md, 'Period goal').split('\n').map(s => s.trim()).filter(s => s && !s.startsWith('- >'));
|
|
25
|
+
const goal = goalLines[0] && goalLines[0] !== '- (none verified)' ? goalLines[0] : '(no verified goal)';
|
|
26
|
+
const top = section(md, 'High-leverage work (ranked)').match(/^1\. \*\*(.+?)\*\* \[([^\]]+)\]/m);
|
|
27
|
+
const stories = [];
|
|
28
|
+
for (const block of section(md, 'Stories').split(/(?:^|\n)### /).slice(1)) {
|
|
29
|
+
const title = block.split('\n')[0].trim();
|
|
30
|
+
const r = block.match(/^Rows: (#\d+–#\d+)/m);
|
|
31
|
+
const a = block.match(/^\s*- > "(.+)" — user, row #(\d+)/m);
|
|
32
|
+
stories.push({ title, rows: r?.[1] ?? '', anchor: a ? `“${a[1]}” (row #${a[2]})` : null });
|
|
33
|
+
}
|
|
34
|
+
const cost = md.match(/^- Calls: .*?\$(\d+(?:\.\d+)?) \(cap/m);
|
|
35
|
+
return {
|
|
36
|
+
file, index: m ? Number(m[2]) : 0, rows: rows ? `#${rows[1]}–#${rows[2]}` : '-',
|
|
37
|
+
from: time?.[1] ?? '', to: time?.[2] ?? '', goal,
|
|
38
|
+
topItem: top ? `${top[1]} [${top[2]}]` : null, stories, costUsd: cost ? Number(cost[1]) : 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const cell = (s) => s.replace(/\|/g, '\\|').replace(/\n+/g, ' ');
|
|
42
|
+
const when = (iso) => (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(0, 16).replace('T', ' ') : iso || '?');
|
|
43
|
+
/** Render INDEX.md. Pure. */
|
|
44
|
+
export function renderIndex(sessionLabel, pages) {
|
|
45
|
+
const ps = [...pages].sort((a, b) => a.index - b.index || a.file.localeCompare(b.file));
|
|
46
|
+
const L = [`# Content library — session ${sessionLabel}`, ''];
|
|
47
|
+
L.push('Period pages from the period map (review mode). New pages are written automatically by the content-lens worker on each ' +
|
|
48
|
+
'compaction, one per period, keyed on the period\'s opening boundary row. content-profile.md and the HWM are not touched.', '');
|
|
49
|
+
L.push('## Periods (chronological)', '', '| # | Date range (UTC) | Rows | Period goal | Top high-leverage item | File |', '|---|---|---|---|---|---|');
|
|
50
|
+
for (const p of ps) {
|
|
51
|
+
L.push(`| ${String(p.index).padStart(2, '0')} | ${when(p.from)} → ${when(p.to)} | ${p.rows} | ${cell(p.goal)} | ${cell(p.topItem ?? '(none verified)')} | [${p.file}](${p.file}) |`);
|
|
52
|
+
}
|
|
53
|
+
L.push('', '## Stories', '', '| Date | Story | User anchor line (verbatim) | Rows | File |', '|---|---|---|---|---|');
|
|
54
|
+
for (const p of ps) {
|
|
55
|
+
for (const s of p.stories)
|
|
56
|
+
L.push(`| ${p.from.slice(0, 10) || '?'} | ${cell(s.title)} | ${cell(s.anchor ?? '-')} | ${s.rows || '-'} | [${p.file}](${p.file}) |`);
|
|
57
|
+
}
|
|
58
|
+
const tops = ps.filter(p => p.topItem);
|
|
59
|
+
L.push('', '## Top high-leverage item per period', '');
|
|
60
|
+
if (!tops.length)
|
|
61
|
+
L.push('- (none verified)');
|
|
62
|
+
for (const p of tops)
|
|
63
|
+
L.push(`- ${String(p.index).padStart(2, '0')}: ${p.topItem} — [${p.file}](${p.file})`);
|
|
64
|
+
const stories = ps.reduce((n, p) => n + p.stories.length, 0);
|
|
65
|
+
const cost = ps.reduce((n, p) => n + p.costUsd, 0);
|
|
66
|
+
L.push('', '## Totals', '', `- Periods: ${ps.length}`, `- Stories: ${stories}`, `- Model spend across pages: $${cost.toFixed(4)}`);
|
|
67
|
+
return L.join('\n') + '\n';
|
|
68
|
+
}
|
|
69
|
+
/** Rebuild <dir>/INDEX.md from every period page in <dir>. `scrub` = final redaction pass. Returns the path. */
|
|
70
|
+
export function rebuildIndex(dir, sessionLabel, scrub = s => s) {
|
|
71
|
+
const pages = readdirSync(dir)
|
|
72
|
+
.filter(f => PAGE_RE.test(f))
|
|
73
|
+
.map(f => parsePage(f, readFileSync(join(dir, f), 'utf-8')));
|
|
74
|
+
const path = join(dir, INDEX_FILE);
|
|
75
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
76
|
+
writeFileSync(tmp, scrub(renderIndex(sessionLabel, pages)), 'utf-8');
|
|
77
|
+
renameSync(tmp, path);
|
|
78
|
+
return path;
|
|
79
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lens-library-select.ts — which PERIOD a compaction just ended. Pure + deterministic.
|
|
3
|
+
*
|
|
4
|
+
* A period runs from one compaction seam (lens-period-boundaries) to the row
|
|
5
|
+
* before the next seam. Seams come in clusters (several re-compactions within
|
|
6
|
+
* minutes), so short periods are merged FORWARD: walking the seams in order, a
|
|
7
|
+
* group only closes at a seam once it spans >= minSpanMs of conversation. The
|
|
8
|
+
* walk depends only on earlier rows, so a later seam never re-numbers an earlier
|
|
9
|
+
* group. That makes the group's OPENING boundary row (startRowId) a stable key:
|
|
10
|
+
* the same period always gets the same key, index and file name.
|
|
11
|
+
*
|
|
12
|
+
* Timing: the worker runs at PostCompact, usually BEFORE the recall store has
|
|
13
|
+
* ingested the seam this compaction will produce. So the db's TAIL (last seam →
|
|
14
|
+
* last row) is the period that just ended; it is treated as closed by "this
|
|
15
|
+
* compaction" when it spans >= minSpanMs, else it is deferred (merged into the
|
|
16
|
+
* next one). If the new seam is already ingested, the tail is tiny and the last
|
|
17
|
+
* seam-closed group is the one that just ended.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_MIN_SPAN_MS: number;
|
|
20
|
+
export interface RowStamp {
|
|
21
|
+
id: number;
|
|
22
|
+
timestamp: string;
|
|
23
|
+
}
|
|
24
|
+
export interface LibraryPeriod {
|
|
25
|
+
/** 1-based position in the session's period sequence (stable). */
|
|
26
|
+
index: number;
|
|
27
|
+
/** Opening boundary row (or the session's first row for period 1) — the idempotency key. */
|
|
28
|
+
startRowId: number;
|
|
29
|
+
/** Inclusive last row. */
|
|
30
|
+
endRowId: number;
|
|
31
|
+
/** Seam row that closed it; null = the tail, closed by the compaction that triggered this run. */
|
|
32
|
+
closedBy: number | null;
|
|
33
|
+
/** Conversation rows in the period. */
|
|
34
|
+
rows: number;
|
|
35
|
+
fromTs: string;
|
|
36
|
+
toTs: string;
|
|
37
|
+
spanMs: number;
|
|
38
|
+
}
|
|
39
|
+
export interface Segmentation {
|
|
40
|
+
closed: LibraryPeriod[];
|
|
41
|
+
tail: LibraryPeriod | null;
|
|
42
|
+
}
|
|
43
|
+
/** Split records (ascending id) into greedy forward-merged periods at the given seams. */
|
|
44
|
+
export declare function segmentPeriods(records: RowStamp[], boundaries: number[], minSpanMs?: number): Segmentation;
|
|
45
|
+
export type PickResult = {
|
|
46
|
+
period: LibraryPeriod;
|
|
47
|
+
reason: 'tail' | 'last-closed';
|
|
48
|
+
} | {
|
|
49
|
+
period: null;
|
|
50
|
+
reason: 'no-rows' | 'deferred' | 'already-written';
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The newest period not yet written, among (1) the tail when it is long enough
|
|
54
|
+
* and (2) the last seam-closed group. At most one per run; older gaps are left to
|
|
55
|
+
* a manual backfill so a compaction never triggers surprise spend.
|
|
56
|
+
*/
|
|
57
|
+
export declare function pickLibraryPeriod(seg: Segmentation, isDone: (startRowId: number) => boolean, minSpanMs?: number): PickResult;
|
|
58
|
+
/** <YYYY-MM-DD of the period start>-period-<NN>.md — same scheme as the manual backfill. */
|
|
59
|
+
export declare function pageFileName(p: Pick<LibraryPeriod, 'index' | 'fromTs'>): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lens-library-select.ts — which PERIOD a compaction just ended. Pure + deterministic.
|
|
3
|
+
*
|
|
4
|
+
* A period runs from one compaction seam (lens-period-boundaries) to the row
|
|
5
|
+
* before the next seam. Seams come in clusters (several re-compactions within
|
|
6
|
+
* minutes), so short periods are merged FORWARD: walking the seams in order, a
|
|
7
|
+
* group only closes at a seam once it spans >= minSpanMs of conversation. The
|
|
8
|
+
* walk depends only on earlier rows, so a later seam never re-numbers an earlier
|
|
9
|
+
* group. That makes the group's OPENING boundary row (startRowId) a stable key:
|
|
10
|
+
* the same period always gets the same key, index and file name.
|
|
11
|
+
*
|
|
12
|
+
* Timing: the worker runs at PostCompact, usually BEFORE the recall store has
|
|
13
|
+
* ingested the seam this compaction will produce. So the db's TAIL (last seam →
|
|
14
|
+
* last row) is the period that just ended; it is treated as closed by "this
|
|
15
|
+
* compaction" when it spans >= minSpanMs, else it is deferred (merged into the
|
|
16
|
+
* next one). If the new seam is already ingested, the tail is tiny and the last
|
|
17
|
+
* seam-closed group is the one that just ended.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_MIN_SPAN_MS = 4 * 3_600_000;
|
|
20
|
+
function span(recs) {
|
|
21
|
+
if (recs.length < 2)
|
|
22
|
+
return 0;
|
|
23
|
+
const a = Date.parse(recs[0].timestamp);
|
|
24
|
+
const b = Date.parse(recs[recs.length - 1].timestamp);
|
|
25
|
+
return Number.isFinite(a) && Number.isFinite(b) ? Math.max(0, b - a) : 0;
|
|
26
|
+
}
|
|
27
|
+
function period(index, start, end, closedBy, recs) {
|
|
28
|
+
return {
|
|
29
|
+
index, startRowId: start, endRowId: end, closedBy, rows: recs.length,
|
|
30
|
+
fromTs: recs[0]?.timestamp ?? '', toTs: recs[recs.length - 1]?.timestamp ?? '', spanMs: span(recs),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Split records (ascending id) into greedy forward-merged periods at the given seams. */
|
|
34
|
+
export function segmentPeriods(records, boundaries, minSpanMs = DEFAULT_MIN_SPAN_MS) {
|
|
35
|
+
if (!records.length)
|
|
36
|
+
return { closed: [], tail: null };
|
|
37
|
+
const firstId = records[0].id;
|
|
38
|
+
const lastId = records[records.length - 1].id;
|
|
39
|
+
const seams = [...new Set(boundaries)].filter(b => b > firstId && b <= lastId).sort((a, b) => a - b);
|
|
40
|
+
const closed = [];
|
|
41
|
+
let start = firstId;
|
|
42
|
+
let i = 0; // first record index of the current group
|
|
43
|
+
for (const s of seams) {
|
|
44
|
+
let j = i;
|
|
45
|
+
while (j < records.length && records[j].id < s)
|
|
46
|
+
j++;
|
|
47
|
+
const group = records.slice(i, j);
|
|
48
|
+
if (span(group) >= minSpanMs) {
|
|
49
|
+
closed.push(period(closed.length + 1, start, s - 1, s, group));
|
|
50
|
+
start = s;
|
|
51
|
+
i = j;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const tailRecs = records.slice(i);
|
|
55
|
+
return { closed, tail: tailRecs.length ? period(closed.length + 1, start, lastId, null, tailRecs) : null };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The newest period not yet written, among (1) the tail when it is long enough
|
|
59
|
+
* and (2) the last seam-closed group. At most one per run; older gaps are left to
|
|
60
|
+
* a manual backfill so a compaction never triggers surprise spend.
|
|
61
|
+
*/
|
|
62
|
+
export function pickLibraryPeriod(seg, isDone, minSpanMs = DEFAULT_MIN_SPAN_MS) {
|
|
63
|
+
if (!seg.tail && !seg.closed.length)
|
|
64
|
+
return { period: null, reason: 'no-rows' };
|
|
65
|
+
const tailReady = !!seg.tail && seg.tail.spanMs >= minSpanMs;
|
|
66
|
+
if (tailReady && !isDone(seg.tail.startRowId))
|
|
67
|
+
return { period: seg.tail, reason: 'tail' };
|
|
68
|
+
const last = seg.closed[seg.closed.length - 1];
|
|
69
|
+
if (last && !isDone(last.startRowId))
|
|
70
|
+
return { period: last, reason: 'last-closed' };
|
|
71
|
+
return { period: null, reason: tailReady || !seg.tail ? 'already-written' : 'deferred' };
|
|
72
|
+
}
|
|
73
|
+
/** <YYYY-MM-DD of the period start>-period-<NN>.md — same scheme as the manual backfill. */
|
|
74
|
+
export function pageFileName(p) {
|
|
75
|
+
const day = /^\d{4}-\d{2}-\d{2}/.test(p.fromTs) ? p.fromTs.slice(0, 10) : 'undated';
|
|
76
|
+
return `${day}-period-${String(p.index).padStart(2, '0')}.md`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lens-library.ts — the LIBRARY STEP of the detached content-lens worker.
|
|
3
|
+
*
|
|
4
|
+
* On each compaction: pick the period that just ended (lens-library-select),
|
|
5
|
+
* run the existing period map on exactly that row range (period-run: strip →
|
|
6
|
+
* redact → one OpenRouter call under PERIOD_CAP_USD, review mode — never the
|
|
7
|
+
* profile or the HWM), and publish ONE page per period to
|
|
8
|
+
* <project>/osb/<sessionId>/library/<sid8>/<YYYY-MM-DD>-period-<NN>.md
|
|
9
|
+
* then rebuild INDEX.md there. Same layout as the manual c97588f4 backfill.
|
|
10
|
+
*
|
|
11
|
+
* Idempotent: pages are keyed on the period's opening boundary row in
|
|
12
|
+
* .library-manifest.json; a period already written ("ok") is skipped with no
|
|
13
|
+
* model call. A failed run publishes nothing (the reply stays in .staging/) and
|
|
14
|
+
* is retried next time. Fail-open: never throws; the caller just logs the result.
|
|
15
|
+
* Opt-out: OSBORN_CONTENT_LENS=0 (same switch as the launcher).
|
|
16
|
+
*/
|
|
17
|
+
import { type LibraryPeriod } from './lens-library-select.js';
|
|
18
|
+
import { type IngestOptions, type IngestState } from './lens-ingest.js';
|
|
19
|
+
export declare const MANIFEST_FILE = ".library-manifest.json";
|
|
20
|
+
export interface ManifestEntry {
|
|
21
|
+
startRowId: number;
|
|
22
|
+
endRowId: number;
|
|
23
|
+
closedBy: number | 'compaction';
|
|
24
|
+
index: number;
|
|
25
|
+
file: string;
|
|
26
|
+
status: 'ok' | 'error' | 'dry';
|
|
27
|
+
costUsd: number;
|
|
28
|
+
errors: string[];
|
|
29
|
+
updatedAt: string;
|
|
30
|
+
/** Cloud ingest outcome (content_ingest RPC). Anything but ok/oversize is retried next run. */
|
|
31
|
+
ingest?: IngestState;
|
|
32
|
+
}
|
|
33
|
+
export interface Manifest {
|
|
34
|
+
version: 1;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
/** key = String(startRowId) */
|
|
37
|
+
periods: Record<string, ManifestEntry>;
|
|
38
|
+
}
|
|
39
|
+
export type LibraryStatus = 'disabled' | 'no-key' | 'no-db' | 'no-rows' | 'deferred' | 'already-written' | 'written' | 'dry' | 'model-error' | 'failed';
|
|
40
|
+
export interface LibraryStepResult {
|
|
41
|
+
status: LibraryStatus;
|
|
42
|
+
libraryDir: string | null;
|
|
43
|
+
period: LibraryPeriod | null;
|
|
44
|
+
page: string | null;
|
|
45
|
+
index: string | null;
|
|
46
|
+
costUsd: number;
|
|
47
|
+
errors: string[];
|
|
48
|
+
}
|
|
49
|
+
export interface LibraryStepOptions {
|
|
50
|
+
sessionId: string;
|
|
51
|
+
projectDir?: string | null;
|
|
52
|
+
cwd?: string;
|
|
53
|
+
/** No model call: block stats page only (manifest status "dry", never counts as written). */
|
|
54
|
+
dry?: boolean;
|
|
55
|
+
/** Write here instead of the session's library dir (dry runs / review). */
|
|
56
|
+
outDir?: string;
|
|
57
|
+
minSpanMs?: number;
|
|
58
|
+
/** Called every 60s while the step runs (worker refreshes its lock). */
|
|
59
|
+
keepAlive?: () => void;
|
|
60
|
+
log?: (m: string) => void;
|
|
61
|
+
}
|
|
62
|
+
/** The record handed to the ingest seam. */
|
|
63
|
+
export interface LibraryPageRecord {
|
|
64
|
+
sessionId: string;
|
|
65
|
+
key: string;
|
|
66
|
+
period: LibraryPeriod;
|
|
67
|
+
pagePath: string;
|
|
68
|
+
markdown: string;
|
|
69
|
+
costUsd: number;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* ── INGEST SEAM ──────────────────────────────────────────────────────────────
|
|
73
|
+
* The ONE place a published page goes to the cloud library: Supabase RPC
|
|
74
|
+
* content_ingest (migration 007) as a private draft, authed by OSBORN_SYNC_TOKEN
|
|
75
|
+
* (see lens-ingest.ts). Never throws; the returned state is recorded in the
|
|
76
|
+
* manifest so failures retry next run. Errors here never affect the local page.
|
|
77
|
+
* Opt-out: OSBORN_CONTENT_INGEST=0 (and OSBORN_CONTENT_LENS=0).
|
|
78
|
+
*/
|
|
79
|
+
export declare function ingestLibraryPage(page: LibraryPageRecord, o?: IngestOptions): Promise<IngestState>;
|
|
80
|
+
/**
|
|
81
|
+
* Retry pass: ingest every written ("ok") page whose ingest never succeeded (or
|
|
82
|
+
* whose content changed). No model call. Does nothing when ingest isn't
|
|
83
|
+
* configured. Returns true if the manifest changed. Never throws.
|
|
84
|
+
*/
|
|
85
|
+
export declare function ingestPendingPages(dir: string, m: Manifest, o?: IngestOptions & {
|
|
86
|
+
skipKey?: string;
|
|
87
|
+
}): Promise<boolean>;
|
|
88
|
+
export declare function libraryDirFor(dbPath: string, sessionId: string): string;
|
|
89
|
+
export declare function readManifest(dir: string, sessionId: string): Manifest;
|
|
90
|
+
export declare function writeManifest(dir: string, m: Manifest): void;
|
|
91
|
+
/** Run the library step once. Never throws. */
|
|
92
|
+
export declare function runLibraryStep(o: LibraryStepOptions): Promise<LibraryStepResult>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lens-library.ts — the LIBRARY STEP of the detached content-lens worker.
|
|
3
|
+
*
|
|
4
|
+
* On each compaction: pick the period that just ended (lens-library-select),
|
|
5
|
+
* run the existing period map on exactly that row range (period-run: strip →
|
|
6
|
+
* redact → one OpenRouter call under PERIOD_CAP_USD, review mode — never the
|
|
7
|
+
* profile or the HWM), and publish ONE page per period to
|
|
8
|
+
* <project>/osb/<sessionId>/library/<sid8>/<YYYY-MM-DD>-period-<NN>.md
|
|
9
|
+
* then rebuild INDEX.md there. Same layout as the manual c97588f4 backfill.
|
|
10
|
+
*
|
|
11
|
+
* Idempotent: pages are keyed on the period's opening boundary row in
|
|
12
|
+
* .library-manifest.json; a period already written ("ok") is skipped with no
|
|
13
|
+
* model call. A failed run publishes nothing (the reply stays in .staging/) and
|
|
14
|
+
* is retried next time. Fail-open: never throws; the caller just logs the result.
|
|
15
|
+
* Opt-out: OSBORN_CONTENT_LENS=0 (same switch as the launcher).
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { basename, dirname, join } from 'node:path';
|
|
19
|
+
import { loadConversationRows, resolveSessionDb } from './lens-db.js';
|
|
20
|
+
import { isContentLensEnabled } from './lens-launch.js';
|
|
21
|
+
import { readCompactionBoundaries } from './lens-period-boundaries.js';
|
|
22
|
+
import { loadClientRedactor } from './lens-redact.js';
|
|
23
|
+
import { redactSecrets } from './transcript-sanitizer.js';
|
|
24
|
+
import { runPeriodMap } from './period-run.js';
|
|
25
|
+
import { DEFAULT_MIN_SPAN_MS, pageFileName, pickLibraryPeriod, segmentPeriods } from './lens-library-select.js';
|
|
26
|
+
import { rebuildIndex } from './lens-library-index.js';
|
|
27
|
+
import { contentHashFor, ingestPage, needsIngest, resolveIngestConfig } from './lens-ingest.js';
|
|
28
|
+
export const MANIFEST_FILE = '.library-manifest.json';
|
|
29
|
+
const STAGING_DIR = '.staging';
|
|
30
|
+
/**
|
|
31
|
+
* ── INGEST SEAM ──────────────────────────────────────────────────────────────
|
|
32
|
+
* The ONE place a published page goes to the cloud library: Supabase RPC
|
|
33
|
+
* content_ingest (migration 007) as a private draft, authed by OSBORN_SYNC_TOKEN
|
|
34
|
+
* (see lens-ingest.ts). Never throws; the returned state is recorded in the
|
|
35
|
+
* manifest so failures retry next run. Errors here never affect the local page.
|
|
36
|
+
* Opt-out: OSBORN_CONTENT_INGEST=0 (and OSBORN_CONTENT_LENS=0).
|
|
37
|
+
*/
|
|
38
|
+
export async function ingestLibraryPage(page, o = {}) {
|
|
39
|
+
const p = page.period;
|
|
40
|
+
return ingestPage({
|
|
41
|
+
sessionId: page.sessionId, startRowId: p.startRowId, endRowId: p.endRowId, index: p.index,
|
|
42
|
+
closedBy: p.closedBy ?? 'compaction', file: basename(page.pagePath), markdown: page.markdown,
|
|
43
|
+
}, o);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Retry pass: ingest every written ("ok") page whose ingest never succeeded (or
|
|
47
|
+
* whose content changed). No model call. Does nothing when ingest isn't
|
|
48
|
+
* configured. Returns true if the manifest changed. Never throws.
|
|
49
|
+
*/
|
|
50
|
+
export async function ingestPendingPages(dir, m, o = {}) {
|
|
51
|
+
if (!resolveIngestConfig().ok)
|
|
52
|
+
return false;
|
|
53
|
+
let changed = false;
|
|
54
|
+
for (const [key, e] of Object.entries(m.periods)) {
|
|
55
|
+
if (e.status !== 'ok' || key === o.skipKey)
|
|
56
|
+
continue;
|
|
57
|
+
try {
|
|
58
|
+
const path = join(dir, e.file);
|
|
59
|
+
if (!existsSync(path))
|
|
60
|
+
continue;
|
|
61
|
+
const markdown = readFileSync(path, 'utf-8');
|
|
62
|
+
if (!needsIngest(e.ingest, contentHashFor({ sessionId: m.sessionId, startRowId: e.startRowId, markdown })))
|
|
63
|
+
continue;
|
|
64
|
+
e.ingest = await ingestPage({ sessionId: m.sessionId, startRowId: e.startRowId, endRowId: e.endRowId, index: e.index, closedBy: e.closedBy, file: e.file, markdown }, o);
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* fail-open: try again next run */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return changed;
|
|
72
|
+
}
|
|
73
|
+
export function libraryDirFor(dbPath, sessionId) {
|
|
74
|
+
return join(dirname(dbPath), 'library', sessionId.slice(0, 8));
|
|
75
|
+
}
|
|
76
|
+
export function readManifest(dir, sessionId) {
|
|
77
|
+
try {
|
|
78
|
+
const o = JSON.parse(readFileSync(join(dir, MANIFEST_FILE), 'utf-8'));
|
|
79
|
+
if (o && typeof o.periods === 'object' && !Array.isArray(o.periods))
|
|
80
|
+
return { version: 1, sessionId, periods: o.periods };
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* missing / unreadable → empty */
|
|
84
|
+
}
|
|
85
|
+
return { version: 1, sessionId, periods: {} };
|
|
86
|
+
}
|
|
87
|
+
function writeAtomic(path, text) {
|
|
88
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
89
|
+
writeFileSync(tmp, text, 'utf-8');
|
|
90
|
+
renameSync(tmp, path);
|
|
91
|
+
}
|
|
92
|
+
export function writeManifest(dir, m) {
|
|
93
|
+
writeAtomic(join(dir, MANIFEST_FILE), JSON.stringify(m, null, 2) + '\n');
|
|
94
|
+
}
|
|
95
|
+
const envNum = (k) => {
|
|
96
|
+
const n = Number(process.env[k]);
|
|
97
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
98
|
+
};
|
|
99
|
+
/** Run the library step once. Never throws. */
|
|
100
|
+
export async function runLibraryStep(o) {
|
|
101
|
+
const log = o.log ?? (() => { });
|
|
102
|
+
const res = { status: 'failed', libraryDir: null, period: null, page: null, index: null, costUsd: 0, errors: [] };
|
|
103
|
+
const timer = o.keepAlive ? setInterval(o.keepAlive, 60_000) : null;
|
|
104
|
+
timer?.unref();
|
|
105
|
+
try {
|
|
106
|
+
if (!isContentLensEnabled())
|
|
107
|
+
return { ...res, status: 'disabled' };
|
|
108
|
+
if (!o.dry && !(process.env.OPENROUTER_API_KEY || '').trim())
|
|
109
|
+
return { ...res, status: 'no-key' };
|
|
110
|
+
const db = resolveSessionDb(o.sessionId, o.projectDir, o.cwd);
|
|
111
|
+
if (!db)
|
|
112
|
+
return { ...res, status: 'no-db' };
|
|
113
|
+
const projectDir = dirname(dirname(dirname(db)));
|
|
114
|
+
const envH = envNum('OSBORN_LIBRARY_MIN_PERIOD_H');
|
|
115
|
+
const minSpan = o.minSpanMs ?? (envH ? envH * 3_600_000 : DEFAULT_MIN_SPAN_MS);
|
|
116
|
+
const dir = (res.libraryDir = o.outDir ?? libraryDirFor(db, o.sessionId));
|
|
117
|
+
// Segment the whole session at its compaction seams (ids + timestamps only).
|
|
118
|
+
const rows = loadConversationRows(db, 0).records.map(r => ({ id: r.id, timestamp: r.timestamp }));
|
|
119
|
+
const seg = segmentPeriods(rows, readCompactionBoundaries(db), minSpan);
|
|
120
|
+
const manifest = readManifest(dir, o.sessionId);
|
|
121
|
+
const pick = pickLibraryPeriod(seg, start => manifest.periods[String(start)]?.status === 'ok', minSpan);
|
|
122
|
+
log(`library: ${seg.closed.length} closed period(s), tail ${seg.tail ? `#${seg.tail.startRowId}–#${seg.tail.endRowId} (${(seg.tail.spanMs / 3_600_000).toFixed(1)}h)` : 'none'} → ${pick.reason}`);
|
|
123
|
+
const red = loadClientRedactor(projectDir);
|
|
124
|
+
const scrub = (s) => redactSecrets(red.redact(s), { assistant: true });
|
|
125
|
+
if (pick.reason !== 'tail' && pick.reason !== 'last-closed') {
|
|
126
|
+
// Nothing new to write; still retry pages whose cloud ingest never landed (no model call).
|
|
127
|
+
if (!o.dry && !o.outDir && (await ingestPendingPages(dir, manifest, { scrub, log })))
|
|
128
|
+
writeManifest(dir, manifest);
|
|
129
|
+
return { ...res, status: pick.reason };
|
|
130
|
+
}
|
|
131
|
+
const p = (res.period = pick.period);
|
|
132
|
+
const key = String(p.startRowId);
|
|
133
|
+
const file = pageFileName(p);
|
|
134
|
+
// Period map on exactly this row range, staged first; published only when a map came back.
|
|
135
|
+
mkdirSync(join(dir, STAGING_DIR), { recursive: true });
|
|
136
|
+
const staged = join(dir, STAGING_DIR, file);
|
|
137
|
+
const r = await runPeriodMap({ sessionId: o.sessionId, outPath: staged, fromRowId: p.startRowId, toRowId: p.endRowId, dry: o.dry, log });
|
|
138
|
+
res.costUsd = r.costUsd;
|
|
139
|
+
res.errors = [...r.errors];
|
|
140
|
+
const ok = !!r.map || !!o.dry;
|
|
141
|
+
const entry = {
|
|
142
|
+
startRowId: p.startRowId, endRowId: p.endRowId, closedBy: p.closedBy ?? 'compaction', index: p.index, file,
|
|
143
|
+
status: o.dry ? 'dry' : r.map ? 'ok' : 'error', costUsd: r.costUsd, errors: r.errors, updatedAt: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
if (ok) {
|
|
146
|
+
const marker = `<!-- osborn-library: key=${key} rows=${p.startRowId}-${p.endRowId} closed-by=${entry.closedBy} period=${p.index}${o.dry ? ' dry' : ''} -->\n`;
|
|
147
|
+
const markdown = marker + readFileSync(staged, 'utf-8');
|
|
148
|
+
res.page = join(dir, file);
|
|
149
|
+
writeAtomic(res.page, markdown);
|
|
150
|
+
rmSync(staged, { force: true });
|
|
151
|
+
res.index = rebuildIndex(dir, o.sessionId.slice(0, 8), scrub);
|
|
152
|
+
if (!o.dry) {
|
|
153
|
+
try {
|
|
154
|
+
const st = await ingestLibraryPage({ sessionId: o.sessionId, key, period: p, pagePath: res.page, markdown, costUsd: r.costUsd }, { scrub, log });
|
|
155
|
+
entry.ingest = st;
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
log(`library: ingest seam failed (page kept): ${e?.message ?? e}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
manifest.periods[key] = entry;
|
|
163
|
+
if (!o.dry && !o.outDir)
|
|
164
|
+
await ingestPendingPages(dir, manifest, { scrub, log, skipKey: key });
|
|
165
|
+
writeManifest(dir, manifest);
|
|
166
|
+
res.status = o.dry ? 'dry' : r.map ? 'written' : 'model-error';
|
|
167
|
+
log(`library: period ${p.index} rows #${p.startRowId}–#${p.endRowId} → ${res.status}${res.page ? ` ${res.page}` : ''} $${r.costUsd.toFixed(4)}${r.errors.length ? ` errors=${r.errors.join('; ')}` : ''}`);
|
|
168
|
+
return res;
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
res.errors.push(e?.message ?? String(e));
|
|
172
|
+
log(`library: failed (fail-open): ${e?.message ?? e}`);
|
|
173
|
+
return { ...res, status: 'failed' };
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
if (timer)
|
|
177
|
+
clearInterval(timer);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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
|
+
export declare const DEFAULT_MODEL = "minimax/minimax-m3";
|
|
17
|
+
export declare const WINDOW_FRACTION = 0.25;
|
|
18
|
+
export declare const lensModel: () => string;
|
|
19
|
+
export interface ModelInfo {
|
|
20
|
+
id: string;
|
|
21
|
+
contextLength: number;
|
|
22
|
+
promptPerTok: number;
|
|
23
|
+
completionPerTok: number;
|
|
24
|
+
/** true = read from the OpenRouter models API this run; false = built-in fallback. */
|
|
25
|
+
verified: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Live context length + price. Null for an unknown overridden model (can't enforce the cost cap). */
|
|
28
|
+
export declare function getModelInfo(apiKey: string, id?: string): Promise<ModelInfo | null>;
|
|
29
|
+
export declare const windowTokensFor: (info: ModelInfo) => number;
|
|
30
|
+
export declare class Budget {
|
|
31
|
+
readonly info: ModelInfo;
|
|
32
|
+
calls: number;
|
|
33
|
+
failures: number;
|
|
34
|
+
spentUsd: number;
|
|
35
|
+
refused: number;
|
|
36
|
+
readonly maxCalls: number;
|
|
37
|
+
readonly maxCostUsd: number;
|
|
38
|
+
/** caps override the env/default caps (e.g. the period map's own $0.50 hard cap). */
|
|
39
|
+
constructor(info: ModelInfo, caps?: {
|
|
40
|
+
maxCalls?: number;
|
|
41
|
+
maxCostUsd?: number;
|
|
42
|
+
});
|
|
43
|
+
estimate(promptTokens: number, maxOut: number): number;
|
|
44
|
+
/** Reserve one call if both caps allow its worst case. */
|
|
45
|
+
take(promptTokens: number, maxOut: number): boolean;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Transcript FIRST, instructions AFTER: measured on minimax-m3 with a ~257k-token
|
|
49
|
+
* window, instructions-before-transcript returned {"angles":[],"capabilities":[]}
|
|
50
|
+
* (the task gets lost in a long context); the query belongs at the end.
|
|
51
|
+
*/
|
|
52
|
+
export declare function mapPrompt(window: string, part: number, total: number, maxA: number, maxC: number): string;
|
|
53
|
+
export declare function reducePrompt(candidatesJson: string, maxA: number, maxC: number): string;
|
|
54
|
+
export interface ChatResult {
|
|
55
|
+
content: string;
|
|
56
|
+
/** 'stop' normally; 'length' = output cap hit (JSON likely truncated). */
|
|
57
|
+
finish: string;
|
|
58
|
+
provider: string;
|
|
59
|
+
promptTokens: number;
|
|
60
|
+
completionTokens: number;
|
|
61
|
+
costUsd: number;
|
|
62
|
+
}
|
|
63
|
+
/** One chat call. Returns null when the budget refuses; throws on HTTP/timeout errors. */
|
|
64
|
+
export declare function chat(budget: Budget, user: string, apiKey: string, maxOut?: number): Promise<ChatResult | null>;
|