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,170 @@
1
+ /**
2
+ * lens-db.ts — the content lens's SOURCE: the per-session recall store
3
+ * (`session.db`, the same SQLite file `osborn-recall` queries), keyed by session ID.
4
+ *
5
+ * <claudeDir>/projects/<slug>/osb/<sessionId>/session.db
6
+ * content(id INTEGER PK, source TEXT, line_num, byte_offset, ts TEXT,
7
+ * msg_type TEXT, model, git_branch, cwd, tool_name, blob BLOB)
8
+ * blob = brotli-compressed full text (session-store.ts compress()).
9
+ * id is append-only and chronological; source = 'main' | 'agent-<id8>'.
10
+ *
11
+ * CONVERSATION ONLY: msg_type IN ('user','assistant'); thinking / tool_use /
12
+ * tool_result rows are never read. Default source is the main thread only
13
+ * (sub-agent rows are the orchestrator's prompts + agent chatter, not the
14
+ * conversation); OSBORN_LENS_INCLUDE_SUBAGENTS=1 widens it.
15
+ *
16
+ * Every pulled row goes through the same sanitizer as before (drops harness
17
+ * wrappers like <session_tail>/[EMERGENCY STOP], unwraps [INTERRUPTED]/[CONTEXT]
18
+ * to the user's actual words) and then a second redaction pass in assistant
19
+ * mode ({assistant:true}, i.e. incl. the high-entropy fallback) for BOTH speakers,
20
+ * then the optional client/customer redactor (names, IDs, emails, billing amounts).
21
+ * Opened read-only; never writes to the store.
22
+ */
23
+ import { existsSync, readdirSync, statSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import { brotliDecompressSync } from 'node:zlib';
26
+ import Database from 'better-sqlite3';
27
+ import { redactSecrets, sanitizeRecord } from './transcript-sanitizer.js';
28
+ import { claudeDir, slugDir } from './lens-paths.js';
29
+ import { stripInjected } from './lens-strip.js';
30
+ const bump = (m, k) => (m[k] = (m[k] ?? 0) + 1);
31
+ const SID_RE = /^[A-Za-z0-9._-]{1,128}$/;
32
+ export const CHARS_PER_TOKEN = 4;
33
+ /** A single huge row (pasted logs etc.) is clipped in the VIEW only; verification uses full text. */
34
+ const MAX_RECORD_VIEW_CHARS = 20_000;
35
+ /**
36
+ * Resolve session.db for a session id: <projectDir>/osb/<sid>/session.db, then
37
+ * the cwd slug, then a scan of every project's osb dir (same layout as
38
+ * session-store.ts getStorePath / recall-cli listStores).
39
+ */
40
+ export function resolveSessionDb(sessionId, projectDir, cwd) {
41
+ if (!sessionId || !SID_RE.test(sessionId))
42
+ return null;
43
+ const candidates = [];
44
+ if (projectDir)
45
+ candidates.push(join(projectDir, 'osb', sessionId, 'session.db'));
46
+ if (cwd)
47
+ candidates.push(join(slugDir(cwd), 'osb', sessionId, 'session.db'));
48
+ for (const p of candidates)
49
+ if (existsSync(p) && statSync(p).size > 0)
50
+ return p;
51
+ const root = join(claudeDir(), 'projects');
52
+ try {
53
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
54
+ if (!ent.isDirectory())
55
+ continue;
56
+ const p = join(root, ent.name, 'osb', sessionId, 'session.db');
57
+ if (existsSync(p) && statSync(p).size > 0)
58
+ return p;
59
+ }
60
+ }
61
+ catch {
62
+ /* no projects dir */
63
+ }
64
+ return null;
65
+ }
66
+ export function includeSubagents() {
67
+ return ['1', 'true', 'yes', 'on'].includes((process.env.OSBORN_LENS_INCLUDE_SUBAGENTS ?? '').trim().toLowerCase());
68
+ }
69
+ /**
70
+ * Read conversation rows with id > afterRowId, in id order, sanitized.
71
+ * Throws if the DB can't be opened (caller logs + exits).
72
+ */
73
+ export function loadConversationRows(dbPath, afterRowId = 0, clientRedact, throughRowId) {
74
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
75
+ try {
76
+ db.pragma('busy_timeout = 5000');
77
+ const where = includeSubagents() ? '' : `AND source = 'main'`;
78
+ const upper = Number.isFinite(throughRowId) ? `AND id <= ${Math.floor(throughRowId)}` : '';
79
+ const stmt = db.prepare(`SELECT id, ts, msg_type, blob FROM content
80
+ WHERE id > ? ${upper} AND msg_type IN ('user', 'assistant') ${where}
81
+ ORDER BY id`);
82
+ const out = { dbPath, records: [], maxRowId: afterRowId, stats: { rowsRead: 0, dropped: 0, unwrapped: 0, chars: 0, stripped: {} } };
83
+ for (const row of stmt.iterate(afterRowId)) {
84
+ out.stats.rowsRead++;
85
+ if (row.id > out.maxRowId)
86
+ out.maxRowId = row.id;
87
+ let raw;
88
+ try {
89
+ raw = brotliDecompressSync(row.blob).toString('utf-8');
90
+ }
91
+ catch {
92
+ out.stats.dropped++;
93
+ continue;
94
+ }
95
+ const speaker = row.msg_type === 'assistant' ? 'assistant' : 'user';
96
+ // Injected / summarised context (session tails, recall blocks, reminders,
97
+ // compaction summaries, silent control rows…) goes first — lens-strip.ts.
98
+ const st = stripInjected(raw, speaker);
99
+ for (const k of st.kinds)
100
+ bump(out.stats.stripped, k);
101
+ if (st.text === null) {
102
+ out.stats.dropped++;
103
+ continue;
104
+ }
105
+ const lead = st.text.replace(/^\[time:[^\]]*\]\s*/, '').trimStart();
106
+ const { record, action } = sanitizeRecord({ text: st.text, timestamp: row.ts ?? '', speaker });
107
+ if (!record || !record.timestamp) {
108
+ bump(out.stats.stripped, `harness-other:${(lead.match(/^(\[[A-Za-z][^\]\n]{0,40}\]|<[a-z-]+>)/)?.[1] ?? 'empty/other')}`);
109
+ out.stats.dropped++;
110
+ continue;
111
+ }
112
+ if (action === 'unwrap') {
113
+ out.stats.unwrapped++;
114
+ bump(out.stats.stripped, lead.startsWith('[CONTEXT]') ? 'context-unwrapped' : 'interrupted-unwrapped');
115
+ }
116
+ // Second pass: assistant-mode redaction for every row, both speakers; then
117
+ // client/customer redaction (lens-redact.ts) — all before any model sees it.
118
+ let text = redactSecrets(record.text, { assistant: true });
119
+ if (clientRedact)
120
+ text = clientRedact(text);
121
+ if (!text.trim()) {
122
+ out.stats.dropped++;
123
+ continue;
124
+ }
125
+ out.records.push({ id: row.id, timestamp: record.timestamp, speaker, text });
126
+ out.stats.chars += text.length;
127
+ }
128
+ return out;
129
+ }
130
+ finally {
131
+ db.close();
132
+ }
133
+ }
134
+ /** Record as the model sees it: header with row id, exact timestamp, speaker. */
135
+ export function recordView(r) {
136
+ const t = r.text.length > MAX_RECORD_VIEW_CHARS ? r.text.slice(0, MAX_RECORD_VIEW_CHARS) + ' …[clipped]' : r.text;
137
+ return `[#${r.id} | ${r.timestamp} | ${r.speaker}]\n${t}`;
138
+ }
139
+ export const estimateTokens = (chars) => Math.ceil(chars / CHARS_PER_TOKEN);
140
+ /** Pack records IN ORDER into windows of ≤ windowTokens (estimated at CHARS_PER_TOKEN). */
141
+ export function packWindows(records, windowTokens) {
142
+ const maxChars = Math.max(10_000, windowTokens * CHARS_PER_TOKEN);
143
+ const windows = [];
144
+ let cur = { views: [], recs: [], chars: 0 };
145
+ const flush = () => {
146
+ if (!cur.recs.length)
147
+ return;
148
+ const text = cur.views.join('\n\n');
149
+ windows.push({
150
+ index: windows.length,
151
+ text,
152
+ records: cur.recs,
153
+ firstRowId: cur.recs[0].id,
154
+ lastRowId: cur.recs[cur.recs.length - 1].id,
155
+ chars: text.length,
156
+ estTokens: estimateTokens(text.length),
157
+ });
158
+ cur = { views: [], recs: [], chars: 0 };
159
+ };
160
+ for (const r of records) {
161
+ const v = recordView(r);
162
+ if (cur.chars + v.length + 2 > maxChars && cur.recs.length)
163
+ flush();
164
+ cur.views.push(v);
165
+ cur.recs.push(r);
166
+ cur.chars += v.length + 2;
167
+ }
168
+ flush();
169
+ return windows;
170
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * lens-hn.ts — GAP CHECK via the keyless Hacker News Algolia API.
3
+ * Fail-open: any network error → { reachable: false }, never throws.
4
+ */
5
+ export interface HnStory {
6
+ title: string;
7
+ points: number;
8
+ num_comments: number;
9
+ url: string;
10
+ }
11
+ export interface GapEvidence {
12
+ queries: string[];
13
+ reachable: boolean;
14
+ stories: HnStory[];
15
+ note: string;
16
+ }
17
+ /** Deterministic demand-vs-supply note from the HN results (no model). */
18
+ export declare function gapNote(stories: HnStory[], totalHits: number): string;
19
+ export declare function gapCheck(queries: string[]): Promise<GapEvidence>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * lens-hn.ts — GAP CHECK via the keyless Hacker News Algolia API.
3
+ * Fail-open: any network error → { reachable: false }, never throws.
4
+ */
5
+ const HN_SEARCH = 'https://hn.algolia.com/api/v1/search';
6
+ const HN_TIMEOUT_MS = 10_000;
7
+ async function searchHn(query) {
8
+ const ctrl = new AbortController();
9
+ const timer = setTimeout(() => ctrl.abort(), HN_TIMEOUT_MS);
10
+ try {
11
+ const url = `${HN_SEARCH}?query=${encodeURIComponent(query)}&tags=story&hitsPerPage=10`;
12
+ const resp = await fetch(url, { signal: ctrl.signal });
13
+ if (!resp.ok)
14
+ throw new Error(`HN HTTP ${resp.status}`);
15
+ const data = await resp.json();
16
+ const hits = Array.isArray(data?.hits) ? data.hits : [];
17
+ return hits
18
+ .filter(h => h?.objectID && typeof h?.title === 'string')
19
+ .map(h => ({
20
+ title: String(h.title),
21
+ points: Number(h.points) || 0,
22
+ num_comments: Number(h.num_comments) || 0,
23
+ url: `https://news.ycombinator.com/item?id=${encodeURIComponent(String(h.objectID))}`,
24
+ }));
25
+ }
26
+ finally {
27
+ clearTimeout(timer);
28
+ }
29
+ }
30
+ /** Deterministic demand-vs-supply note from the HN results (no model). */
31
+ export function gapNote(stories, totalHits) {
32
+ if (totalHits === 0)
33
+ return 'Supply: no matching HN stories found — open lane, but demand is unproven.';
34
+ const top = stories[0];
35
+ const demand = top.points >= 200 || top.num_comments >= 100 ? 'high' : top.points >= 30 ? 'moderate' : 'low';
36
+ const supply = totalHits >= 15 ? 'crowded' : totalHits >= 5 ? 'some coverage' : 'thin';
37
+ return (`Demand ${demand} (top related story ${top.points} pts / ${top.num_comments} comments); ` +
38
+ `supply ${supply} (${totalHits} related stories across queries). ` +
39
+ (supply !== 'crowded' && demand !== 'low'
40
+ ? 'Likely gap: interest exists, few first-hand build write-ups.'
41
+ : supply === 'crowded'
42
+ ? 'Needs a distinct first-hand angle to stand out.'
43
+ : 'Niche: post for credibility rather than reach.'));
44
+ }
45
+ export async function gapCheck(queries) {
46
+ const qs = queries.map(q => q.trim()).filter(Boolean).slice(0, 2);
47
+ if (qs.length === 0)
48
+ return { queries: [], reachable: false, stories: [], note: 'No search queries emitted.' };
49
+ const seen = new Map();
50
+ let ok = false;
51
+ for (const q of qs) {
52
+ try {
53
+ for (const s of await searchHn(q))
54
+ if (!seen.has(s.url))
55
+ seen.set(s.url, s);
56
+ ok = true;
57
+ }
58
+ catch {
59
+ /* fail-open */
60
+ }
61
+ }
62
+ if (!ok)
63
+ return { queries: qs, reachable: false, stories: [], note: 'HN unreachable — gap not checked.' };
64
+ // Keep Algolia relevance order (sorting by points surfaces loosely-related hits);
65
+ // the demand note reads the strongest of the top-3 relevant stories.
66
+ const all = [...seen.values()];
67
+ const top = all.slice(0, 3);
68
+ return { queries: qs, reachable: true, stories: top, note: gapNote([...top].sort((a, b) => b.points - a.points), all.length) };
69
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * lens-ingest.ts — sends a published library page to the cloud content library
3
+ * as a private DRAFT through the Supabase RPC `public.content_ingest(p_token,
4
+ * p_payload)` (migration 007). This is the module behind the ingest seam in
5
+ * lens-library.ts.
6
+ *
7
+ * - Auth: the machine's per-user OSBORN_SYNC_TOKEN is the credential. The RPC
8
+ * resolves the owner server-side. PostgREST is called with the PUBLIC anon key
9
+ * (shipped by the frontend by design). A service key is never used: any key
10
+ * whose JWT role isn't "anon" is refused.
11
+ * - Fail-open: never throws. Times out after ~10s. Logs outcomes only (never the
12
+ * token, URL query, headers or body).
13
+ * - Opt-out: OSBORN_CONTENT_INGEST=0. OSBORN_CONTENT_LENS=0 disables it too.
14
+ * - Never sends a status. The server forces draft anyway.
15
+ * - Idempotent: content_hash = sha256(sessionId, startRowId, page markdown), so
16
+ * re-runs upsert instead of duplicating.
17
+ */
18
+ export declare const INGEST_MAX_BYTES: number;
19
+ export declare const INGEST_TIMEOUT_MS = 10000;
20
+ export type IngestStatus = 'ok' | 'error' | 'skipped' | 'oversize';
21
+ export interface IngestState {
22
+ status: IngestStatus;
23
+ /** 'disabled' | 'no-token' | 'no-url' | 'no-key' | 'http-500' | 'timeout' | 'network' | 'oversize' ... */
24
+ reason?: string;
25
+ contentHash: string;
26
+ httpStatus?: number;
27
+ id?: string;
28
+ at: string;
29
+ }
30
+ /** The minimal page shape ingest needs (fresh pages and manifest retries both map to it). */
31
+ export interface IngestPage {
32
+ sessionId: string;
33
+ startRowId: number;
34
+ endRowId: number;
35
+ index: number;
36
+ closedBy: number | 'compaction' | null;
37
+ file: string;
38
+ /** The page exactly as written to disk (already redacted by the library step). */
39
+ markdown: string;
40
+ }
41
+ export interface IngestOptions {
42
+ /** Re-redaction applied to every text field before sending (client redactor + redactSecrets). */
43
+ scrub?: (s: string) => string;
44
+ fetchImpl?: typeof fetch;
45
+ timeoutMs?: number;
46
+ log?: (m: string) => void;
47
+ }
48
+ export declare function isIngestEnabled(): boolean;
49
+ /** True only for keys safe to send from a machine: anon JWTs or sb_publishable_ keys. */
50
+ export declare function isAnonKey(key: string): boolean;
51
+ export type IngestConfig = {
52
+ ok: true;
53
+ url: string;
54
+ anonKey: string;
55
+ token: string;
56
+ } | {
57
+ ok: false;
58
+ reason: string;
59
+ };
60
+ export declare function resolveIngestConfig(): IngestConfig;
61
+ export declare function contentHashFor(p: Pick<IngestPage, 'sessionId' | 'startRowId' | 'markdown'>): string;
62
+ /** Map a page to the content_ingest payload. Never includes status/published_at/owner. */
63
+ export declare function buildIngestPayload(p: IngestPage, scrub?: (s: string) => string): Record<string, unknown>;
64
+ /** POST one page to content_ingest. Never throws; returns the state to record in the manifest. */
65
+ export declare function ingestPage(p: IngestPage, o?: IngestOptions): Promise<IngestState>;
66
+ /** Does this manifest entry still need an ingest attempt? */
67
+ export declare function needsIngest(state: IngestState | undefined, currentHash: string): boolean;
@@ -0,0 +1,155 @@
1
+ /**
2
+ * lens-ingest.ts — sends a published library page to the cloud content library
3
+ * as a private DRAFT through the Supabase RPC `public.content_ingest(p_token,
4
+ * p_payload)` (migration 007). This is the module behind the ingest seam in
5
+ * lens-library.ts.
6
+ *
7
+ * - Auth: the machine's per-user OSBORN_SYNC_TOKEN is the credential. The RPC
8
+ * resolves the owner server-side. PostgREST is called with the PUBLIC anon key
9
+ * (shipped by the frontend by design). A service key is never used: any key
10
+ * whose JWT role isn't "anon" is refused.
11
+ * - Fail-open: never throws. Times out after ~10s. Logs outcomes only (never the
12
+ * token, URL query, headers or body).
13
+ * - Opt-out: OSBORN_CONTENT_INGEST=0. OSBORN_CONTENT_LENS=0 disables it too.
14
+ * - Never sends a status. The server forces draft anyway.
15
+ * - Idempotent: content_hash = sha256(sessionId, startRowId, page markdown), so
16
+ * re-runs upsert instead of duplicating.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { isContentLensEnabled } from './lens-launch.js';
20
+ import { parsePage } from './lens-library-index.js';
21
+ /** Public project values the frontend ships (NEXT_PUBLIC_*). The anon key is public by design. */
22
+ const DEFAULT_SUPABASE_URL = 'https://frzbawsadhmmltokvexj.supabase.co';
23
+ const DEFAULT_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZyemJhd3NhZGhtbWx0b2t2ZXhqIiwicm9sZSI6ImFub24iLCJpYXQiOjE2Nzg3MzgxMjcsImV4cCI6MTk5NDMxNDEyN30.KAHO-apaXL3G7mBTZ17r8GN0vXoOZIL7d_HzKr7hJjc';
24
+ export const INGEST_MAX_BYTES = 250 * 1024;
25
+ export const INGEST_TIMEOUT_MS = 10_000;
26
+ const OFF = ['0', 'off', 'false', 'no'];
27
+ export function isIngestEnabled() {
28
+ if (!isContentLensEnabled())
29
+ return false;
30
+ return !OFF.includes((process.env.OSBORN_CONTENT_INGEST ?? '').trim().toLowerCase());
31
+ }
32
+ /** True only for keys safe to send from a machine: anon JWTs or sb_publishable_ keys. */
33
+ export function isAnonKey(key) {
34
+ if (key.startsWith('sb_publishable_'))
35
+ return true;
36
+ const parts = key.split('.');
37
+ if (parts.length !== 3)
38
+ return false;
39
+ try {
40
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'))?.role === 'anon';
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ export function resolveIngestConfig() {
47
+ if (!isIngestEnabled())
48
+ return { ok: false, reason: 'disabled' };
49
+ const token = (process.env.OSBORN_SYNC_TOKEN ?? '').trim();
50
+ if (!token)
51
+ return { ok: false, reason: 'no-token' };
52
+ const e = process.env;
53
+ const url = (e.OSBORN_SUPABASE_URL ?? e.SUPABASE_URL ?? e.NEXT_PUBLIC_SUPABASE_URL ?? DEFAULT_SUPABASE_URL).trim().replace(/\/+$/, '');
54
+ if (!/^https:\/\/[^/?#]+$/.test(url))
55
+ return { ok: false, reason: 'no-url' };
56
+ const anonKey = [e.OSBORN_SUPABASE_ANON_KEY, e.SUPABASE_ANON_KEY, e.NEXT_PUBLIC_SUPABASE_ANON_KEY, DEFAULT_ANON_KEY]
57
+ .map(k => (k ?? '').trim())
58
+ .find(k => k && isAnonKey(k));
59
+ if (!anonKey)
60
+ return { ok: false, reason: 'no-key' };
61
+ return { ok: true, url, anonKey, token };
62
+ }
63
+ export function contentHashFor(p) {
64
+ return createHash('sha256').update(`${p.sessionId}\n${p.startRowId}\n${p.markdown}`).digest('hex');
65
+ }
66
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s);
67
+ const SESSION_ID_RE = /^[a-zA-Z0-9._-]{1,128}$/;
68
+ /** Map a page to the content_ingest payload. Never includes status/published_at/owner. */
69
+ export function buildIngestPayload(p, scrub = s => s) {
70
+ const summary = parsePage(p.file, p.markdown);
71
+ const nn = String(p.index).padStart(2, '0');
72
+ const goal = summary.goal && summary.goal !== '(no verified goal)' ? summary.goal : '';
73
+ const title = clip(scrub(goal ? `Period ${nn}: ${goal}` : `Period ${nn}`), 200);
74
+ const hook = clip(scrub(summary.topItem ?? goal ?? ''), 280);
75
+ return {
76
+ type: 'text_post',
77
+ title,
78
+ hook: hook || null,
79
+ body: scrub(p.markdown),
80
+ source_session_id: SESSION_ID_RE.test(p.sessionId) ? p.sessionId : null,
81
+ source_anchors: { start_row_id: p.startRowId, end_row_id: p.endRowId, closed_by: p.closedBy, period_index: p.index, file: p.file },
82
+ source_kind: 'library_page',
83
+ content_hash: contentHashFor(p),
84
+ };
85
+ }
86
+ /** POST one page to content_ingest. Never throws; returns the state to record in the manifest. */
87
+ export async function ingestPage(p, o = {}) {
88
+ const log = o.log ?? (() => { });
89
+ const at = new Date().toISOString();
90
+ let contentHash = '';
91
+ const tag = `library: ingest period ${p.index} (#${p.startRowId})`;
92
+ try {
93
+ contentHash = contentHashFor(p);
94
+ const cfg = resolveIngestConfig();
95
+ if (!cfg.ok)
96
+ return { status: 'skipped', reason: cfg.reason, contentHash, at };
97
+ const payload = buildIngestPayload(p, o.scrub);
98
+ const body = JSON.stringify({ p_token: cfg.token, p_payload: payload });
99
+ const bytes = Buffer.byteLength(body, 'utf-8');
100
+ if (bytes > INGEST_MAX_BYTES) {
101
+ log(`${tag} → skipped: payload ${bytes} bytes > ${INGEST_MAX_BYTES}`);
102
+ return { status: 'oversize', reason: 'oversize', contentHash, at };
103
+ }
104
+ const ctl = new AbortController();
105
+ const timer = setTimeout(() => ctl.abort(), o.timeoutMs ?? INGEST_TIMEOUT_MS);
106
+ try {
107
+ const res = await (o.fetchImpl ?? fetch)(`${cfg.url}/rest/v1/rpc/content_ingest`, {
108
+ method: 'POST',
109
+ // This project's PostgREST db_schema lists `storage` first, so the profile headers are required to hit public.content_ingest.
110
+ headers: { apikey: cfg.anonKey, Authorization: `Bearer ${cfg.anonKey}`, 'Content-Type': 'application/json', 'Content-Profile': 'public', 'Accept-Profile': 'public' },
111
+ body,
112
+ signal: ctl.signal,
113
+ });
114
+ const text = await res.text().catch(() => '');
115
+ if (!res.ok) {
116
+ // Only the sanitized PostgREST code (e.g. 28000) and hint are logged, never the message or body.
117
+ let code = '';
118
+ let hint = '';
119
+ try {
120
+ const j = JSON.parse(text);
121
+ code = String(j?.code ?? '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8);
122
+ hint = String(j?.hint ?? '').replace(/[^A-Za-z0-9 ._-]/g, '').replace(/\s+/g, ' ').trim().slice(0, 80);
123
+ }
124
+ catch { /* not JSON */ }
125
+ log(`${tag} → failed: HTTP ${res.status}${code ? ` code=${code}` : ''}${hint ? ` hint="${hint}"` : ''} (will retry next run)`);
126
+ return { status: 'error', reason: `http-${res.status}`, httpStatus: res.status, contentHash, at };
127
+ }
128
+ let id;
129
+ try {
130
+ const v = JSON.parse(text);
131
+ if (typeof v === 'string' && /^[0-9a-f-]{36}$/i.test(v))
132
+ id = v;
133
+ }
134
+ catch { /* ignore */ }
135
+ log(`${tag} → ok: draft saved (HTTP ${res.status})`);
136
+ return { status: 'ok', httpStatus: res.status, ...(id ? { id } : {}), contentHash, at };
137
+ }
138
+ finally {
139
+ clearTimeout(timer);
140
+ }
141
+ }
142
+ catch (e) {
143
+ const reason = e?.name === 'AbortError' ? 'timeout' : 'network';
144
+ log(`${tag} → failed: ${reason} (will retry next run)`);
145
+ return { status: 'error', reason, contentHash, at };
146
+ }
147
+ }
148
+ /** Does this manifest entry still need an ingest attempt? */
149
+ export function needsIngest(state, currentHash) {
150
+ if (!state)
151
+ return true;
152
+ if (state.contentHash !== currentHash)
153
+ return true;
154
+ return state.status !== 'ok' && state.status !== 'oversize';
155
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * lens-launch.ts — HOOK-SIDE launcher for the compaction content lens.
3
+ *
4
+ * Called from the PostCompact hook. It only: takes the per-project lock, writes
5
+ * a tiny 0600 temp JSON payload (session id + transcript_path + cwd — nothing
6
+ * else), and spawns a DETACHED Node process running compaction-lens-worker.
7
+ * No awaits, no heavy imports (node: builtins only), never throws.
8
+ */
9
+ export interface LensLaunchPayload {
10
+ sessionId: string;
11
+ transcriptPath?: string;
12
+ cwd?: string;
13
+ }
14
+ export type LensLaunchResult = {
15
+ status: 'disabled';
16
+ } | {
17
+ status: 'no-key';
18
+ } | {
19
+ status: 'locked';
20
+ projectDir: string;
21
+ } | {
22
+ status: 'unlockable';
23
+ projectDir: string | null;
24
+ } | {
25
+ status: 'no-worker';
26
+ } | {
27
+ status: 'spawned';
28
+ pid?: number;
29
+ projectDir: string | null;
30
+ worker: string;
31
+ } | {
32
+ status: 'error';
33
+ error: string;
34
+ };
35
+ /** ON by default; kill switch: OSBORN_CONTENT_LENS=0 | off | false | no. */
36
+ export declare function isContentLensEnabled(): boolean;
37
+ /**
38
+ * Worker entry + node args. From dist → dist/content/compaction-lens-worker.js.
39
+ * From a tsx/src dev run → the .ts sibling, loaded through tsx's ESM hook.
40
+ */
41
+ export declare function resolveWorkerCommand(): {
42
+ args: string[];
43
+ worker: string;
44
+ } | null;
45
+ /** Fire-and-forget. Returns synchronously; the worker is never awaited. */
46
+ export declare function launchCompactionLens(payload: LensLaunchPayload): LensLaunchResult;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * lens-launch.ts — HOOK-SIDE launcher for the compaction content lens.
3
+ *
4
+ * Called from the PostCompact hook. It only: takes the per-project lock, writes
5
+ * a tiny 0600 temp JSON payload (session id + transcript_path + cwd — nothing
6
+ * else), and spawns a DETACHED Node process running compaction-lens-worker.
7
+ * No awaits, no heavy imports (node: builtins only), never throws.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { tmpdir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { LOCK_FILE, acquireLock, findTranscriptBySessionId, releaseLock, resolveProjectDir } from './lens-paths.js';
17
+ /** ON by default; kill switch: OSBORN_CONTENT_LENS=0 | off | false | no. */
18
+ export function isContentLensEnabled() {
19
+ const v = (process.env.OSBORN_CONTENT_LENS ?? '').trim().toLowerCase();
20
+ return !['0', 'off', 'false', 'no'].includes(v);
21
+ }
22
+ /**
23
+ * Worker entry + node args. From dist → dist/content/compaction-lens-worker.js.
24
+ * From a tsx/src dev run → the .ts sibling, loaded through tsx's ESM hook.
25
+ */
26
+ export function resolveWorkerCommand() {
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const js = join(here, 'compaction-lens-worker.js');
29
+ if (existsSync(js))
30
+ return { args: [js], worker: js };
31
+ const ts = join(here, 'compaction-lens-worker.ts');
32
+ if (existsSync(ts)) {
33
+ try {
34
+ const tsxEsm = createRequire(import.meta.url).resolve('tsx/esm');
35
+ return { args: ['--import', pathToFileURL(tsxEsm).href, ts], worker: ts };
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ let warnedSpawn = false;
44
+ /** Fire-and-forget. Returns synchronously; the worker is never awaited. */
45
+ export function launchCompactionLens(payload) {
46
+ let projectDir = null;
47
+ let lockHeld = false;
48
+ try {
49
+ if (!isContentLensEnabled())
50
+ return { status: 'disabled' };
51
+ // OpenRouter is the only model path; without a key the worker can do nothing.
52
+ if (!(process.env.OPENROUTER_API_KEY || '').trim())
53
+ return { status: 'no-key' };
54
+ const cmd = resolveWorkerCommand();
55
+ if (!cmd)
56
+ return { status: 'no-worker' };
57
+ projectDir = resolveProjectDir(payload.transcriptPath, payload.cwd);
58
+ if (!projectDir) {
59
+ const t = findTranscriptBySessionId(payload.sessionId);
60
+ projectDir = t ? dirname(t) : null;
61
+ }
62
+ if (!projectDir)
63
+ return { status: 'unlockable', projectDir: null };
64
+ // mkdir then lock; if the dir can't be created or locked, skip the launch entirely
65
+ // (an unlocked worker could race another one on the profile / high-water mark).
66
+ try {
67
+ mkdirSync(projectDir, { recursive: true });
68
+ }
69
+ catch {
70
+ return { status: 'unlockable', projectDir };
71
+ }
72
+ if (!acquireLock(projectDir)) {
73
+ return existsSync(join(projectDir, LOCK_FILE)) ? { status: 'locked', projectDir } : { status: 'unlockable', projectDir };
74
+ }
75
+ lockHeld = true;
76
+ const argFile = join(tmpdir(), `osborn-lens-${process.pid}-${randomBytes(6).toString('hex')}.json`);
77
+ writeFileSync(argFile, JSON.stringify({
78
+ sessionId: payload.sessionId,
79
+ transcriptPath: payload.transcriptPath,
80
+ cwd: payload.cwd,
81
+ lockDir: lockHeld ? projectDir : null,
82
+ }), { mode: 0o600, flag: 'wx' });
83
+ {
84
+ const child = spawn(process.execPath, [...cmd.args, argFile], {
85
+ detached: true,
86
+ stdio: 'ignore',
87
+ env: { ...process.env },
88
+ });
89
+ child.on('error', err => {
90
+ if (lockHeld && projectDir)
91
+ releaseLock(projectDir);
92
+ if (!warnedSpawn) {
93
+ warnedSpawn = true;
94
+ console.error('⚠️ content-lens: worker spawn failed:', err?.message);
95
+ }
96
+ });
97
+ child.unref();
98
+ return { status: 'spawned', pid: child.pid, projectDir, worker: cmd.worker };
99
+ }
100
+ }
101
+ catch (err) {
102
+ if (lockHeld && projectDir)
103
+ releaseLock(projectDir);
104
+ const msg = err instanceof Error ? err.message : String(err);
105
+ if (!warnedSpawn) {
106
+ warnedSpawn = true;
107
+ console.error('⚠️ content-lens: launch failed:', msg);
108
+ }
109
+ return { status: 'error', error: msg };
110
+ }
111
+ }
@@ -0,0 +1,30 @@
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
+ export declare const INDEX_FILE = "INDEX.md";
8
+ export declare const PAGE_RE: RegExp;
9
+ export interface PageStory {
10
+ title: string;
11
+ rows: string;
12
+ anchor: string | null;
13
+ }
14
+ export interface PageSummary {
15
+ file: string;
16
+ index: number;
17
+ rows: string;
18
+ from: string;
19
+ to: string;
20
+ goal: string;
21
+ topItem: string | null;
22
+ stories: PageStory[];
23
+ costUsd: number;
24
+ }
25
+ /** Parse one period page (renderPeriodMd output). Pure. */
26
+ export declare function parsePage(file: string, md: string): PageSummary;
27
+ /** Render INDEX.md. Pure. */
28
+ export declare function renderIndex(sessionLabel: string, pages: PageSummary[]): string;
29
+ /** Rebuild <dir>/INDEX.md from every period page in <dir>. `scrub` = final redaction pass. Returns the path. */
30
+ export declare function rebuildIndex(dir: string, sessionLabel: string, scrub?: (s: string) => string): string;