@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +220 -174
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
@@ -62,7 +62,7 @@ export declare function attributedSetLostPids(prev: Set<number>, next: Set<numbe
62
62
  export declare function filterCachedUnattributed(sessions: ActiveSession[], attributed: Set<number>, alive: (pid: number, startedAtMs?: number) => boolean): ActiveSession[];
63
63
  type ActiveContext = 'terminal' | 'teams' | 'cloud' | 'headless';
64
64
  /** The SessionMeta fields the live-row backfill reads — the enrichment a running process cannot report. */
65
- export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName'>;
65
+ export type BackfillMeta = Pick<SessionMeta, 'version' | 'timestamp' | 'label' | 'ticketId' | 'prUrl' | 'prNumber' | 'origin' | 'routineName' | 'harness'>;
66
66
  export declare function backfillActiveRowsFromMeta(sessions: ActiveSession[], metaById: Map<string, BackfillMeta>): void;
67
67
  export declare function backfillActiveRowsFromIndex(sessions: ActiveSession[]): void;
68
68
  export declare function isRunningLiveSession(s: ActiveSession): boolean;
@@ -112,6 +112,13 @@ export type RecapSource = 'label' | 'last' | 'prompt';
112
112
  export interface ActiveSession {
113
113
  context: ActiveContext;
114
114
  kind: string;
115
+ /**
116
+ * Custom harness / profile name when this live process was launched via
117
+ * `agents run <profile>` (e.g. `deepseek`). `kind` stays the HOST process
118
+ * (claude) so transcript lookup and live-signal parsers keep working.
119
+ * `sessions --active` displays this when set (PHNX-2935).
120
+ */
121
+ harness?: string;
115
122
  /** Specific host app — 'code', 'cursor', 'codium', 'iterm', 'terminal', 'warp', 'tmux', etc. */
116
123
  host?: string;
117
124
  pid?: number;
@@ -652,6 +659,8 @@ export declare function listUnattributedActive(attributed: Set<number>): Promise
652
659
  /** One tmux pane's resolved agent identity for the authoritative source. */
653
660
  interface PaneIdentity {
654
661
  agent: string;
662
+ /** Custom harness/profile name from the launch registry, when set. */
663
+ harness?: string;
655
664
  /** Exact session id when resolvable (launch registry, or the hook join). */
656
665
  sessionId?: string;
657
666
  /** The agent's OS pid from the launch registry (may differ from `pane_pid`). */
@@ -45,6 +45,7 @@ import { classifyHostLink, HOST_HEARTBEAT_STALE_MS } from './host-link.js';
45
45
  import { mapBounded } from '../concurrency.js';
46
46
  import { linearIssueUrl } from './linear.js';
47
47
  import { viewingInLabel } from './viewing-in.js';
48
+ import { claudeProjectDirName } from '../project-key.js';
48
49
  const execFileAsync = promisify(execFile);
49
50
  /**
50
51
  * The owner (actor id) to show for a session in `--active`. Prefers the actor
@@ -166,6 +167,8 @@ export function backfillActiveRowsFromMeta(sessions, metaById) {
166
167
  s.origin = m.origin;
167
168
  if (!s.routineName && m.routineName)
168
169
  s.routineName = m.routineName;
170
+ if (!s.harness && m.harness)
171
+ s.harness = m.harness;
169
172
  }
170
173
  }
171
174
  function loadBackfillMetaFor(sessions) {
@@ -528,10 +531,6 @@ function readLiveTerminals() {
528
531
  }
529
532
  return Array.from(merged.values());
530
533
  }
531
- /** Convert an absolute cwd to the Claude-project folder name (slashes and dots → dashes). */
532
- function claudeProjectDirName(cwd) {
533
- return cwd.replace(/[/.]/g, '-');
534
- }
535
534
  /**
536
535
  * Process-local memo for Claude transcript path resolution. Each active-session
537
536
  * poll re-walks every Claude version-home `projects/` tree for every live pid
@@ -1010,6 +1009,7 @@ export async function listTeamsActive(opts = {}) {
1010
1009
  return applyState({
1011
1010
  context: 'teams',
1012
1011
  kind: a.agentType,
1012
+ harness: a.profileName ?? undefined,
1013
1013
  pid: a.pid ?? undefined,
1014
1014
  sessionId: resolvedId,
1015
1015
  machine: offloaded ? execHost : undefined,
@@ -1071,6 +1071,7 @@ export async function listTerminalsActive() {
1071
1071
  return applyState({
1072
1072
  context: 'terminal',
1073
1073
  kind: t.kind,
1074
+ harness: pidEntry?.harness,
1074
1075
  host: detectHost(t.pid, procByPid),
1075
1076
  tty: procByPid.get(t.pid)?.tty,
1076
1077
  pid: t.pid,
@@ -1557,6 +1558,7 @@ async function listUnattributedActiveLive(attributed) {
1557
1558
  out.push(applyState({
1558
1559
  context,
1559
1560
  kind,
1561
+ harness: entry?.harness,
1560
1562
  host,
1561
1563
  tty: procByPid.get(pid)?.tty,
1562
1564
  pid,
@@ -1621,7 +1623,7 @@ export function resolvePaneIdentity(pane, sessName, meta, liveEntry, getHookInde
1621
1623
  terminalId: liveEntry.terminalId,
1622
1624
  })?.session_id
1623
1625
  ?? nameSessionId;
1624
- return { agent: liveEntry.agent, sessionId, pid: liveEntry.pid };
1626
+ return { agent: liveEntry.agent, harness: liveEntry.harness, sessionId, pid: liveEntry.pid };
1625
1627
  }
1626
1628
  // No live-registry entry. Session-meta labels are the wrapped-origin fallback;
1627
1629
  // prefer them, then fall back to the name so a pane with neither a registry
@@ -1771,6 +1773,7 @@ export async function listTmuxAgentSessions() {
1771
1773
  out.push(applyState({
1772
1774
  context: 'terminal',
1773
1775
  kind: id.agent,
1776
+ harness: id.harness,
1774
1777
  host: 'tmux',
1775
1778
  pid,
1776
1779
  sessionId: id.sessionId ?? sessionIdFromFile(sessionFile),
@@ -7,6 +7,13 @@ export interface SessionActorRecord {
7
7
  initiatedBy?: 'human' | 'agent';
8
8
  /** Effective permissions mode used by the launcher. */
9
9
  mode?: SessionRunMode;
10
+ /**
11
+ * Custom harness / profile name when launched via `agents run <profile>`
12
+ * (e.g. `deepseek`). Joined onto the session index at scan time so a
13
+ * durable listing can distinguish the profile from its host agent
14
+ * (PHNX-2935).
15
+ */
16
+ harness?: string;
10
17
  /** Stable wrapper names that resolve to this native session id. */
11
18
  aliases?: string[];
12
19
  startedAtMs: number;
@@ -40,6 +40,7 @@ function isSafeAlias(alias) {
40
40
  function hasRecordData(record) {
41
41
  return typeof record.actor === 'string'
42
42
  || typeof record.mode === 'string'
43
+ || typeof record.harness === 'string'
43
44
  || (Array.isArray(record.aliases) && record.aliases.some(alias => typeof alias === 'string'));
44
45
  }
45
46
  function normalizedAliases(aliases) {
@@ -83,6 +84,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
83
84
  actor: previous?.actor,
84
85
  initiatedBy: previous?.initiatedBy,
85
86
  mode: previous?.mode,
87
+ harness: previous?.harness,
86
88
  aliases: normalizedAliases([...(previous?.aliases ?? []), alias]),
87
89
  startedAtMs: previous?.startedAtMs ?? Date.now(),
88
90
  });
@@ -12,21 +12,41 @@ import { type IndexedToolCall } from './tool-calls.js';
12
12
  /** Current schema version; bumped when migrations are added. Exported so tests
13
13
  * assert against the constant instead of hardcoding a number that every bump
14
14
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
15
- export declare const SCHEMA_VERSION = 40;
15
+ export declare const SCHEMA_VERSION = 42;
16
+ /**
17
+ * Bump to force the content extractor (assistant-answer text, alongside the
18
+ * user-prompt text every harness already accumulates) to re-derive on every
19
+ * session's next scan. Unlike RESOURCE_INDEX_VERSION this is read by the
20
+ * change-detector itself (`filterChangedEntries`, discover.ts) via
21
+ * `scan_ledger.extractor_version` — a stored version below this one is treated
22
+ * as "changed" even when the file's (mtime, size) are unchanged, so bumping it
23
+ * here backfills every existing session's assistant text on its next scan
24
+ * without a `DELETE FROM scan_ledger` (which would also throw away the
25
+ * resumable parser_state/content_text for Claude/Codex).
26
+ */
27
+ export declare const CONTENT_INDEX_VERSION = 1;
16
28
  /**
17
29
  * Bumping this invalidates every cached facet row without touching the schema
18
30
  * version, so a change to the extraction logic (a new metric, a corrected bucket)
19
31
  * re-derives on the next `agents insights` instead of silently reporting stale
20
32
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
21
33
  */
22
- /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
23
- export declare const INSIGHTS_EXTRACTOR_VERSION = 6;
34
+ /** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
35
+ export declare const INSIGHTS_EXTRACTOR_VERSION = 7;
24
36
  export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
25
37
  /** File stat snapshot used to detect changes between scan runs. */
26
38
  export interface ScanStamp {
27
39
  fileMtimeMs: number;
28
40
  fileSize: number;
29
41
  scannedAt?: number;
42
+ /**
43
+ * `scan_ledger.extractor_version` as of the last scan, when read from the
44
+ * ledger (undefined for a freshly-computed stamp that hasn't been persisted
45
+ * yet). Compared against {@link CONTENT_INDEX_VERSION} by
46
+ * `filterChangedEntries` (discover.ts) to force a re-extract independent of
47
+ * (mtime, size).
48
+ */
49
+ extractorVersion?: number | null;
30
50
  }
31
51
  /** Filter and pagination options for querying the sessions table. */
32
52
  export interface QueryOptions {
@@ -163,6 +183,10 @@ interface ParserStateRow {
163
183
  fileMtimeMs: number;
164
184
  fileSize: number;
165
185
  scannedAt: number;
186
+ /** See {@link ScanStamp.extractorVersion}. A mismatch vs CONTENT_INDEX_VERSION
187
+ * means this continuation predates the current content extractor and MUST
188
+ * be treated as absent (forcing a full re-parse) rather than resumed from. */
189
+ extractorVersion: number | null;
166
190
  }
167
191
  /**
168
192
  * Bulk-load the resumable-parse continuation (parser_state + content_text) plus
@@ -208,11 +232,15 @@ export declare function recordDirScans(entries: Array<{
208
232
  * Upsert a session row and replace its FTS5 content in a single transaction.
209
233
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
210
234
  */
211
- export declare function upsertSession(meta: SessionMeta, content: string, scan?: ScanStamp): void;
235
+ export declare function upsertSession(meta: SessionMeta, content: string, scan?: ScanStamp, assistantContent?: string): void;
212
236
  /** Batch-upsert sessions with their FTS5 content and scan stamps in a single transaction. */
213
237
  export declare function upsertSessionsBatch(entries: Array<{
214
238
  meta: SessionMeta;
215
239
  content: string;
240
+ /** Assistant-answer text, accumulated the same way as `content` (the
241
+ * user-prompt text) but stored in session_text's own `assistant` column
242
+ * with a lower BM25 weight — see BM25_WEIGHTS. */
243
+ assistantContent?: string;
216
244
  scan?: ScanStamp;
217
245
  parserState?: string;
218
246
  contentText?: string;
@@ -584,6 +612,13 @@ interface FtsHit {
584
612
  sessionId: string;
585
613
  score: number;
586
614
  matchedTerms: string[];
615
+ /**
616
+ * A short bm25 `snippet()` excerpt around the best-matching column (label,
617
+ * topic, project, user content, or assistant answer), with the matched
618
+ * term(s) wrapped in `**…**`. Absent for a handle/label-tier hit (tiers 1-3
619
+ * below), which has no excerpt to show — the label itself IS the match.
620
+ */
621
+ snippet?: string;
587
622
  }
588
623
  /**
589
624
  * Escape a raw user query into a safe FTS5 MATCH expression.
@@ -27,7 +27,19 @@ const DB_PATH = getSessionsDbPath();
27
27
  /** Current schema version; bumped when migrations are added. Exported so tests
28
28
  * assert against the constant instead of hardcoding a number that every bump
29
29
  * then has to chase (docs/sessions.md calls the constant the source of truth). */
30
- export const SCHEMA_VERSION = 40;
30
+ export const SCHEMA_VERSION = 42;
31
+ /**
32
+ * Bump to force the content extractor (assistant-answer text, alongside the
33
+ * user-prompt text every harness already accumulates) to re-derive on every
34
+ * session's next scan. Unlike RESOURCE_INDEX_VERSION this is read by the
35
+ * change-detector itself (`filterChangedEntries`, discover.ts) via
36
+ * `scan_ledger.extractor_version` — a stored version below this one is treated
37
+ * as "changed" even when the file's (mtime, size) are unchanged, so bumping it
38
+ * here backfills every existing session's assistant text on its next scan
39
+ * without a `DELETE FROM scan_ledger` (which would also throw away the
40
+ * resumable parser_state/content_text for Claude/Codex).
41
+ */
42
+ export const CONTENT_INDEX_VERSION = 1;
31
43
  /**
32
44
  * Bump to force `agents sessions backfill resources` to re-derive every
33
45
  * session's skill/slash-command tallies on its next run (resource_scan_ledger
@@ -54,16 +66,22 @@ function canonicalLedgerKey(filePath) {
54
66
  return filePath;
55
67
  }
56
68
  }
57
- // BM25 column weights for session_text: label > topic > project > content.
58
- // Higher weights make matches in that column rank higher.
59
- /** BM25 column weights for FTS5: label > topic > project > content. */
60
- const BM25_WEIGHTS = [5.0, 2.0, 1.5, 1.0];
69
+ // BM25 column weights for session_text: label > topic > project > content >
70
+ // assistant. Higher weights make matches in that column rank higher.
71
+ // `assistant` (the agent's own answers) is weighted BELOW `content` (the
72
+ // user's prompts): a user's own words are a stronger signal of "this is the
73
+ // session I meant" than the agent echoing/paraphrasing them back, so an
74
+ // assistant-only match still surfaces but ranks behind an equivalent
75
+ // user-prompt match.
76
+ /** BM25 column weights for FTS5: label > topic > project > content > assistant. */
77
+ const BM25_WEIGHTS = [5.0, 2.0, 1.5, 1.0, 0.5];
61
78
  /** DDL for the sessions database (tables, indexes, FTS5 virtual table). */
62
79
  const SCHEMA = `
63
80
  CREATE TABLE IF NOT EXISTS sessions (
64
81
  id TEXT PRIMARY KEY,
65
82
  short_id TEXT NOT NULL,
66
83
  agent TEXT NOT NULL,
84
+ harness TEXT,
67
85
  origin TEXT DEFAULT 'cli',
68
86
  routine_name TEXT,
69
87
  routine_run_id TEXT,
@@ -134,6 +152,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
134
152
  topic,
135
153
  project,
136
154
  content,
155
+ assistant,
137
156
  tokenize = 'unicode61 remove_diacritics 2'
138
157
  );
139
158
 
@@ -157,7 +176,13 @@ CREATE TABLE IF NOT EXISTS scan_ledger (
157
176
  -- doc so detectTicket + FTS can rebuild on append without re-reading the file.
158
177
  -- Written by B-2; B-1 only defines + round-trips them.
159
178
  parser_state TEXT,
160
- content_text TEXT
179
+ content_text TEXT,
180
+ -- CONTENT_INDEX_VERSION this row's session_text content was last extracted
181
+ -- at. NULL (a pre-v42 row) never equals the current constant, so the
182
+ -- change-detector (filterChangedEntries) treats it as changed even when
183
+ -- (mtime, size) match — the lever that backfills assistant text into
184
+ -- existing sessions without wiping scan_ledger outright.
185
+ extractor_version INTEGER
161
186
  );
162
187
 
163
188
  -- Tracks the mtime + entry-count of every LEAF directory that directly holds
@@ -408,8 +433,8 @@ CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(st
408
433
  * re-derives on the next `agents insights` instead of silently reporting stale
409
434
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
410
435
  */
411
- /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
412
- export const INSIGHTS_EXTRACTOR_VERSION = 6;
436
+ /** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
437
+ export const INSIGHTS_EXTRACTOR_VERSION = 7;
413
438
  export const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
414
439
  const PREVIEW_EXTRACTOR_VERSION = 1;
415
440
  let dbInstance = null;
@@ -1072,6 +1097,60 @@ function migrateSchema(db, fromVersion) {
1072
1097
  db.exec(`ALTER TABLE sessions ADD COLUMN background_shell_count INTEGER`);
1073
1098
  }
1074
1099
  }
1100
+ if (fromVersion < 41) {
1101
+ // v40 -> v41: persist the custom-harness / profile name a run was launched
1102
+ // as (PHNX-2935). Transcript discovery still keys `agent` on the HOST
1103
+ // (the file lives under the host's session dir), so without this column
1104
+ // `agents sessions` cannot tell `agents run deepseek` from a native claude
1105
+ // run. Additive, no ledger flush — the name is launch metadata joined from
1106
+ // the actor sidecar, not parsed from the transcript. Pre-upgrade rows stay
1107
+ // NULL (native / unknown) until a sidecar-backed rescan fills them.
1108
+ const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
1109
+ if (!cols.has('harness'))
1110
+ db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
1111
+ }
1112
+ if (fromVersion < 42) {
1113
+ // v41 -> v42: index the agent's ANSWERS, not just the user's prompts.
1114
+ // session_text gains an `assistant` column (own FTS5 column, own lower
1115
+ // BM25 weight — see BM25_WEIGHTS) and scan_ledger gains `extractor_version`
1116
+ // so the change-detector can force a re-extract independent of
1117
+ // (mtime, size). FTS5 can't ALTER a virtual table's column set, but unlike
1118
+ // v1->v2 (which dropped the table and forced a blind full rescan of
1119
+ // everything), the existing label/topic/project/content in every row is
1120
+ // still exactly right — only `assistant` is missing. Rename the old table
1121
+ // out of the way, create the new 6-column one, and copy the old rows back
1122
+ // in (assistant defaults to '' until the extractor_version lever backfills
1123
+ // it on that row's next scan) — search over label/topic/project/content
1124
+ // never blacks out for the transient window it takes existing sessions to
1125
+ // get rescanned, which can be a long time for a session whose transcript
1126
+ // is otherwise cold.
1127
+ db.exec(`ALTER TABLE session_text RENAME TO session_text_v41`);
1128
+ db.exec(`
1129
+ CREATE VIRTUAL TABLE session_text USING fts5(
1130
+ session_id UNINDEXED,
1131
+ label,
1132
+ topic,
1133
+ project,
1134
+ content,
1135
+ assistant,
1136
+ tokenize = 'unicode61 remove_diacritics 2'
1137
+ );
1138
+ `);
1139
+ db.exec(`
1140
+ INSERT INTO session_text (session_id, label, topic, project, content, assistant)
1141
+ SELECT session_id, label, topic, project, content, '' FROM session_text_v41
1142
+ `);
1143
+ db.exec(`DROP TABLE session_text_v41`);
1144
+ const ledgerCols = db.prepare(`PRAGMA table_info(scan_ledger)`).all();
1145
+ if (!ledgerCols.some(c => c.name === 'extractor_version')) {
1146
+ db.exec(`ALTER TABLE scan_ledger ADD COLUMN extractor_version INTEGER`);
1147
+ }
1148
+ // No `DELETE FROM scan_ledger` — the new column is NULL on every existing
1149
+ // row, which already never equals CONTENT_INDEX_VERSION, so every session
1150
+ // re-extracts on its next scan while parser_state/content_text (the
1151
+ // Claude/Codex resumable continuation) stay intact for rows that don't
1152
+ // need a full reparse for any OTHER reason.
1153
+ }
1075
1154
  }
1076
1155
  /**
1077
1156
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -1194,6 +1273,21 @@ export function getDB() {
1194
1273
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_machine_ts ON sessions(machine, timestamp DESC)`);
1195
1274
  db.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_agent_ts ON sessions(agent, timestamp DESC)`);
1196
1275
  }
1276
+ // harness column: only after the column is guaranteed present.
1277
+ // Fresh SCHEMA (v41) includes the column; older DBs get it from migrate v41.
1278
+ // schema_version can be stamped at SCHEMA_VERSION without the column existing
1279
+ // — getDB writes the marker for any DB whose meta has no row (a hand-built
1280
+ // or partially-created index), and migrateSchema never runs in that path
1281
+ // (`currentVersion === undefined`). If a partial upgrade left schema_version
1282
+ // ahead of the column, repair here so the next upsertSession INSERT naming
1283
+ // `harness` does not throw (PHNX-2935). Same shape as the `machine` repair
1284
+ // above, for the same reason.
1285
+ {
1286
+ const cols = db.prepare(`PRAGMA table_info(sessions)`).all();
1287
+ if (!cols.some((c) => c.name === 'harness')) {
1288
+ db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
1289
+ }
1290
+ }
1197
1291
  // One-shot cleanup of the pre-SQLite JSONL indexes. Safe — nothing reads
1198
1292
  // them anymore. Guarded by a meta flag so we only try once.
1199
1293
  const cleaned = db.prepare(`SELECT value FROM meta WHERE key = 'legacy_indexes_removed'`).get();
@@ -1427,13 +1521,18 @@ export function getScanStampsForPaths(filePaths) {
1427
1521
  const placeholders = chunk.map(() => '?').join(',');
1428
1522
  const rows = db
1429
1523
  .prepare(`
1430
- SELECT file_path, file_mtime_ms, file_size, scanned_at
1524
+ SELECT file_path, file_mtime_ms, file_size, scanned_at, extractor_version
1431
1525
  FROM scan_ledger
1432
1526
  WHERE file_path IN (${placeholders})
1433
1527
  `)
1434
1528
  .all(...chunk);
1435
1529
  for (const row of rows) {
1436
- const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at };
1530
+ const stamp = {
1531
+ fileMtimeMs: row.file_mtime_ms,
1532
+ fileSize: row.file_size,
1533
+ scannedAt: row.scanned_at,
1534
+ extractorVersion: row.extractor_version,
1535
+ };
1437
1536
  for (const original of canonicalToOriginals.get(row.file_path) || []) {
1438
1537
  result.set(original, stamp);
1439
1538
  }
@@ -1470,7 +1569,7 @@ export function getParserStatesForPaths(filePaths) {
1470
1569
  const placeholders = chunk.map(() => '?').join(',');
1471
1570
  const rows = db
1472
1571
  .prepare(`
1473
- SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text
1572
+ SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text, extractor_version
1474
1573
  FROM scan_ledger
1475
1574
  WHERE file_path IN (${placeholders})
1476
1575
  `)
@@ -1482,6 +1581,7 @@ export function getParserStatesForPaths(filePaths) {
1482
1581
  fileMtimeMs: row.file_mtime_ms,
1483
1582
  fileSize: row.file_size,
1484
1583
  scannedAt: row.scanned_at,
1584
+ extractorVersion: row.extractor_version,
1485
1585
  };
1486
1586
  for (const original of canonicalToOriginals.get(row.file_path) || []) {
1487
1587
  result.set(original, state);
@@ -1499,17 +1599,23 @@ export function recordScans(entries) {
1499
1599
  return;
1500
1600
  const db = getDB();
1501
1601
  const stmt = db.prepare(`
1502
- INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at)
1503
- VALUES (?, ?, ?, ?)
1602
+ INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, extractor_version)
1603
+ VALUES (?, ?, ?, ?, ?)
1504
1604
  ON CONFLICT(file_path) DO UPDATE SET
1505
1605
  file_mtime_ms = excluded.file_mtime_ms,
1506
1606
  file_size = excluded.file_size,
1507
- scanned_at = excluded.scanned_at
1607
+ scanned_at = excluded.scanned_at,
1608
+ extractor_version = excluded.extractor_version
1508
1609
  `);
1509
1610
  const now = Date.now();
1510
1611
  const txn = db.transaction((items) => {
1511
1612
  for (const { filePath, scan } of items) {
1512
- stmt.run(canonicalLedgerKey(filePath), scan.fileMtimeMs, scan.fileSize, now);
1613
+ // Stamped at the CURRENT content extractor version even for a file that
1614
+ // yielded no session (malformed / no id): we ran today's extractor over
1615
+ // it and it produced nothing, so it is current, not stale — otherwise a
1616
+ // permanently-unparseable file would re-trigger "changed" on every scan
1617
+ // forever once CONTENT_INDEX_VERSION is bumped.
1618
+ stmt.run(canonicalLedgerKey(filePath), scan.fileMtimeMs, scan.fileSize, now, CONTENT_INDEX_VERSION);
1513
1619
  }
1514
1620
  });
1515
1621
  txn(entries);
@@ -1583,7 +1689,7 @@ export function recordDirScans(entries) {
1583
1689
  }
1584
1690
  const upsertSessionStmt = (db) => db.prepare(`
1585
1691
  INSERT INTO sessions (
1586
- id, short_id, agent, origin, routine_name, routine_run_id,
1692
+ id, short_id, agent, harness, origin, routine_name, routine_run_id,
1587
1693
  version, account, account_key, account_org, mode, timestamp, last_activity,
1588
1694
  project, cwd, git_branch, topic, label, message_count, token_count,
1589
1695
  output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
@@ -1594,7 +1700,7 @@ const upsertSessionStmt = (db) => db.prepare(`
1594
1700
  recent_directories_touched, linear_project, linear_project_url, machine,
1595
1701
  actor, initiated_by, used_browser, used_computer
1596
1702
  ) VALUES (
1597
- @id, @short_id, @agent, @origin, @routine_name, @routine_run_id,
1703
+ @id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
1598
1704
  @version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
1599
1705
  @project, @cwd, @git_branch, @topic, @label, @message_count, @token_count,
1600
1706
  @output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
@@ -1608,6 +1714,11 @@ const upsertSessionStmt = (db) => db.prepare(`
1608
1714
  ON CONFLICT(id) DO UPDATE SET
1609
1715
  short_id = excluded.short_id,
1610
1716
  agent = excluded.agent,
1717
+ -- Custom harness/profile name is launch metadata, not transcript-derived.
1718
+ -- COALESCE(existing, incoming) keeps a stored stamp on rescan (the scanner
1719
+ -- carries none) and backfills a NULL-first row once the sidecar lands —
1720
+ -- the same write-once pattern as actor/initiated_by (PHNX-2935).
1721
+ harness = COALESCE(sessions.harness, excluded.harness),
1611
1722
  origin = excluded.origin,
1612
1723
  routine_name = excluded.routine_name,
1613
1724
  routine_run_id = excluded.routine_run_id,
@@ -1834,7 +1945,7 @@ function enrichCachedSessionMeta(meta) {
1834
1945
  }
1835
1946
  }
1836
1947
  const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
1837
- const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
1948
+ const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content, assistant) VALUES (?, ?, ?, ?, ?, ?)`);
1838
1949
  // Read back the label the upsert actually stored (which may be the preserved
1839
1950
  // one, not the incoming blank) so the FTS label column stays consistent with
1840
1951
  // sessions.label after a bare rescan.
@@ -1871,7 +1982,7 @@ function resolveMachine(meta) {
1871
1982
  * Upsert a session row and replace its FTS5 content in a single transaction.
1872
1983
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
1873
1984
  */
1874
- export function upsertSession(meta, content, scan) {
1985
+ export function upsertSession(meta, content, scan, assistantContent = '') {
1875
1986
  meta = enrichCachedSessionMeta(meta);
1876
1987
  // Join the durable sessionId -> actor sidecar (RUSH-2019) when the caller
1877
1988
  // didn't already carry an actor, so a scanned transcript still attributes to a
@@ -1886,6 +1997,7 @@ export function upsertSession(meta, content, scan) {
1886
1997
  id: meta.id,
1887
1998
  short_id: meta.shortId,
1888
1999
  agent: meta.agent,
2000
+ harness: meta.harness ?? actorRec?.harness ?? null,
1889
2001
  origin: meta.origin ?? 'cli',
1890
2002
  routine_name: meta.routineName ?? null,
1891
2003
  routine_run_id: meta.routineRunId ?? null,
@@ -1941,7 +2053,7 @@ export function upsertSession(meta, content, scan) {
1941
2053
  insText.run(meta.id,
1942
2054
  // Use the label the upsert actually stored (preserve-non-empty rule),
1943
2055
  // not the raw incoming one, so FTS label ranking survives a bare rescan.
1944
- storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
2056
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
1945
2057
  });
1946
2058
  txn();
1947
2059
  }
@@ -1959,19 +2071,23 @@ export function upsertSessionsBatch(entries) {
1959
2071
  // stored owner on rescan.
1960
2072
  const actorIndex = loadSessionActorIndex();
1961
2073
  // Persist the Claude resumable-parse continuation (parser_state + content_text)
1962
- // alongside the stamp. On a full/incremental Claude parse the caller passes the
2074
+ // alongside the stamp, plus the CURRENT content extractor version — a caller
2075
+ // that reached this batch write ran today's extractor over the file, so the
2076
+ // ledger row is current regardless of which branch (full/incremental)
2077
+ // produced it. On a full/incremental Claude parse the caller passes the
1963
2078
  // serialized newState + accumulated user doc so the NEXT scan can resume from
1964
2079
  // the persisted offset (B-2). Other scanners pass neither, leaving both columns
1965
2080
  // NULL exactly as before — their ledger rows are unaffected.
1966
2081
  const ledger = db.prepare(`
1967
- INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text)
1968
- VALUES (?, ?, ?, ?, ?, ?)
2082
+ INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text, extractor_version)
2083
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1969
2084
  ON CONFLICT(file_path) DO UPDATE SET
1970
2085
  file_mtime_ms = excluded.file_mtime_ms,
1971
2086
  file_size = excluded.file_size,
1972
2087
  scanned_at = excluded.scanned_at,
1973
2088
  parser_state = excluded.parser_state,
1974
- content_text = excluded.content_text
2089
+ content_text = excluded.content_text,
2090
+ extractor_version = excluded.extractor_version
1975
2091
  `);
1976
2092
  // Build a lookup from canonical file path → entry, used inside the write
1977
2093
  // transaction to re-check the ledger AFTER acquiring the lock. When a
@@ -2042,17 +2158,26 @@ export function upsertSessionsBatch(entries) {
2042
2158
  const chunk = paths.slice(i, i + CHUNK);
2043
2159
  const phs = chunk.map(() => '?').join(',');
2044
2160
  const rows = db
2045
- .prepare(`SELECT file_path, file_mtime_ms, file_size FROM scan_ledger WHERE file_path IN (${phs})`)
2161
+ .prepare(`SELECT file_path, file_mtime_ms, file_size, extractor_version FROM scan_ledger WHERE file_path IN (${phs})`)
2046
2162
  .all(...chunk);
2047
2163
  for (const row of rows) {
2048
2164
  const entry = byPath.get(row.file_path);
2049
- if (entry && row.file_mtime_ms === entry.scan.fileMtimeMs && row.file_size === entry.scan.fileSize) {
2165
+ // A concurrent writer's row only makes THIS entry redundant when it is
2166
+ // current at CONTENT_INDEX_VERSION too — otherwise a (mtime, size) match
2167
+ // alone would make the version lever a no-op: the very reason this batch
2168
+ // was scheduled (a stale extractor_version) would be silently skipped as
2169
+ // "someone else already indexed it", when what they indexed predates the
2170
+ // current extractor.
2171
+ if (entry &&
2172
+ row.file_mtime_ms === entry.scan.fileMtimeMs &&
2173
+ row.file_size === entry.scan.fileSize &&
2174
+ row.extractor_version === CONTENT_INDEX_VERSION) {
2050
2175
  alreadyIndexed.add(entry.meta.id);
2051
2176
  }
2052
2177
  }
2053
2178
  }
2054
2179
  for (const entry of items) {
2055
- const { meta, content, scan, parserState, contentText } = entry;
2180
+ const { meta, content, assistantContent, scan, parserState, contentText } = entry;
2056
2181
  if (alreadyIndexed.has(meta.id))
2057
2182
  continue;
2058
2183
  // Per-row guard: one malformed session (e.g. a required field that resolves to
@@ -2081,6 +2206,7 @@ export function upsertSessionsBatch(entries) {
2081
2206
  id: meta.id,
2082
2207
  short_id: meta.shortId,
2083
2208
  agent: meta.agent,
2209
+ harness: meta.harness ?? actorIndex.get(meta.id)?.harness ?? null,
2084
2210
  origin: meta.origin ?? 'cli',
2085
2211
  routine_name: meta.routineName ?? null,
2086
2212
  routine_run_id: meta.routineRunId ?? null,
@@ -2135,9 +2261,9 @@ export function upsertSessionsBatch(entries) {
2135
2261
  insText.run(meta.id,
2136
2262
  // Mirror upsertSession: index the label the upsert actually stored
2137
2263
  // (preserve-non-empty rule), not the raw incoming one.
2138
- storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
2264
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
2139
2265
  if (scan && meta.filePath) {
2140
- ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null);
2266
+ ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null, CONTENT_INDEX_VERSION);
2141
2267
  }
2142
2268
  writtenEntries.push(entry);
2143
2269
  }
@@ -2309,6 +2435,7 @@ function rowToMeta(row) {
2309
2435
  id: row.id,
2310
2436
  shortId: row.short_id,
2311
2437
  agent: row.agent,
2438
+ harness: row.harness ?? undefined,
2312
2439
  origin: (row.origin === 'routine' ? 'routine' : 'cli'),
2313
2440
  routineName: row.routine_name ?? undefined,
2314
2441
  routineRunId: row.routine_run_id ?? undefined,
@@ -3474,11 +3601,16 @@ export function ftsSearch(input, limit = 200) {
3474
3601
  return hits.slice(0, limit);
3475
3602
  }
3476
3603
  // Tier 4: FTS5 content match, skipping anything already surfaced via label.
3604
+ // `snippet(session_text, -1, ...)` lets FTS5 pick the best-matching column
3605
+ // itself (label/topic/project/content/assistant) rather than us guessing —
3606
+ // a query that only matched in `assistant` (an agent-only answer) still gets
3607
+ // an excerpt from the right column instead of an empty content snippet.
3477
3608
  if (expr) {
3478
3609
  try {
3479
3610
  const rows = db
3480
3611
  .prepare(`
3481
- SELECT session_id, bm25(session_text, ${BM25_WEIGHTS.join(', ')}) AS rank
3612
+ SELECT session_id, bm25(session_text, ${BM25_WEIGHTS.join(', ')}) AS rank,
3613
+ snippet(session_text, -1, '**', '**', '…', 12) AS snip
3482
3614
  FROM session_text
3483
3615
  WHERE session_text MATCH ?
3484
3616
  ORDER BY rank ASC
@@ -3488,7 +3620,12 @@ export function ftsSearch(input, limit = 200) {
3488
3620
  for (const r of rows) {
3489
3621
  if (seen.has(r.session_id))
3490
3622
  continue;
3491
- hits.push({ sessionId: r.session_id, score: -r.rank, matchedTerms: terms });
3623
+ hits.push({
3624
+ sessionId: r.session_id,
3625
+ score: -r.rank,
3626
+ matchedTerms: terms,
3627
+ snippet: r.snip?.trim() || undefined,
3628
+ });
3492
3629
  seen.add(r.session_id);
3493
3630
  }
3494
3631
  }