@phnx-labs/agents-cli 1.22.53 → 1.22.55

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 (166) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +59 -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 +219 -173
  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 +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/routines.js +31 -2
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions.d.ts +20 -12
  27. package/dist/commands/sessions.js +64 -20
  28. package/dist/commands/setup-accounts.d.ts +8 -0
  29. package/dist/commands/setup-accounts.js +47 -0
  30. package/dist/commands/setup.d.ts +1 -1
  31. package/dist/commands/setup.js +11 -2
  32. package/dist/commands/share.d.ts +79 -3
  33. package/dist/commands/share.js +347 -18
  34. package/dist/commands/ssh.d.ts +7 -0
  35. package/dist/commands/ssh.js +18 -2
  36. package/dist/commands/status.js +14 -0
  37. package/dist/commands/view.d.ts +11 -1
  38. package/dist/commands/view.js +35 -7
  39. package/dist/lib/account-registry.js +15 -3
  40. package/dist/lib/accounting/rotate.d.ts +20 -6
  41. package/dist/lib/accounting/rotate.js +38 -7
  42. package/dist/lib/accounting/usage.d.ts +68 -1
  43. package/dist/lib/accounting/usage.js +116 -10
  44. package/dist/lib/agent-spec/agents.d.ts +5 -2
  45. package/dist/lib/agent-spec/agents.js +25 -7
  46. package/dist/lib/analytics/mix-commands.js +12 -6
  47. package/dist/lib/auth-mint.d.ts +150 -0
  48. package/dist/lib/auth-mint.js +434 -0
  49. package/dist/lib/browser/cdp.d.ts +1 -1
  50. package/dist/lib/browser/cdp.js +1 -1
  51. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  52. package/dist/lib/browser/ffmpeg.js +184 -0
  53. package/dist/lib/browser/remote-control.d.ts +9 -7
  54. package/dist/lib/browser/remote-control.js +9 -7
  55. package/dist/lib/browser/service.js +119 -25
  56. package/dist/lib/claude-account-token.d.ts +10 -0
  57. package/dist/lib/claude-account-token.js +14 -4
  58. package/dist/lib/config-drift.d.ts +37 -0
  59. package/dist/lib/config-drift.js +72 -0
  60. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  61. package/dist/lib/daemon/auth-sync-service.js +34 -0
  62. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  63. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  64. package/dist/lib/daemon/daemon.js +87 -176
  65. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  66. package/dist/lib/daemon/heartbeat-service.js +26 -0
  67. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  68. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  69. package/dist/lib/daemon/runner.d.ts +15 -0
  70. package/dist/lib/daemon/runner.js +23 -0
  71. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  72. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  73. package/dist/lib/daemon/service.d.ts +2 -2
  74. package/dist/lib/daemon/service.js +1 -1
  75. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  76. package/dist/lib/daemon/session-state-service.js +34 -0
  77. package/dist/lib/daemon/supervisor.d.ts +17 -7
  78. package/dist/lib/daemon/supervisor.js +87 -14
  79. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  80. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  81. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  82. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  83. package/dist/lib/daemon-services.d.ts +1 -1
  84. package/dist/lib/daemon-services.js +25 -0
  85. package/dist/lib/device-config.d.ts +3 -3
  86. package/dist/lib/device-config.js +5 -5
  87. package/dist/lib/devices/connect.d.ts +26 -0
  88. package/dist/lib/devices/connect.js +48 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  90. package/dist/lib/devices/doctor-findings.js +19 -1
  91. package/dist/lib/devices/harness-inventory.js +5 -2
  92. package/dist/lib/exec.d.ts +28 -0
  93. package/dist/lib/exec.js +73 -7
  94. package/dist/lib/feed/feed.d.ts +1 -1
  95. package/dist/lib/feed/feed.js +23 -1
  96. package/dist/lib/feed-broadcast.js +1 -1
  97. package/dist/lib/fleet/apply.d.ts +11 -0
  98. package/dist/lib/fleet/apply.js +23 -3
  99. package/dist/lib/fleet/auth-sync.js +5 -3
  100. package/dist/lib/help.d.ts +9 -0
  101. package/dist/lib/help.js +29 -1
  102. package/dist/lib/hosts/passthrough.d.ts +1 -10
  103. package/dist/lib/hosts/passthrough.js +1 -13
  104. package/dist/lib/installations/versions.js +9 -1
  105. package/dist/lib/linux-userns.d.ts +58 -0
  106. package/dist/lib/linux-userns.js +116 -0
  107. package/dist/lib/memory.d.ts +26 -0
  108. package/dist/lib/memory.js +80 -1
  109. package/dist/lib/monitors/config.d.ts +11 -0
  110. package/dist/lib/monitors/config.js +8 -0
  111. package/dist/lib/monitors/engine.d.ts +5 -1
  112. package/dist/lib/monitors/engine.js +13 -4
  113. package/dist/lib/monitors/state.d.ts +37 -1
  114. package/dist/lib/monitors/state.js +79 -4
  115. package/dist/lib/permissions-registry.d.ts +2 -0
  116. package/dist/lib/permissions-registry.js +116 -14
  117. package/dist/lib/permissions.d.ts +5 -3
  118. package/dist/lib/permissions.js +25 -27
  119. package/dist/lib/profiles.d.ts +8 -7
  120. package/dist/lib/profiles.js +12 -0
  121. package/dist/lib/project-key.d.ts +9 -0
  122. package/dist/lib/project-key.js +11 -0
  123. package/dist/lib/scheduling/routines.d.ts +47 -0
  124. package/dist/lib/scheduling/routines.js +70 -1
  125. package/dist/lib/secrets/bundles.d.ts +35 -0
  126. package/dist/lib/secrets/bundles.js +78 -1
  127. package/dist/lib/secrets/push.d.ts +3 -8
  128. package/dist/lib/secrets/push.js +18 -14
  129. package/dist/lib/secrets/remote.d.ts +9 -18
  130. package/dist/lib/secrets/remote.js +11 -26
  131. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  132. package/dist/lib/secrets/reserved-sync.js +129 -0
  133. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  134. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  135. package/dist/lib/self-heal/registry.js +4 -0
  136. package/dist/lib/self-heal/types.d.ts +1 -1
  137. package/dist/lib/session/active.js +1 -4
  138. package/dist/lib/session/db.d.ts +41 -5
  139. package/dist/lib/session/db.js +132 -30
  140. package/dist/lib/session/discover.d.ts +32 -4
  141. package/dist/lib/session/discover.js +119 -25
  142. package/dist/lib/session/insights.d.ts +14 -0
  143. package/dist/lib/session/insights.js +25 -2
  144. package/dist/lib/session/linear.js +1 -1
  145. package/dist/lib/session/shell-programs.d.ts +17 -0
  146. package/dist/lib/session/shell-programs.js +21 -0
  147. package/dist/lib/session/state.js +2 -1
  148. package/dist/lib/session/stream-render.js +2 -1
  149. package/dist/lib/session/tool-calls.js +2 -5
  150. package/dist/lib/session/trajectory-html.js +2 -1
  151. package/dist/lib/session/trajectory.js +3 -12
  152. package/dist/lib/session/types.d.ts +8 -0
  153. package/dist/lib/share/capture.js +11 -2
  154. package/dist/lib/share/publish.d.ts +56 -5
  155. package/dist/lib/share/publish.js +126 -18
  156. package/dist/lib/share/worker-template.d.ts +3 -12
  157. package/dist/lib/share/worker-template.js +860 -59
  158. package/dist/lib/startup/root-command.js +2 -1
  159. package/dist/lib/state.d.ts +16 -0
  160. package/dist/lib/state.js +178 -46
  161. package/dist/lib/sync-status.d.ts +4 -0
  162. package/dist/lib/sync-status.js +3 -0
  163. package/dist/lib/traces/classify.js +24 -19
  164. package/dist/lib/usage-refresh.js +2 -1
  165. package/dist/lib/view-types.d.ts +7 -0
  166. package/package.json +9 -2
