fapony 0.3.3 → 0.3.5

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 (87) hide show
  1. package/README.md +7 -1
  2. package/fapony.ts +3 -116
  3. package/package.json +5 -6
  4. package/skill/move-to-done/SKILL.md +3 -2
  5. package/src/adapters/cli.ts +128 -0
  6. package/src/adapters/hooks/compute-hint-impact.ts +107 -0
  7. package/src/adapters/hooks/context-data.ts +102 -0
  8. package/src/adapters/hooks/edit-hint.ts +195 -0
  9. package/src/adapters/hooks/git-autonomy.ts +178 -0
  10. package/src/adapters/hooks/index.ts +80 -0
  11. package/src/adapters/hooks/mv-guard.ts +52 -0
  12. package/src/adapters/hooks/read-hint.ts +383 -0
  13. package/src/adapters/hooks/session-start.ts +101 -0
  14. package/src/adapters/hooks/stop.ts +434 -0
  15. package/src/{mcp → adapters/mcp}/evidence.ts +2 -2
  16. package/src/{mcp → adapters/mcp}/primitives.ts +2 -2
  17. package/src/{mcp → adapters/mcp}/tools/check.ts +1 -1
  18. package/src/{mcp → adapters/mcp}/tools/collect.ts +1 -1
  19. package/src/{mcp → adapters/mcp}/tools/mem.ts +3 -3
  20. package/src/{mcp → adapters/mcp}/tools/report.ts +4 -4
  21. package/src/adapters/mcp/types.ts +18 -0
  22. package/src/{mcp → adapters/mcp}/worktree.ts +1 -1
  23. package/src/analyze.ts +1 -1
  24. package/src/commands.ts +180 -0
  25. package/src/conventions-seed.ts +1 -1
  26. package/src/core/config.ts +199 -0
  27. package/src/core/debt-format.ts +107 -0
  28. package/src/core/debt-types.ts +79 -0
  29. package/src/core/defaults.ts +8 -0
  30. package/src/core/enums.ts +34 -0
  31. package/src/core/format.ts +33 -0
  32. package/src/core/hint-log.ts +68 -0
  33. package/src/core/hook-helpers.ts +31 -0
  34. package/src/core/mem-log.ts +357 -0
  35. package/src/core/parse.ts +71 -0
  36. package/src/core/pricing.ts +217 -0
  37. package/src/core/safety.ts +18 -0
  38. package/src/core/types.ts +142 -0
  39. package/src/core/util.ts +105 -0
  40. package/src/db/store.ts +2 -2
  41. package/src/debt/cli.ts +1 -1
  42. package/src/debt/format.ts +2 -107
  43. package/src/debt/load.ts +1 -1
  44. package/src/debt/promotion.ts +1 -1
  45. package/src/debt/types.ts +14 -79
  46. package/src/digest/collect.ts +4 -3
  47. package/src/gate.ts +2 -2
  48. package/src/gates.ts +1 -1
  49. package/src/hook.ts +70 -1424
  50. package/src/init-mem.ts +1 -1
  51. package/src/init.ts +1 -1
  52. package/src/install/claude.ts +17 -0
  53. package/src/install/opencode.ts +114 -0
  54. package/src/install.ts +12 -5
  55. package/src/lint-baseline.ts +1 -2
  56. package/src/map.ts +29 -8
  57. package/src/mem/commands/plan.ts +197 -42
  58. package/src/mem/commands/read.ts +53 -12
  59. package/src/mem/index.ts +60 -1
  60. package/src/mem/store.ts +5 -1
  61. package/src/memory.ts +26 -388
  62. package/src/parse.ts +9 -71
  63. package/src/price/fetch.ts +4 -16
  64. package/src/price/resolve.ts +12 -213
  65. package/src/report/cli.ts +3 -3
  66. package/src/safety.ts +2 -18
  67. package/src/seed/plan-seed.ts +126 -32
  68. package/src/seed/primitives.ts +1 -7
  69. package/src/session/types.ts +14 -128
  70. package/src/setup.ts +1 -1
  71. package/src/stats/data.ts +3 -3
  72. package/src/telemetry.ts +2 -2
  73. package/src/update.ts +1 -1
  74. package/src/usage/cache.ts +1 -2
  75. package/src/usage/cli.ts +1 -1
  76. package/src/usage/scan.ts +2 -1
  77. package/src/util.ts +10 -93
  78. package/src/web/html.ts +2 -33
  79. package/src/db/defaults.ts +0 -34
  80. package/src/db/getters.ts +0 -35
  81. package/src/db/index.ts +0 -7
  82. package/src/db/load.ts +0 -57
  83. package/src/db/types.ts +0 -77
  84. package/src/mcp/types.ts +0 -54
  85. package/src/test.ts +0 -2
  86. /package/src/{mcp → adapters/mcp}/tools/index.ts +0 -0
  87. /package/src/{mcp → adapters/mcp}/transport.ts +0 -0