@@ -12,21 +12,42 @@ 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 = 41;
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;
24
- export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
34
+ /** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
35
+ export declare const INSIGHTS_EXTRACTOR_VERSION = 7;
36
+ /** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
37
+ export declare const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
25
38
  /** File stat snapshot used to detect changes between scan runs. */
26
39
  export interface ScanStamp {
27
40
  fileMtimeMs: number;
28
41
  fileSize: number;
29
42
  scannedAt?: number;
43
+ /**
44
+ * `scan_ledger.extractor_version` as of the last scan, when read from the
45
+ * ledger (undefined for a freshly-computed stamp that hasn't been persisted
46
+ * yet). Compared against {@link CONTENT_INDEX_VERSION} by
47
+ * `filterChangedEntries` (discover.ts) to force a re-extract independent of
48
+ * (mtime, size).
49
+ */
50
+ extractorVersion?: number | null;
30
51
  }
31
52
  /** Filter and pagination options for querying the sessions table. */
32
53
  export interface QueryOptions {
@@ -163,6 +184,10 @@ interface ParserStateRow {
163
184
  fileMtimeMs: number;
164
185
  fileSize: number;
165
186
  scannedAt: number;
187
+ /** See {@link ScanStamp.extractorVersion}. A mismatch vs CONTENT_INDEX_VERSION
188
+ * means this continuation predates the current content extractor and MUST
189
+ * be treated as absent (forcing a full re-parse) rather than resumed from. */
190
+ extractorVersion: number | null;
166
191
  }
167
192
  /**
168
193
  * Bulk-load the resumable-parse continuation (parser_state + content_text) plus
@@ -208,11 +233,15 @@ export declare function recordDirScans(entries: Array<{
208
233
  * Upsert a session row and replace its FTS5 content in a single transaction.
209
234
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
210
235
  */
211
- export declare function upsertSession(meta: SessionMeta, content: string, scan?: ScanStamp): void;
236
+ export declare function upsertSession(meta: SessionMeta, content: string, scan?: ScanStamp, assistantContent?: string): void;
212
237
  /** Batch-upsert sessions with their FTS5 content and scan stamps in a single transaction. */
213
238
  export declare function upsertSessionsBatch(entries: Array<{
214
239
  meta: SessionMeta;
215
240
  content: string;
241
+ /** Assistant-answer text, accumulated the same way as `content` (the
242
+ * user-prompt text) but stored in session_text's own `assistant` column
243
+ * with a lower BM25 weight — see BM25_WEIGHTS. */
244
+ assistantContent?: string;
216
245
  scan?: ScanStamp;
217
246
  parserState?: string;
218
247
  contentText?: string;
@@ -584,6 +613,13 @@ interface FtsHit {
584
613
  sessionId: string;
585
614
  score: number;
586
615
  matchedTerms: string[];
616
+ /**
617
+ * A short bm25 `snippet()` excerpt around the best-matching column (label,
618
+ * topic, project, user content, or assistant answer), with the matched
619
+ * term(s) wrapped in `**…**`. Absent for a handle/label-tier hit (tiers 1-3
620
+ * below), which has no excerpt to show — the label itself IS the match.
621
+ */
622
+ snippet?: string;
587
623
  }
588
624
  /**
589
625
  * 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 = 41;
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,10 +66,15 @@ 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 (
@@ -135,6 +152,7 @@ CREATE VIRTUAL TABLE IF NOT EXISTS session_text USING fts5(
135
152
  topic,
136
153
  project,
137
154
  content,
155
+ assistant,
138
156
  tokenize = 'unicode61 remove_diacritics 2'
139
157
  );
140
158
 
@@ -158,7 +176,13 @@ CREATE TABLE IF NOT EXISTS scan_ledger (
158
176
  -- doc so detectTicket + FTS can rebuild on append without re-reading the file.
159
177
  -- Written by B-2; B-1 only defines + round-trips them.
160
178
  parser_state TEXT,
161
- 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
162
186
  );
163
187
 
164
188
  -- Tracks the mtime + entry-count of every LEAF directory that directly holds
@@ -409,9 +433,10 @@ CREATE INDEX IF NOT EXISTS idx_computer_sessions_started ON computer_sessions(st
409
433
  * re-derives on the next `agents insights` instead of silently reporting stale
410
434
  * numbers alongside fresh ones. Same role as RESOURCE_INDEX_VERSION.
411
435
  */
412
- /** Bump when facet extraction changes so cached rows recompute (stalls-by-model v6). */
413
- export const INSIGHTS_EXTRACTOR_VERSION = 6;
414
- export const SESSION_TOPIC_EXTRACTOR_VERSION = 1;
436
+ /** Bump when facet extraction changes so cached rows recompute (shell-command-by-binary v7). */
437
+ export const INSIGHTS_EXTRACTOR_VERSION = 7;
438
+ /** Bump when classifyTopic's output changes so cached topics recompute (human task taxonomy v2). */
439
+ export const SESSION_TOPIC_EXTRACTOR_VERSION = 2;
415
440
  const PREVIEW_EXTRACTOR_VERSION = 1;
416
441
  let dbInstance = null;
417
442
  /**
@@ -1085,6 +1110,48 @@ function migrateSchema(db, fromVersion) {
1085
1110
  if (!cols.has('harness'))
1086
1111
  db.exec(`ALTER TABLE sessions ADD COLUMN harness TEXT`);
1087
1112
  }
1113
+ if (fromVersion < 42) {
1114
+ // v41 -> v42: index the agent's ANSWERS, not just the user's prompts.
1115
+ // session_text gains an `assistant` column (own FTS5 column, own lower
1116
+ // BM25 weight — see BM25_WEIGHTS) and scan_ledger gains `extractor_version`
1117
+ // so the change-detector can force a re-extract independent of
1118
+ // (mtime, size). FTS5 can't ALTER a virtual table's column set, but unlike
1119
+ // v1->v2 (which dropped the table and forced a blind full rescan of
1120
+ // everything), the existing label/topic/project/content in every row is
1121
+ // still exactly right — only `assistant` is missing. Rename the old table
1122
+ // out of the way, create the new 6-column one, and copy the old rows back
1123
+ // in (assistant defaults to '' until the extractor_version lever backfills
1124
+ // it on that row's next scan) — search over label/topic/project/content
1125
+ // never blacks out for the transient window it takes existing sessions to
1126
+ // get rescanned, which can be a long time for a session whose transcript
1127
+ // is otherwise cold.
1128
+ db.exec(`ALTER TABLE session_text RENAME TO session_text_v41`);
1129
+ db.exec(`
1130
+ CREATE VIRTUAL TABLE session_text USING fts5(
1131
+ session_id UNINDEXED,
1132
+ label,
1133
+ topic,
1134
+ project,
1135
+ content,
1136
+ assistant,
1137
+ tokenize = 'unicode61 remove_diacritics 2'
1138
+ );
1139
+ `);
1140
+ db.exec(`
1141
+ INSERT INTO session_text (session_id, label, topic, project, content, assistant)
1142
+ SELECT session_id, label, topic, project, content, '' FROM session_text_v41
1143
+ `);
1144
+ db.exec(`DROP TABLE session_text_v41`);
1145
+ const ledgerCols = db.prepare(`PRAGMA table_info(scan_ledger)`).all();
1146
+ if (!ledgerCols.some(c => c.name === 'extractor_version')) {
1147
+ db.exec(`ALTER TABLE scan_ledger ADD COLUMN extractor_version INTEGER`);
1148
+ }
1149
+ // No `DELETE FROM scan_ledger` — the new column is NULL on every existing
1150
+ // row, which already never equals CONTENT_INDEX_VERSION, so every session
1151
+ // re-extracts on its next scan while parser_state/content_text (the
1152
+ // Claude/Codex resumable continuation) stay intact for rows that don't
1153
+ // need a full reparse for any OTHER reason.
1154
+ }
1088
1155
  }
1089
1156
  /**
1090
1157
  * Stamp `account_key` / `account_org` / `account` on every Claude row from its
@@ -1455,13 +1522,18 @@ export function getScanStampsForPaths(filePaths) {
1455
1522
  const placeholders = chunk.map(() => '?').join(',');
1456
1523
  const rows = db
1457
1524
  .prepare(`
1458
- SELECT file_path, file_mtime_ms, file_size, scanned_at
1525
+ SELECT file_path, file_mtime_ms, file_size, scanned_at, extractor_version
1459
1526
  FROM scan_ledger
1460
1527
  WHERE file_path IN (${placeholders})
1461
1528
  `)
1462
1529
  .all(...chunk);
1463
1530
  for (const row of rows) {
1464
- const stamp = { fileMtimeMs: row.file_mtime_ms, fileSize: row.file_size, scannedAt: row.scanned_at };
1531
+ const stamp = {
1532
+ fileMtimeMs: row.file_mtime_ms,
1533
+ fileSize: row.file_size,
1534
+ scannedAt: row.scanned_at,
1535
+ extractorVersion: row.extractor_version,
1536
+ };
1465
1537
  for (const original of canonicalToOriginals.get(row.file_path) || []) {
1466
1538
  result.set(original, stamp);
1467
1539
  }
@@ -1498,7 +1570,7 @@ export function getParserStatesForPaths(filePaths) {
1498
1570
  const placeholders = chunk.map(() => '?').join(',');
1499
1571
  const rows = db
1500
1572
  .prepare(`
1501
- SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text
1573
+ SELECT file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text, extractor_version
1502
1574
  FROM scan_ledger
1503
1575
  WHERE file_path IN (${placeholders})
1504
1576
  `)
@@ -1510,6 +1582,7 @@ export function getParserStatesForPaths(filePaths) {
1510
1582
  fileMtimeMs: row.file_mtime_ms,
1511
1583
  fileSize: row.file_size,
1512
1584
  scannedAt: row.scanned_at,
1585
+ extractorVersion: row.extractor_version,
1513
1586
  };
1514
1587
  for (const original of canonicalToOriginals.get(row.file_path) || []) {
1515
1588
  result.set(original, state);
@@ -1527,17 +1600,23 @@ export function recordScans(entries) {
1527
1600
  return;
1528
1601
  const db = getDB();
1529
1602
  const stmt = db.prepare(`
1530
- INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at)
1531
- VALUES (?, ?, ?, ?)
1603
+ INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, extractor_version)
1604
+ VALUES (?, ?, ?, ?, ?)
1532
1605
  ON CONFLICT(file_path) DO UPDATE SET
1533
1606
  file_mtime_ms = excluded.file_mtime_ms,
1534
1607
  file_size = excluded.file_size,
1535
- scanned_at = excluded.scanned_at
1608
+ scanned_at = excluded.scanned_at,
1609
+ extractor_version = excluded.extractor_version
1536
1610
  `);
1537
1611
  const now = Date.now();
1538
1612
  const txn = db.transaction((items) => {
1539
1613
  for (const { filePath, scan } of items) {
1540
- stmt.run(canonicalLedgerKey(filePath), scan.fileMtimeMs, scan.fileSize, now);
1614
+ // Stamped at the CURRENT content extractor version even for a file that
1615
+ // yielded no session (malformed / no id): we ran today's extractor over
1616
+ // it and it produced nothing, so it is current, not stale — otherwise a
1617
+ // permanently-unparseable file would re-trigger "changed" on every scan
1618
+ // forever once CONTENT_INDEX_VERSION is bumped.
1619
+ stmt.run(canonicalLedgerKey(filePath), scan.fileMtimeMs, scan.fileSize, now, CONTENT_INDEX_VERSION);
1541
1620
  }
1542
1621
  });
1543
1622
  txn(entries);
@@ -1867,7 +1946,7 @@ function enrichCachedSessionMeta(meta) {
1867
1946
  }
1868
1947
  }
1869
1948
  const deleteTextStmt = (db) => db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
1870
- const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content) VALUES (?, ?, ?, ?, ?)`);
1949
+ const insertTextStmt = (db) => db.prepare(`INSERT INTO session_text (session_id, label, topic, project, content, assistant) VALUES (?, ?, ?, ?, ?, ?)`);
1871
1950
  // Read back the label the upsert actually stored (which may be the preserved
1872
1951
  // one, not the incoming blank) so the FTS label column stays consistent with
1873
1952
  // sessions.label after a bare rescan.
@@ -1904,7 +1983,7 @@ function resolveMachine(meta) {
1904
1983
  * Upsert a session row and replace its FTS5 content in a single transaction.
1905
1984
  * `content` is the tokenizable user-prompt text; pass '' to leave the row unsearchable.
1906
1985
  */
1907
- export function upsertSession(meta, content, scan) {
1986
+ export function upsertSession(meta, content, scan, assistantContent = '') {
1908
1987
  meta = enrichCachedSessionMeta(meta);
1909
1988
  // Join the durable sessionId -> actor sidecar (RUSH-2019) when the caller
1910
1989
  // didn't already carry an actor, so a scanned transcript still attributes to a
@@ -1975,7 +2054,7 @@ export function upsertSession(meta, content, scan) {
1975
2054
  insText.run(meta.id,
1976
2055
  // Use the label the upsert actually stored (preserve-non-empty rule),
1977
2056
  // not the raw incoming one, so FTS label ranking survives a bare rescan.
1978
- storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
2057
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
1979
2058
  });
1980
2059
  txn();
1981
2060
  }
@@ -1993,19 +2072,23 @@ export function upsertSessionsBatch(entries) {
1993
2072
  // stored owner on rescan.
1994
2073
  const actorIndex = loadSessionActorIndex();
1995
2074
  // Persist the Claude resumable-parse continuation (parser_state + content_text)
1996
- // alongside the stamp. On a full/incremental Claude parse the caller passes the
2075
+ // alongside the stamp, plus the CURRENT content extractor version — a caller
2076
+ // that reached this batch write ran today's extractor over the file, so the
2077
+ // ledger row is current regardless of which branch (full/incremental)
2078
+ // produced it. On a full/incremental Claude parse the caller passes the
1997
2079
  // serialized newState + accumulated user doc so the NEXT scan can resume from
1998
2080
  // the persisted offset (B-2). Other scanners pass neither, leaving both columns
1999
2081
  // NULL exactly as before — their ledger rows are unaffected.
2000
2082
  const ledger = db.prepare(`
2001
- INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text)
2002
- VALUES (?, ?, ?, ?, ?, ?)
2083
+ INSERT INTO scan_ledger (file_path, file_mtime_ms, file_size, scanned_at, parser_state, content_text, extractor_version)
2084
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2003
2085
  ON CONFLICT(file_path) DO UPDATE SET
2004
2086
  file_mtime_ms = excluded.file_mtime_ms,
2005
2087
  file_size = excluded.file_size,
2006
2088
  scanned_at = excluded.scanned_at,
2007
2089
  parser_state = excluded.parser_state,
2008
- content_text = excluded.content_text
2090
+ content_text = excluded.content_text,
2091
+ extractor_version = excluded.extractor_version
2009
2092
  `);
2010
2093
  // Build a lookup from canonical file path → entry, used inside the write
2011
2094
  // transaction to re-check the ledger AFTER acquiring the lock. When a
@@ -2076,17 +2159,26 @@ export function upsertSessionsBatch(entries) {
2076
2159
  const chunk = paths.slice(i, i + CHUNK);
2077
2160
  const phs = chunk.map(() => '?').join(',');
2078
2161
  const rows = db
2079
- .prepare(`SELECT file_path, file_mtime_ms, file_size FROM scan_ledger WHERE file_path IN (${phs})`)
2162
+ .prepare(`SELECT file_path, file_mtime_ms, file_size, extractor_version FROM scan_ledger WHERE file_path IN (${phs})`)
2080
2163
  .all(...chunk);
2081
2164
  for (const row of rows) {
2082
2165
  const entry = byPath.get(row.file_path);
2083
- if (entry && row.file_mtime_ms === entry.scan.fileMtimeMs && row.file_size === entry.scan.fileSize) {
2166
+ // A concurrent writer's row only makes THIS entry redundant when it is
2167
+ // current at CONTENT_INDEX_VERSION too — otherwise a (mtime, size) match
2168
+ // alone would make the version lever a no-op: the very reason this batch
2169
+ // was scheduled (a stale extractor_version) would be silently skipped as
2170
+ // "someone else already indexed it", when what they indexed predates the
2171
+ // current extractor.
2172
+ if (entry &&
2173
+ row.file_mtime_ms === entry.scan.fileMtimeMs &&
2174
+ row.file_size === entry.scan.fileSize &&
2175
+ row.extractor_version === CONTENT_INDEX_VERSION) {
2084
2176
  alreadyIndexed.add(entry.meta.id);
2085
2177
  }
2086
2178
  }
2087
2179
  }
2088
2180
  for (const entry of items) {
2089
- const { meta, content, scan, parserState, contentText } = entry;
2181
+ const { meta, content, assistantContent, scan, parserState, contentText } = entry;
2090
2182
  if (alreadyIndexed.has(meta.id))
2091
2183
  continue;
2092
2184
  // Per-row guard: one malformed session (e.g. a required field that resolves to
@@ -2170,9 +2262,9 @@ export function upsertSessionsBatch(entries) {
2170
2262
  insText.run(meta.id,
2171
2263
  // Mirror upsertSession: index the label the upsert actually stored
2172
2264
  // (preserve-non-empty rule), not the raw incoming one.
2173
- storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '');
2265
+ storedFtsLabel(readLabel, meta.id), meta.topic ?? '', meta.project ?? '', content ?? '', assistantContent ?? '');
2174
2266
  if (scan && meta.filePath) {
2175
- ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null);
2267
+ ledger.run(canonicalLedgerKey(meta.filePath), scan.fileMtimeMs, scan.fileSize, now, parserState ?? null, contentText ?? null, CONTENT_INDEX_VERSION);
2176
2268
  }
2177
2269
  writtenEntries.push(entry);
2178
2270
  }
@@ -3510,11 +3602,16 @@ export function ftsSearch(input, limit = 200) {
3510
3602
  return hits.slice(0, limit);
3511
3603
  }
3512
3604
  // Tier 4: FTS5 content match, skipping anything already surfaced via label.
3605
+ // `snippet(session_text, -1, ...)` lets FTS5 pick the best-matching column
3606
+ // itself (label/topic/project/content/assistant) rather than us guessing —
3607
+ // a query that only matched in `assistant` (an agent-only answer) still gets
3608
+ // an excerpt from the right column instead of an empty content snippet.
3513
3609
  if (expr) {
3514
3610
  try {
3515
3611
  const rows = db
3516
3612
  .prepare(`
3517
- SELECT session_id, bm25(session_text, ${BM25_WEIGHTS.join(', ')}) AS rank
3613
+ SELECT session_id, bm25(session_text, ${BM25_WEIGHTS.join(', ')}) AS rank,
3614
+ snippet(session_text, -1, '**', '**', '…', 12) AS snip
3518
3615
  FROM session_text
3519
3616
  WHERE session_text MATCH ?
3520
3617
  ORDER BY rank ASC
@@ -3524,7 +3621,12 @@ export function ftsSearch(input, limit = 200) {
3524
3621
  for (const r of rows) {
3525
3622
  if (seen.has(r.session_id))
3526
3623
  continue;
3527
- hits.push({ sessionId: r.session_id, score: -r.rank, matchedTerms: terms });
3624
+ hits.push({
3625
+ sessionId: r.session_id,
3626
+ score: -r.rank,
3627
+ matchedTerms: terms,
3628
+ snippet: r.snip?.trim() || undefined,
3629
+ });
3528
3630
  seen.add(r.session_id);
3529
3631
  }
3530
3632
  }
@@ -104,6 +104,8 @@ interface ClaudeSessionScan {
104
104
  entrypoint?: string;
105
105
  /** Concatenated user message text, ready to hand to FTS5. */
106
106
  contentText?: string;
107
+ /** Concatenated assistant-answer text, ready to hand to FTS5's `assistant` column. */
108
+ assistantText?: string;
107
109
  /** Durable state signals persisted to the index by the session-state engine. */
108
110
  prUrl?: string;
109
111
  prNumber?: number;
@@ -154,6 +156,7 @@ interface CodexSessionScan {
154
156
  durationMs?: number;
155
157
  lastActivity?: string;
156
158
  contentText?: string;
159
+ assistantText?: string;
157
160
  prUrl?: string;
158
161
  prNumber?: number;
159
162
  worktreeSlug?: string;
@@ -347,8 +350,15 @@ export declare function looksLikeSessionId(query: string): boolean;
347
350
  */
348
351
  export declare function resolveSessionById(sessions: SessionMeta[], idQuery: string): SessionMeta[];
349
352
  /**
350
- * Run an FTS5 search over the DB and intersect with the given session list,
351
- * preserving the existing SessionMeta[] contract so sessions.ts is unchanged.
353
+ * Run an FTS5 search over the DB and union hits with the given session list.
354
+ *
355
+ * The listing pool is a minority of the index — cwd-scoped, default-capped at
356
+ * 50, and skipping whole classes of indexed transcript — so intersecting FTS
357
+ * hits with it dropped grep-visible sessions that the index already found
358
+ * (PHNX-2767: `agents sessions "tmux pane"` returned 0 while the project
359
+ * transcripts matched). Hits already in the pool keep the caller's SessionMeta;
360
+ * hits the pool missed are hydrated from the index so a content query returns
361
+ * the transcript FTS matched.
352
362
  */
353
363
  export declare function searchContentIndex(sessions: SessionMeta[], query: string): Map<string, SessionMeta>;
354
364
  /**
@@ -375,6 +385,13 @@ interface PreStatEntry {
375
385
  * stat. Same debounce and change-detection as filterChangedFiles; the raw
376
386
  * mtime is floored here so warm files match the ledger exactly as the stat path
377
387
  * does (Math.floor(stat.mtimeMs)).
388
+ *
389
+ * A file is also treated as "changed" — independent of (mtime, size) — when
390
+ * its ledger row's `extractor_version` is behind {@link CONTENT_INDEX_VERSION}.
391
+ * This is the lever that backfills a content-extractor improvement (e.g.
392
+ * indexing assistant answers, not just user prompts) into every already-scanned
393
+ * session on its next pass, without a `DELETE FROM scan_ledger` that would also
394
+ * discard the Claude/Codex resumable parser_state.
378
395
  */
379
396
  export declare function filterChangedEntries(entries: PreStatEntry[]): Array<{
380
397
  filePath: string;
@@ -429,9 +446,11 @@ export declare function parseCodexThreadNameIndex(raw: string): Map<string, stri
429
446
  export declare function readCodexMeta(filePath: string, resolveAccount?: () => string | undefined, currentVersion?: string, scanStamp?: ScanStamp, priorRow?: {
430
447
  parserState: string | null;
431
448
  fileMtimeMs: number;
449
+ extractorVersion?: number | null;
432
450
  }): Promise<{
433
451
  meta: SessionMeta;
434
452
  content: string;
453
+ assistantContent: string;
435
454
  parserState?: string;
436
455
  contentText?: string;
437
456
  toolCalls?: IndexedToolCall[];
@@ -469,6 +488,10 @@ interface ClaudeParseState {
469
488
  lastTsMs?: number;
470
489
  seenAssistantIds: Set<string>;
471
490
  userTexts: string[];
491
+ /** Assistant-answer text (#PHNX content-search), accumulated the same way as
492
+ * `userTexts` but indexed into session_text's own lower-weighted `assistant`
493
+ * column instead of `content` — see BM25_WEIGHTS. */
494
+ assistantTexts: string[];
472
495
  sawPrCreate: boolean;
473
496
  prUrl?: string;
474
497
  prNumber?: number;
@@ -521,7 +544,7 @@ export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessi
521
544
  * exact even when the recent window is smaller than the true count.
522
545
  */
523
546
  export interface ClaudeParserState {
524
- v: 4;
547
+ v: 5;
525
548
  /**
526
549
  * Fan-out tallies carried across a RESUMED parse (RUSH-3091/3095). They must
527
550
  * live in the durable blob: the resumable parser reads only new bytes, so
@@ -566,6 +589,7 @@ export interface ClaudeParserState {
566
589
  spawnedTeam?: string;
567
590
  ticketId?: string;
568
591
  contentText?: string;
592
+ assistantContentText?: string;
569
593
  checklistEvents: SessionEvent[];
570
594
  recentDirectoriesTouched: string[];
571
595
  toolCalls: ToolCallCollectorSnapshot;
@@ -663,6 +687,8 @@ interface CodexParseState {
663
687
  firstTsMs?: number;
664
688
  lastTsMs?: number;
665
689
  userTexts: string[];
690
+ /** See ClaudeParseState.assistantTexts — same accumulate-and-lower-weight FTS treatment. */
691
+ assistantTexts: string[];
666
692
  sawPrCreate: boolean;
667
693
  prUrl?: string;
668
694
  prNumber?: number;
@@ -693,7 +719,7 @@ export declare function initCodexParseState(): CodexParseState;
693
719
  * finalize is identical after a resume.
694
720
  */
695
721
  export interface CodexParserState {
696
- v: 2;
722
+ v: 3;
697
723
  offset: number;
698
724
  jsonlDroppingOversizedLine?: boolean;
699
725
  sessionId?: string;
@@ -716,6 +742,7 @@ export interface CodexParserState {
716
742
  spawnedTeam?: string;
717
743
  ticketId?: string;
718
744
  contentText?: string;
745
+ assistantContentText?: string;
719
746
  checklistEvents: SessionEvent[];
720
747
  recentDirectoriesTouched: string[];
721
748
  toolCalls: ToolCallCollectorSnapshot;
@@ -772,6 +799,7 @@ export declare function __resetCodexScanBranchCountsForTest(): void;
772
799
  export declare function readCursorMeta(filePath: string, currentVersion?: string): {
773
800
  meta: SessionMeta;
774
801
  content: string;
802
+ assistantContent: string;
775
803
  events: SessionEvent[];
776
804
  } | null;
777
805
  /** Parse a single Kimi session state.json file to extract session metadata. */