@@ -0,0 +1,383 @@
1
+ // src/adapters/hooks/read-hint.ts — Read hint + re-read hint + commit hint
2
+ //
3
+ // Split from src/hook.ts (PLAN-lib-layer chunk 3). Attaches size/read context
4
+ // to file reads. Also includes commitHintFor (OpenCode commit hint).
5
+
6
+ import {
7
+ appendFileSync,
8
+ existsSync,
9
+ mkdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ statSync,
13
+ } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join, relative, resolve } from "node:path";
16
+ import { SCAN_EXTS } from "../../analyze.js";
17
+ import { recordHintFire } from "../../core/hint-log.js";
18
+ import { sessionKey } from "../../core/hook-helpers.js";
19
+ import { readMemLog } from "../../memory.js";
20
+ import { renderSeed } from "../../seed/review-seed.js";
21
+ import { readContextData } from "./context-data.js";
22
+
23
+ // --- Read hint (size) ---
24
+
25
+ /** Below this size a full read is already cheap — stay silent. */
26
+ export const READ_HINT_MIN_BYTES = 24_000;
27
+ /** A caller-chosen limit below this is a bounded read — already cheap. */
28
+ export const READ_HINT_MIN_LIMIT = 300;
29
+ /** One-time measurement (2026-09-17, this repo): 5 files / 2,146 lines ≈ 3.7KB out. */
30
+ const READ_HINT_MEASURED = "measured ~3.7KB output on a 2,146-line file";
31
+ /** Cap on the outline attached to the read hint — keeps the hint compact. */
32
+ const READ_HINT_OUTLINE_CAP = 2_000;
33
+
34
+ export interface ReadHintInput {
35
+ filePath: unknown;
36
+ offset?: unknown;
37
+ limit?: unknown;
38
+ cwd: string;
39
+ }
40
+
41
+ /**
42
+ * Factual one-liner for a full-file read of a large source file, or null.
43
+ * Every unknown resolves to null — a hint must never fire on a guess.
44
+ */
45
+ export function readHintFor(opts: ReadHintInput): string | null {
46
+ try {
47
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
48
+ const dot = opts.filePath.lastIndexOf(".");
49
+ if (dot < 0 || !SCAN_EXTS.has(opts.filePath.slice(dot))) return null;
50
+ const limit = typeof opts.limit === "number" ? opts.limit : null;
51
+ if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
52
+ const st = statSync(opts.filePath);
53
+ if (!st.isFile() || st.size < READ_HINT_MIN_BYTES) return null;
54
+ const git = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
55
+ cwd: opts.cwd,
56
+ stdout: "pipe",
57
+ stderr: "pipe",
58
+ });
59
+ if (git.exitCode !== 0) return null;
60
+ const lines = readFileSync(opts.filePath, "utf-8").split("\n").length;
61
+ const rel = relative(opts.cwd, opts.filePath);
62
+ const shown = rel.startsWith("..") ? opts.filePath : rel;
63
+
64
+ let outline = "";
65
+ try {
66
+ const seed = renderSeed(["--files", shown], opts.cwd);
67
+ const sigMatch = seed.match(
68
+ /signatures \(current\):\n([\s\S]*?)(?:\n\w|\nstatic graph)/,
69
+ );
70
+ if (sigMatch) {
71
+ outline = sigMatch[1].trim();
72
+ } else {
73
+ const afterFiles = seed.indexOf("\nimporters");
74
+ if (afterFiles > 0) {
75
+ outline = seed
76
+ .slice(0, Math.min(afterFiles, READ_HINT_OUTLINE_CAP))
77
+ .trim();
78
+ } else {
79
+ outline = seed.slice(0, READ_HINT_OUTLINE_CAP).trim();
80
+ }
81
+ }
82
+ if (outline.length > READ_HINT_OUTLINE_CAP) {
83
+ outline = `${outline.slice(0, READ_HINT_OUTLINE_CAP).trimEnd()}\n… truncated`;
84
+ }
85
+ } catch {
86
+ // review-seed failed — fall back to the command suggestion
87
+ }
88
+
89
+ if (outline) {
90
+ return (
91
+ `fapony: ${shown} is ${lines} lines\n${outline}\n` +
92
+ `(review-seed --files ${shown} for importers + callers; skill /lookup-before-edit)`
93
+ );
94
+ }
95
+ return (
96
+ `fapony: ${shown} is ${lines} lines — review-seed --files ${shown} ` +
97
+ `returns exports with line numbers, importers, and signatures first ` +
98
+ `(${READ_HINT_MEASURED}; skill /lookup-before-edit has the routine)`
99
+ );
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ // --- Re-read tracking (mtime heuristic) ---
106
+
107
+ const READ_TRACK_DIR = "read-track";
108
+
109
+ export interface ReadTrackRow {
110
+ ts: string;
111
+ path: string;
112
+ mtime: number;
113
+ }
114
+
115
+ /** Directory holding one read log per session. */
116
+ function readTrackDir(): string {
117
+ const base =
118
+ process.env.FAPONY_STATE_DIR || join(homedir(), ".config", "fapony");
119
+ return join(base, READ_TRACK_DIR);
120
+ }
121
+
122
+ /** Absolute path of a session's read log — may not exist. */
123
+ export function readTrackPath(session: string): string {
124
+ return join(readTrackDir(), `${sessionKey(session)}.jsonl`);
125
+ }
126
+
127
+ function readTrackRows(session: string): ReadTrackRow[] {
128
+ const p = readTrackPath(session);
129
+ if (!existsSync(p)) return [];
130
+ const rows: ReadTrackRow[] = [];
131
+ for (const line of readFileSync(p, "utf-8").split("\n")) {
132
+ if (!line) continue;
133
+ try {
134
+ const r = JSON.parse(line) as ReadTrackRow;
135
+ if (typeof r.path === "string" && typeof r.mtime === "number") {
136
+ rows.push(r);
137
+ }
138
+ } catch {
139
+ // a torn line must not lose the rest of the log
140
+ }
141
+ }
142
+ return rows;
143
+ }
144
+
145
+ function appendReadTrackRow(session: string, row: ReadTrackRow): void {
146
+ const dir = readTrackDir();
147
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
148
+ appendFileSync(readTrackPath(session), `${JSON.stringify(row)}\n`, "utf-8");
149
+ }
150
+
151
+ export interface RereadHintInput {
152
+ filePath: unknown;
153
+ offset?: unknown;
154
+ limit?: unknown;
155
+ cwd: string;
156
+ session?: unknown;
157
+ }
158
+
159
+ /**
160
+ * Annotate a full-file read of a path already read this session whose mtime
161
+ * has not moved, or null. Records every full read.
162
+ */
163
+ export function rereadHintFor(opts: RereadHintInput): string | null {
164
+ try {
165
+ if (process.env.FAPONY_NO_REREAD_HINT === "1") return null;
166
+ if (typeof opts.session !== "string" || opts.session === "") return null;
167
+ if (typeof opts.filePath !== "string" || opts.filePath === "") return null;
168
+ const offset = typeof opts.offset === "number" ? opts.offset : null;
169
+ if (offset !== null && offset !== 0) return null;
170
+ const limit = typeof opts.limit === "number" ? opts.limit : null;
171
+ if (limit !== null && limit < READ_HINT_MIN_LIMIT) return null;
172
+
173
+ const abs = (() => {
174
+ const p = opts.filePath.startsWith("/")
175
+ ? opts.filePath
176
+ : join(opts.cwd, opts.filePath);
177
+ try {
178
+ return realpathSync(p);
179
+ } catch {
180
+ return resolve(p);
181
+ }
182
+ })();
183
+ const st = statSync(abs);
184
+ if (!st.isFile()) return null;
185
+ const mtime = Math.round(st.mtimeMs);
186
+
187
+ const prior = readTrackRows(opts.session).filter((r) => r.path === abs);
188
+ const last = prior.at(-1);
189
+ appendReadTrackRow(opts.session, {
190
+ ts: new Date().toISOString(),
191
+ path: abs,
192
+ mtime,
193
+ });
194
+ if (!last || last.mtime !== mtime) return null;
195
+
196
+ const rel = relative(opts.cwd, opts.filePath);
197
+ const shown = rel.startsWith("..") ? opts.filePath : rel;
198
+ return (
199
+ `fapony: already read ${shown} ${prior.length}\u00d7 this session — ` +
200
+ `content unchanged since the last read (mtime), grep the line range ` +
201
+ `you need instead of re-reading it`
202
+ );
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ // --- Commit hint (OpenCode tool.execute.after) ---
209
+
210
+ /** Below this number of commits, the hint is unnecessary noise. */
211
+ export const COMMIT_HINT_MIN_COMMITS = 1;
212
+
213
+ export interface CommitHintInput {
214
+ command: unknown;
215
+ cwd: string;
216
+ }
217
+
218
+ function git(args: string[], cwd: string): string | null {
219
+ try {
220
+ const p = Bun.spawnSync(["git", ...args], {
221
+ cwd,
222
+ stdout: "pipe",
223
+ stderr: "pipe",
224
+ });
225
+ return p.exitCode === 0 ? p.stdout.toString().trim() : null;
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ import { hookTsMs, utcStamp } from "../../core/hook-helpers.js";
232
+
233
+ /**
234
+ * Nudge for bash commands containing `git commit` that produced commits with
235
+ * no mem row recorded for them.
236
+ */
237
+ export function commitHintFor(opts: CommitHintInput): string | null {
238
+ try {
239
+ if (typeof opts.command !== "string" || opts.command === "") return null;
240
+ if (!/\bgit\s+commit\b/.test(opts.command)) return null;
241
+
242
+ const worktree = git(["rev-parse", "--show-toplevel"], opts.cwd);
243
+ if (!worktree) return null;
244
+
245
+ let memLastTs: string | null = null;
246
+ try {
247
+ memLastTs = readMemLog(worktree).rows[0]?.ts ?? null;
248
+ } catch {
249
+ memLastTs = null;
250
+ }
251
+ if (!memLastTs) return null;
252
+ const since = utcStamp(new Date(hookTsMs(memLastTs) + 1000));
253
+
254
+ const log = git(
255
+ ["log", "--since", `${since} +0000`, "--format=%h %s"],
256
+ worktree,
257
+ );
258
+ const commitList = log ? log.split("\n").filter(Boolean) : [];
259
+ if (commitList.length < COMMIT_HINT_MIN_COMMITS) return null;
260
+
261
+ const lines: string[] = [
262
+ `${commitList.length} commit(s) since last mem row (${memLastTs.slice(0, 10)}) — record a mem row for this work.`,
263
+ ];
264
+ for (const c of commitList.slice(0, 5)) lines.push(` ${c}`);
265
+ if (commitList.length > 5) lines.push(` … +${commitList.length - 5} more`);
266
+ lines.push(
267
+ `fapony mem add <decision|bug|note> "what happened" --files <files> ${worktree}/.fapony/plan/PLAN.md`,
268
+ );
269
+
270
+ const prefixed = lines.map((l) => `fapony: ${l}`).join("\n");
271
+ return prefixed;
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ // --- Read hint CLI command ---
278
+
279
+ /** Claude Code PreToolUse (matcher Read): stdin JSON in, additionalContext out. */
280
+ export async function cmdHookReadHint(): Promise<void> {
281
+ try {
282
+ const raw = JSON.parse(await Bun.stdin.text()) as {
283
+ cwd?: string;
284
+ transcript_path?: string;
285
+ session_id?: string;
286
+ tool_input?: {
287
+ file_path?: unknown;
288
+ offset?: unknown;
289
+ limit?: unknown;
290
+ };
291
+ };
292
+ const cwd = raw.cwd ?? process.cwd();
293
+ const filePath = raw.tool_input?.file_path;
294
+ const session = raw.transcript_path ?? raw.session_id;
295
+ const parts: string[] = [];
296
+ const hint = readHintFor({
297
+ filePath,
298
+ offset: raw.tool_input?.offset,
299
+ limit: raw.tool_input?.limit,
300
+ cwd,
301
+ });
302
+ if (hint) parts.push(hint);
303
+ const reread = rereadHintFor({
304
+ filePath,
305
+ offset: raw.tool_input?.offset,
306
+ limit: raw.tool_input?.limit,
307
+ cwd,
308
+ session,
309
+ });
310
+ if (reread) parts.push(reread);
311
+ const ctx = readContextData(filePath, cwd);
312
+ if (ctx) {
313
+ for (const line of [...ctx.debtLines, ...ctx.memLines]) {
314
+ parts.push(line);
315
+ }
316
+ }
317
+ if (parts.length > 0) {
318
+ console.log(
319
+ JSON.stringify({
320
+ hookSpecificOutput: {
321
+ hookEventName: "PreToolUse",
322
+ additionalContext: parts.join("\n"),
323
+ },
324
+ }),
325
+ );
326
+ }
327
+
328
+ // --- hint-fire log ---
329
+ const rel =
330
+ typeof filePath === "string"
331
+ ? (() => {
332
+ try {
333
+ const git = Bun.spawnSync(
334
+ ["git", "rev-parse", "--show-toplevel"],
335
+ { cwd, stdout: "pipe", stderr: "pipe" },
336
+ );
337
+ if (git.exitCode !== 0) return null;
338
+ const wt = realpathSync(git.stdout.toString().trim());
339
+ const abs = realpathSync(
340
+ filePath.startsWith("/") ? filePath : join(wt, filePath),
341
+ );
342
+ const r = relative(wt, abs).split("\\").join("/");
343
+ return r.startsWith("..") ? null : r;
344
+ } catch {
345
+ return null;
346
+ }
347
+ })()
348
+ : null;
349
+ const worktree = ctx?.worktree ?? null;
350
+ if (worktree) {
351
+ if (hint) {
352
+ recordHintFire({
353
+ ts: new Date().toISOString(),
354
+ worktree,
355
+ surface: "read",
356
+ file: rel,
357
+ count: 1,
358
+ });
359
+ }
360
+ if (ctx && ctx.debtIds.length > 0) {
361
+ recordHintFire({
362
+ ts: new Date().toISOString(),
363
+ worktree,
364
+ surface: "debt",
365
+ file: rel,
366
+ count: ctx.debtIds.length,
367
+ ids: ctx.debtIds,
368
+ });
369
+ }
370
+ if (ctx && ctx.memLines.length > 0) {
371
+ recordHintFire({
372
+ ts: new Date().toISOString(),
373
+ worktree,
374
+ surface: "mem",
375
+ file: rel,
376
+ count: ctx.memLines.length,
377
+ });
378
+ }
379
+ }
380
+ } catch {
381
+ // any failure = no hint; a hook must never block a read over a hint
382
+ }
383
+ }
@@ -0,0 +1,101 @@
1
+ // src/adapters/hooks/session-start.ts — SessionStart hook: emit mem kickoff
2
+ // as context.
3
+ //
4
+ // Split from src/hook.ts (PLAN-lib-layer chunk 3). sessionStartContext is the
5
+ // one copy shared by Claude/Codex CLI and the generated OpenCode plugin.
6
+
7
+ import { whereMemDir } from "../../memory.js";
8
+
9
+ /** Cap on injected context — kickoff is short, a broken repo's output is not. */
10
+ export const SESSION_START_MAX_CHARS = 4_000;
11
+
12
+ const TRUNCATED = "… truncated — run `fapony mem find <word>` for the rest";
13
+
14
+ /** Trim to whole lines, keeping the marker's line boundary intact. */
15
+ function headLines(text: string, max: number): string {
16
+ const cut = text.slice(0, max);
17
+ const lastNl = cut.lastIndexOf("\n");
18
+ return (lastNl > 0 ? cut.slice(0, lastNl) : cut).trimEnd();
19
+ }
20
+
21
+ /**
22
+ * Trim to whole lines within the cap, with an honest truncation marker.
23
+ *
24
+ * Kickoff's ordering is: ranked rows → "## next up" → "## recent". The
25
+ * most actionable content is at the top (bugs, diff-matched) and in "## next
26
+ * up". A plain head-cut drops the suggestions — so we try to keep "## next up"
27
+ * visible. When "## recent" exists, we cut it first; otherwise fall back to
28
+ * keeping "## next up" at the tail.
29
+ */
30
+ export function capContext(
31
+ text: string,
32
+ max = SESSION_START_MAX_CHARS,
33
+ ): string {
34
+ if (text.length <= max) return text;
35
+ const recentIdx = text.indexOf("\n## recent");
36
+ if (recentIdx > 0) {
37
+ const head = text.slice(0, recentIdx).trimEnd();
38
+ if (head.length <= max) return head;
39
+ const nextIdx = head.lastIndexOf("\n## next up");
40
+ if (nextIdx > 0) {
41
+ const beforeNext = head.slice(0, nextIdx).trimEnd();
42
+ const nextTail = head.slice(nextIdx).trimEnd();
43
+ if (nextTail.length < max / 2) {
44
+ return `${headLines(beforeNext, max - nextTail.length)}\n${TRUNCATED}\n${nextTail}`;
45
+ }
46
+ }
47
+ return `${headLines(head, max)}\n${TRUNCATED}`;
48
+ }
49
+ const at = text.lastIndexOf("\n## next up");
50
+ const tail = at > 0 ? text.slice(at).trimEnd() : "";
51
+ if (tail && tail.length < max / 2) {
52
+ return `${headLines(text, max - tail.length)}\n${TRUNCATED}\n${tail}`;
53
+ }
54
+ return `${headLines(text, max)}\n${TRUNCATED}`;
55
+ }
56
+
57
+ /**
58
+ * Kickoff output for `cwd`'s mem log, capped — or null when there is no log in
59
+ * scope, the command fails, or it prints nothing. The single implementation
60
+ * behind both cmdHookSessionStart (Claude/Codex) and the generated OpenCode
61
+ * plugin, so a pull of INSTALL_ROOT updates all three.
62
+ *
63
+ * `faponyTs` is where the CLI lives. The hook defaults to `Bun.main` (itself,
64
+ * when run as `bun <root>/fapony.ts hook-session-start`); the OpenCode plugin
65
+ * must pass its baked path because `Bun.main` inside OpenCode is OpenCode's own
66
+ * entry, not fapony's.
67
+ */
68
+ export function sessionStartContext(
69
+ cwd: string,
70
+ faponyTs: string = Bun.main,
71
+ ): string | null {
72
+ if (!whereMemDir(cwd).dir) return null;
73
+ const p = Bun.spawnSync([process.execPath, faponyTs, "mem", "kickoff"], {
74
+ cwd,
75
+ stdout: "pipe",
76
+ stderr: "pipe",
77
+ });
78
+ const out = p.stdout.toString().trim();
79
+ if (p.exitCode !== 0 || !out) return null;
80
+ return capContext(out);
81
+ }
82
+
83
+ /** SessionStart hook: emit sessionStartContext as Claude/Codex JSON. */
84
+ export async function cmdHookSessionStart(): Promise<void> {
85
+ try {
86
+ const raw = JSON.parse(await Bun.stdin.text()) as { cwd?: string };
87
+ const cwd = raw.cwd ?? process.cwd();
88
+ const ctx = sessionStartContext(cwd);
89
+ if (!ctx) return;
90
+ console.log(
91
+ JSON.stringify({
92
+ hookSpecificOutput: {
93
+ hookEventName: "SessionStart",
94
+ additionalContext: ctx,
95
+ },
96
+ }),
97
+ );
98
+ } catch {
99
+ // any failure = no context, never a broken session start
100
+ }
101
+ }