@getrift/rift 0.1.0-beta.1 → 0.1.0-beta.11

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 (207) hide show
  1. package/README.md +154 -0
  2. package/dist/src/auth/keychain.d.ts +9 -0
  3. package/dist/src/auth/keychain.d.ts.map +1 -1
  4. package/dist/src/auth/keychain.js +37 -0
  5. package/dist/src/auth/keychain.js.map +1 -1
  6. package/dist/src/capture/recover-quarantine.d.ts +221 -0
  7. package/dist/src/capture/recover-quarantine.d.ts.map +1 -0
  8. package/dist/src/capture/recover-quarantine.js +453 -0
  9. package/dist/src/capture/recover-quarantine.js.map +1 -0
  10. package/dist/src/cli/commands/backfill.d.ts.map +1 -1
  11. package/dist/src/cli/commands/backfill.js +5 -2
  12. package/dist/src/cli/commands/backfill.js.map +1 -1
  13. package/dist/src/cli/commands/capture-recover.d.ts +12 -0
  14. package/dist/src/cli/commands/capture-recover.d.ts.map +1 -0
  15. package/dist/src/cli/commands/capture-recover.js +120 -0
  16. package/dist/src/cli/commands/capture-recover.js.map +1 -0
  17. package/dist/src/cli/commands/capture.d.ts.map +1 -1
  18. package/dist/src/cli/commands/capture.js +10 -4
  19. package/dist/src/cli/commands/capture.js.map +1 -1
  20. package/dist/src/cli/commands/feedback.d.ts.map +1 -1
  21. package/dist/src/cli/commands/feedback.js +6 -2
  22. package/dist/src/cli/commands/feedback.js.map +1 -1
  23. package/dist/src/cli/commands/mcp-install.js +5 -2
  24. package/dist/src/cli/commands/mcp-install.js.map +1 -1
  25. package/dist/src/cli/commands/onboard.d.ts +24 -0
  26. package/dist/src/cli/commands/onboard.d.ts.map +1 -1
  27. package/dist/src/cli/commands/onboard.js +119 -21
  28. package/dist/src/cli/commands/onboard.js.map +1 -1
  29. package/dist/src/cli/commands/rebuild.d.ts.map +1 -1
  30. package/dist/src/cli/commands/rebuild.js +6 -3
  31. package/dist/src/cli/commands/rebuild.js.map +1 -1
  32. package/dist/src/cli/commands/review.d.ts.map +1 -1
  33. package/dist/src/cli/commands/review.js +22 -7
  34. package/dist/src/cli/commands/review.js.map +1 -1
  35. package/dist/src/cli/commands/search.d.ts.map +1 -1
  36. package/dist/src/cli/commands/search.js +28 -4
  37. package/dist/src/cli/commands/search.js.map +1 -1
  38. package/dist/src/cli/commands/status.d.ts.map +1 -1
  39. package/dist/src/cli/commands/status.js +48 -2
  40. package/dist/src/cli/commands/status.js.map +1 -1
  41. package/dist/src/cli/commands/token-issue.d.ts.map +1 -1
  42. package/dist/src/cli/commands/token-issue.js +9 -1
  43. package/dist/src/cli/commands/token-issue.js.map +1 -1
  44. package/dist/src/cli/commands/triage.d.ts.map +1 -1
  45. package/dist/src/cli/commands/triage.js +7 -5
  46. package/dist/src/cli/commands/triage.js.map +1 -1
  47. package/dist/src/cli/commands/update.d.ts +26 -0
  48. package/dist/src/cli/commands/update.d.ts.map +1 -0
  49. package/dist/src/cli/commands/update.js +130 -0
  50. package/dist/src/cli/commands/update.js.map +1 -0
  51. package/dist/src/cli/default-config-path.d.ts +15 -0
  52. package/dist/src/cli/default-config-path.d.ts.map +1 -0
  53. package/dist/src/cli/default-config-path.js +27 -0
  54. package/dist/src/cli/default-config-path.js.map +1 -0
  55. package/dist/src/cli/http-client.d.ts +56 -1
  56. package/dist/src/cli/http-client.d.ts.map +1 -1
  57. package/dist/src/cli/http-client.js +161 -6
  58. package/dist/src/cli/http-client.js.map +1 -1
  59. package/dist/src/cli/index.d.ts.map +1 -1
  60. package/dist/src/cli/index.js +25 -6
  61. package/dist/src/cli/index.js.map +1 -1
  62. package/dist/src/cli/status/friend-header.d.ts.map +1 -1
  63. package/dist/src/cli/status/friend-header.js +117 -7
  64. package/dist/src/cli/status/friend-header.js.map +1 -1
  65. package/dist/src/ingestion/inbox-core/conversation-fingerprint.d.ts +2 -0
  66. package/dist/src/ingestion/inbox-core/conversation-fingerprint.d.ts.map +1 -0
  67. package/dist/src/ingestion/inbox-core/conversation-fingerprint.js +27 -0
  68. package/dist/src/ingestion/inbox-core/conversation-fingerprint.js.map +1 -0
  69. package/dist/src/ingestion/inbox-core/conversation-key.d.ts +2 -0
  70. package/dist/src/ingestion/inbox-core/conversation-key.d.ts.map +1 -0
  71. package/dist/src/ingestion/inbox-core/conversation-key.js +31 -0
  72. package/dist/src/ingestion/inbox-core/conversation-key.js.map +1 -0
  73. package/dist/src/ingestion/inbox-core/extensions.d.ts +3 -0
  74. package/dist/src/ingestion/inbox-core/extensions.d.ts.map +1 -0
  75. package/dist/src/ingestion/inbox-core/extensions.js +16 -0
  76. package/dist/src/ingestion/inbox-core/extensions.js.map +1 -0
  77. package/dist/src/ingestion/inbox-core/idempotency.d.ts +2 -0
  78. package/dist/src/ingestion/inbox-core/idempotency.d.ts.map +1 -0
  79. package/dist/src/ingestion/inbox-core/idempotency.js +22 -0
  80. package/dist/src/ingestion/inbox-core/idempotency.js.map +1 -0
  81. package/dist/src/ingestion/inbox-core/index.d.ts +20 -0
  82. package/dist/src/ingestion/inbox-core/index.d.ts.map +1 -0
  83. package/dist/src/ingestion/inbox-core/index.js +20 -0
  84. package/dist/src/ingestion/inbox-core/index.js.map +1 -0
  85. package/dist/src/ingestion/inbox-core/source-detection.d.ts +2 -0
  86. package/dist/src/ingestion/inbox-core/source-detection.d.ts.map +1 -0
  87. package/dist/src/ingestion/inbox-core/source-detection.js +23 -0
  88. package/dist/src/ingestion/inbox-core/source-detection.js.map +1 -0
  89. package/dist/src/ingestion/inbox-core/source-sniffer.d.ts +11 -0
  90. package/dist/src/ingestion/inbox-core/source-sniffer.d.ts.map +1 -0
  91. package/dist/src/ingestion/inbox-core/source-sniffer.js +69 -0
  92. package/dist/src/ingestion/inbox-core/source-sniffer.js.map +1 -0
  93. package/dist/src/ingestion/inbox-core/zip-sniffer.d.ts +70 -0
  94. package/dist/src/ingestion/inbox-core/zip-sniffer.d.ts.map +1 -0
  95. package/dist/src/ingestion/inbox-core/zip-sniffer.js +161 -0
  96. package/dist/src/ingestion/inbox-core/zip-sniffer.js.map +1 -0
  97. package/dist/src/ingestion/inbox-watcher.d.ts.map +1 -1
  98. package/dist/src/ingestion/inbox-watcher.js +34 -50
  99. package/dist/src/ingestion/inbox-watcher.js.map +1 -1
  100. package/dist/src/ingestion/indexer.d.ts +7 -0
  101. package/dist/src/ingestion/indexer.d.ts.map +1 -1
  102. package/dist/src/ingestion/indexer.js +36 -2
  103. package/dist/src/ingestion/indexer.js.map +1 -1
  104. package/dist/src/ingestion/metadata-extraction.d.ts +8 -5
  105. package/dist/src/ingestion/metadata-extraction.d.ts.map +1 -1
  106. package/dist/src/ingestion/metadata-extraction.js +24 -5
  107. package/dist/src/ingestion/metadata-extraction.js.map +1 -1
  108. package/dist/src/ingestion/skip-quarantine.d.ts +10 -0
  109. package/dist/src/ingestion/skip-quarantine.d.ts.map +1 -0
  110. package/dist/src/ingestion/skip-quarantine.js +35 -0
  111. package/dist/src/ingestion/skip-quarantine.js.map +1 -0
  112. package/dist/src/jobs/handlers/compact.d.ts.map +1 -1
  113. package/dist/src/jobs/handlers/compact.js +25 -4
  114. package/dist/src/jobs/handlers/compact.js.map +1 -1
  115. package/dist/src/jobs/handlers/ingest.d.ts.map +1 -1
  116. package/dist/src/jobs/handlers/ingest.js +214 -36
  117. package/dist/src/jobs/handlers/ingest.js.map +1 -1
  118. package/dist/src/jobs/handlers/reconcile.d.ts.map +1 -1
  119. package/dist/src/jobs/handlers/reconcile.js +30 -8
  120. package/dist/src/jobs/handlers/reconcile.js.map +1 -1
  121. package/dist/src/jobs/handlers/reindex.d.ts.map +1 -1
  122. package/dist/src/jobs/handlers/reindex.js +12 -2
  123. package/dist/src/jobs/handlers/reindex.js.map +1 -1
  124. package/dist/src/jobs/handlers/save.d.ts.map +1 -1
  125. package/dist/src/jobs/handlers/save.js +9 -2
  126. package/dist/src/jobs/handlers/save.js.map +1 -1
  127. package/dist/src/jobs/queue.d.ts +11 -0
  128. package/dist/src/jobs/queue.d.ts.map +1 -1
  129. package/dist/src/jobs/queue.js +18 -0
  130. package/dist/src/jobs/queue.js.map +1 -1
  131. package/dist/src/jobs/worker-entry.d.ts.map +1 -1
  132. package/dist/src/jobs/worker-entry.js +2 -0
  133. package/dist/src/jobs/worker-entry.js.map +1 -1
  134. package/dist/src/main.js +36 -4
  135. package/dist/src/main.js.map +1 -1
  136. package/dist/src/mcp/errors.d.ts.map +1 -1
  137. package/dist/src/mcp/errors.js +20 -1
  138. package/dist/src/mcp/errors.js.map +1 -1
  139. package/dist/src/mcp/tools/context-pack.d.ts.map +1 -1
  140. package/dist/src/mcp/tools/context-pack.js +5 -2
  141. package/dist/src/mcp/tools/context-pack.js.map +1 -1
  142. package/dist/src/mcp/tools/search.d.ts +6 -2
  143. package/dist/src/mcp/tools/search.d.ts.map +1 -1
  144. package/dist/src/mcp/tools/search.js +34 -4
  145. package/dist/src/mcp/tools/search.js.map +1 -1
  146. package/dist/src/observability/embedding-events.d.ts +52 -0
  147. package/dist/src/observability/embedding-events.d.ts.map +1 -0
  148. package/dist/src/observability/embedding-events.js +149 -0
  149. package/dist/src/observability/embedding-events.js.map +1 -0
  150. package/dist/src/observability/index-events.d.ts +70 -0
  151. package/dist/src/observability/index-events.d.ts.map +1 -0
  152. package/dist/src/observability/index-events.js +148 -0
  153. package/dist/src/observability/index-events.js.map +1 -0
  154. package/dist/src/observability/tool-usage-stats.d.ts +69 -4
  155. package/dist/src/observability/tool-usage-stats.d.ts.map +1 -1
  156. package/dist/src/observability/tool-usage-stats.js +88 -31
  157. package/dist/src/observability/tool-usage-stats.js.map +1 -1
  158. package/dist/src/observability/tool-usage.d.ts +100 -7
  159. package/dist/src/observability/tool-usage.d.ts.map +1 -1
  160. package/dist/src/observability/tool-usage.js +196 -33
  161. package/dist/src/observability/tool-usage.js.map +1 -1
  162. package/dist/src/observability/version-check.d.ts +70 -0
  163. package/dist/src/observability/version-check.d.ts.map +1 -0
  164. package/dist/src/observability/version-check.js +197 -0
  165. package/dist/src/observability/version-check.js.map +1 -0
  166. package/dist/src/providers/ollama-embed.d.ts +2 -1
  167. package/dist/src/providers/ollama-embed.d.ts.map +1 -1
  168. package/dist/src/providers/ollama-embed.js +1 -0
  169. package/dist/src/providers/ollama-embed.js.map +1 -1
  170. package/dist/src/providers/openai-metadata-extraction.d.ts +3 -3
  171. package/dist/src/providers/openai-metadata-extraction.d.ts.map +1 -1
  172. package/dist/src/providers/openai-metadata-extraction.js +18 -3
  173. package/dist/src/providers/openai-metadata-extraction.js.map +1 -1
  174. package/dist/src/providers/stub.d.ts +2 -0
  175. package/dist/src/providers/stub.d.ts.map +1 -1
  176. package/dist/src/providers/stub.js +2 -0
  177. package/dist/src/providers/stub.js.map +1 -1
  178. package/dist/src/providers/types.d.ts +11 -0
  179. package/dist/src/providers/types.d.ts.map +1 -1
  180. package/dist/src/providers/voyage.d.ts +2 -1
  181. package/dist/src/providers/voyage.d.ts.map +1 -1
  182. package/dist/src/providers/voyage.js +1 -0
  183. package/dist/src/providers/voyage.js.map +1 -1
  184. package/dist/src/retrieval/compact.d.ts +115 -2
  185. package/dist/src/retrieval/compact.d.ts.map +1 -1
  186. package/dist/src/retrieval/compact.js +154 -5
  187. package/dist/src/retrieval/compact.js.map +1 -1
  188. package/dist/src/retrieval/context-pack.d.ts +8 -0
  189. package/dist/src/retrieval/context-pack.d.ts.map +1 -1
  190. package/dist/src/retrieval/context-pack.js +17 -2
  191. package/dist/src/retrieval/context-pack.js.map +1 -1
  192. package/dist/src/server/app.d.ts.map +1 -1
  193. package/dist/src/server/app.js +67 -1
  194. package/dist/src/server/app.js.map +1 -1
  195. package/dist/src/server/routes/friend-status.d.ts +202 -3
  196. package/dist/src/server/routes/friend-status.d.ts.map +1 -1
  197. package/dist/src/server/routes/friend-status.js +290 -7
  198. package/dist/src/server/routes/friend-status.js.map +1 -1
  199. package/dist/src/server/routes/mcp-usage.d.ts +4 -4
  200. package/dist/src/server/routes/search.d.ts.map +1 -1
  201. package/dist/src/server/routes/search.js +144 -24
  202. package/dist/src/server/routes/search.js.map +1 -1
  203. package/dist/src/storage/rebuild.d.ts +14 -1
  204. package/dist/src/storage/rebuild.d.ts.map +1 -1
  205. package/dist/src/storage/rebuild.js +160 -34
  206. package/dist/src/storage/rebuild.js.map +1 -1
  207. package/package.json +1 -1
@@ -2,6 +2,13 @@ import { getBuildInfo } from "../build-info.js";
2
2
  import { readAutoCaptureRunSummary, } from "../../capture/observability.js";
3
3
  import { loadFeedbackConfig } from "../../cli/feedback/feedback-config.js";
4
4
  import { sanitizeProjectLabel } from "../../runtime/legacy-name-guard.js";
5
+ import { readEmbeddingEvents, } from "../../observability/embedding-events.js";
6
+ import { readIndexEvents, } from "../../observability/index-events.js";
7
+ import { compareBetaVersions, readVersionCheck, } from "../../observability/version-check.js";
8
+ const VOYAGE_ERROR_LOOKBACK_MS = 24 * 60 * 60 * 1000;
9
+ const INDEX_ERROR_LOOKBACK_MS = 24 * 60 * 60 * 1000;
10
+ const INBOX_ERROR_LOOKBACK_MS = 24 * 60 * 60 * 1000;
11
+ const INBOX_IDEMPOTENCY_PREFIX = "inbox:";
5
12
  export function buildFriendStatusPayload(deps) {
6
13
  const build = getBuildInfo();
7
14
  const voyageKey = process.env["VOYAGE_API_KEY"];
@@ -24,12 +31,9 @@ export function buildFriendStatusPayload(deps) {
24
31
  captureTotals.quarantined += counts.quarantined ?? 0;
25
32
  }
26
33
  }
27
- let lastEmbedAt = null;
28
- for (const success of Object.values(summary.last_success_by_source)) {
29
- if (success && (!lastEmbedAt || success.timestamp > lastEmbedAt)) {
30
- lastEmbedAt = success.timestamp;
31
- }
32
- }
34
+ const voyageHealth = computeVoyageHealth(dataDir, Date.now());
35
+ const indexHealth = computeIndexHealth(dataDir, Date.now());
36
+ const inboxHealth = computeInboxHealth(deps.jobQueue, Date.now());
33
37
  let relayEnabled = false;
34
38
  try {
35
39
  relayEnabled = loadFeedbackConfig(dataDir).enabled === true;
@@ -43,11 +47,20 @@ export function buildFriendStatusPayload(deps) {
43
47
  uptime_seconds: Math.floor((Date.now() - deps.startTime) / 1000),
44
48
  port: deps.config.server.port,
45
49
  voyage_key_present: typeof voyageKey === "string" && voyageKey.length > 0,
50
+ data_dir: dataDir,
46
51
  },
47
52
  voyage: {
48
53
  project_label: sanitizeProjectLabel(deps.config.voyage?.project_label),
49
- last_embed_at: lastEmbedAt,
54
+ last_embed_at: voyageHealth.last_embed_at,
55
+ last_error_at: voyageHealth.last_error_at,
56
+ last_error_reason: voyageHealth.last_error_reason,
57
+ },
58
+ index: {
59
+ last_update_at: indexHealth.last_update_at,
60
+ last_error_at: indexHealth.last_error_at,
61
+ last_error_reason: indexHealth.last_error_reason,
50
62
  },
63
+ inbox: inboxHealth,
51
64
  codex: {
52
65
  last_preflight_at: last?.timestamp ?? null,
53
66
  last_preflight_ok: last ? last.preflight_ok : null,
@@ -59,10 +72,280 @@ export function buildFriendStatusPayload(deps) {
59
72
  last_review: captureTotals.review,
60
73
  last_errors: last?.errors.length ?? 0,
61
74
  last_quarantined: captureTotals.quarantined,
75
+ next_run_at: computeNextCaptureRunAt({
76
+ captureEnabled: deps.config.capture.enabled,
77
+ intervalSeconds: deps.config.capture.interval_seconds,
78
+ startTime: deps.startTime,
79
+ now: Date.now(),
80
+ }),
62
81
  },
63
82
  feedback: { relay_enabled: relayEnabled },
83
+ update: computeUpdateProjection(dataDir, build.version),
84
+ };
85
+ }
86
+ /**
87
+ * Project the version-check snapshot to the three fields the renderer
88
+ * needs. A missing snapshot, a failed probe, or an up-to-date install
89
+ * all return `available: false` — the renderer prints nothing in those
90
+ * cases, so an unreachable npm registry can never make Rift look broken.
91
+ *
92
+ * Exported for tests.
93
+ */
94
+ export function computeUpdateProjection(dataDir, installedVersion) {
95
+ const snapshot = readVersionCheck(dataDir);
96
+ if (!snapshot || snapshot.latest_beta === null) {
97
+ return {
98
+ available: false,
99
+ installed: installedVersion,
100
+ latest_beta: snapshot?.latest_beta ?? null,
101
+ checked_at: snapshot?.checked_at ?? null,
102
+ };
103
+ }
104
+ // Use the installedVersion from the live build, not the snapshot, so a
105
+ // post-update daemon restart immediately reports `available: false`
106
+ // even before the next probe writes a fresh snapshot.
107
+ const available = compareBetaVersions(installedVersion, snapshot.latest_beta) < 0;
108
+ return {
109
+ available,
110
+ installed: installedVersion,
111
+ latest_beta: snapshot.latest_beta,
112
+ checked_at: snapshot.checked_at,
113
+ };
114
+ }
115
+ /**
116
+ * Compute the next auto-capture tick.
117
+ *
118
+ * The daemon registers `setInterval(runCapture, intervalMs)` at startup
119
+ * (`src/main.ts`), so ticks land at `startTime + N * intervalMs` for
120
+ * N ≥ 1 (N = 0 is the eager startup call). We deliberately anchor to
121
+ * `startTime` rather than `last_run_at` because:
122
+ * - `setInterval` does, so this matches what will actually fire.
123
+ * - `last_run_at` lags by hundreds of ms (save-job wait), and could
124
+ * be stale from a prior daemon process for the first interval after
125
+ * a restart.
126
+ *
127
+ * Returns null when auto-capture is disabled in config.
128
+ *
129
+ * Exported for tests.
130
+ */
131
+ export function computeNextCaptureRunAt(opts) {
132
+ if (!opts.captureEnabled)
133
+ return null;
134
+ if (opts.intervalSeconds <= 0)
135
+ return null;
136
+ const intervalMs = opts.intervalSeconds * 1000;
137
+ const elapsed = Math.max(0, opts.now - opts.startTime);
138
+ const ticksElapsed = Math.floor(elapsed / intervalMs);
139
+ return new Date(opts.startTime + (ticksElapsed + 1) * intervalMs).toISOString();
140
+ }
141
+ /**
142
+ * Reduce the embedding-events lane to the three voyage health fields the
143
+ * friend-status route needs.
144
+ *
145
+ * Exported for tests so we can verify the pipeline-filter and 24h-window
146
+ * logic without booting Fastify or stubbing the route layer.
147
+ */
148
+ export function computeVoyageHealth(dataDir, now) {
149
+ let events;
150
+ try {
151
+ events = readEmbeddingEvents(dataDir);
152
+ }
153
+ catch {
154
+ events = [];
155
+ }
156
+ let lastSuccessTs = null;
157
+ let lastErrorTs = null;
158
+ let lastErrorReason = null;
159
+ const errorCutoff = now - VOYAGE_ERROR_LOOKBACK_MS;
160
+ for (const event of events) {
161
+ // The `voyage.*` projection must reflect Voyage health only. The lane
162
+ // itself is provider-agnostic so a future Ollama-only install reuses
163
+ // the same writer, but on a mixed cloud+local install an Ollama
164
+ // success would otherwise refresh `voyage.last_embed_at` and mask a
165
+ // Voyage outage. Filter at projection time, not write time.
166
+ if (event.provider !== "voyage")
167
+ continue;
168
+ if (event.outcome === "success") {
169
+ if (!lastSuccessTs || event.ts > lastSuccessTs) {
170
+ lastSuccessTs = event.ts;
171
+ }
172
+ continue;
173
+ }
174
+ // outcome === "error"
175
+ const eventTime = Date.parse(event.ts);
176
+ if (Number.isNaN(eventTime) || eventTime < errorCutoff)
177
+ continue;
178
+ if (!lastErrorTs || event.ts > lastErrorTs) {
179
+ lastErrorTs = event.ts;
180
+ lastErrorReason = event.error_class ?? "unknown";
181
+ }
182
+ }
183
+ return {
184
+ last_embed_at: lastSuccessTs,
185
+ last_error_at: lastErrorTs,
186
+ last_error_reason: lastErrorReason,
64
187
  };
65
188
  }
189
+ /**
190
+ * Reduce the index-events lane to the three index health fields the
191
+ * friend-status route needs. Provider-agnostic and table-agnostic: an
192
+ * embed can succeed but the subsequent `table.add(...)` can fail, and
193
+ * `index.*` is what makes that visible. No 24h filter on `last_update_at`
194
+ * (a daemon that has not indexed in 7 days should still surface the
195
+ * stale timestamp); errors are filtered to the 24h window so old
196
+ * resolved incidents don't sit on the dashboard forever.
197
+ */
198
+ export function computeIndexHealth(dataDir, now) {
199
+ let events;
200
+ try {
201
+ events = readIndexEvents(dataDir);
202
+ }
203
+ catch {
204
+ events = [];
205
+ }
206
+ let lastSuccessTs = null;
207
+ let lastErrorTs = null;
208
+ let lastErrorReason = null;
209
+ const errorCutoff = now - INDEX_ERROR_LOOKBACK_MS;
210
+ for (const event of events) {
211
+ if (event.outcome === "success") {
212
+ // Shadow-phase successes do NOT credit `last_update_at`: a row
213
+ // written to a shadow table is "prepared", not "searchable",
214
+ // until `commitAllSwaps` runs. If the swap rolls back, those
215
+ // rows stay invisible — so claiming a fresh index from them
216
+ // would lie. The post-commit "live" event is what credits the
217
+ // real update timestamp.
218
+ const phase = event.phase ?? "live";
219
+ if (phase !== "live")
220
+ continue;
221
+ if (!lastSuccessTs || event.ts > lastSuccessTs) {
222
+ lastSuccessTs = event.ts;
223
+ }
224
+ continue;
225
+ }
226
+ // Errors at any phase (including shadow) count toward
227
+ // `last_error_at` — a per-row LanceDB write failure during shadow
228
+ // population is a real failure regardless of the eventual swap.
229
+ const eventTime = Date.parse(event.ts);
230
+ if (Number.isNaN(eventTime) || eventTime < errorCutoff)
231
+ continue;
232
+ if (!lastErrorTs || event.ts > lastErrorTs) {
233
+ lastErrorTs = event.ts;
234
+ lastErrorReason = event.error_class ?? "unknown";
235
+ }
236
+ }
237
+ return {
238
+ last_update_at: lastSuccessTs,
239
+ last_error_at: lastErrorTs,
240
+ last_error_reason: lastErrorReason,
241
+ };
242
+ }
243
+ /**
244
+ * Reduce the inbox-keyed job set to the four fields the friend-status
245
+ * route needs. Pure over the queue's snapshot (no I/O), so tests can
246
+ * pass an in-memory `JobQueue` without booting Fastify.
247
+ *
248
+ * When the queue isn't wired up (deps.jobQueue undefined), this
249
+ * returns the empty / idle shape — the renderer treats it the same
250
+ * as "nothing dropped yet."
251
+ *
252
+ * Exported for tests.
253
+ */
254
+ export function computeInboxHealth(queue, now) {
255
+ if (!queue) {
256
+ return {
257
+ last_import_at: null,
258
+ last_error_at: null,
259
+ last_error_reason: null,
260
+ pending: 0,
261
+ };
262
+ }
263
+ const jobs = queue.listByIdempotencyPrefix(INBOX_IDEMPOTENCY_PREFIX);
264
+ let lastImportTs = null;
265
+ let lastErrorTs = null;
266
+ let lastErrorReason = null;
267
+ let pending = 0;
268
+ const errorCutoff = now - INBOX_ERROR_LOOKBACK_MS;
269
+ for (const job of jobs) {
270
+ // Skip superseded jobs — the queue chains a retry by setting
271
+ // `retried_by` on the failed/completed parent and creating a fresh
272
+ // job with the same idempotency key. Counting both would let a
273
+ // long-resolved failure keep the "Inbox import errors" hint live
274
+ // for 24h after the retry already succeeded; the leaf is the only
275
+ // entry that reflects current state. (Same invariant the queue
276
+ // itself enforces in `findByIdempotencyKey`.)
277
+ if (job.retried_by !== undefined)
278
+ continue;
279
+ if (job.status === "queued" || job.status === "running") {
280
+ pending++;
281
+ continue;
282
+ }
283
+ if (job.status === "completed") {
284
+ if (!lastImportTs || job.updated_at > lastImportTs) {
285
+ lastImportTs = job.updated_at;
286
+ }
287
+ continue;
288
+ }
289
+ if (job.status === "failed") {
290
+ const eventTime = Date.parse(job.updated_at);
291
+ if (Number.isNaN(eventTime) || eventTime < errorCutoff)
292
+ continue;
293
+ if (!lastErrorTs || job.updated_at > lastErrorTs) {
294
+ lastErrorTs = job.updated_at;
295
+ lastErrorReason = classifyJobError(job);
296
+ }
297
+ }
298
+ // cancelled / interrupted: ignore — neither a successful import
299
+ // nor a real error the user can act on. A retry will be either
300
+ // queued (counted as pending) or completed/failed (counted above).
301
+ }
302
+ return {
303
+ last_import_at: lastImportTs,
304
+ last_error_at: lastErrorTs,
305
+ last_error_reason: lastErrorReason,
306
+ pending,
307
+ };
308
+ }
309
+ /**
310
+ * Best-effort short label for a failed inbox job's error. The queue
311
+ * stores the raw error message (`job.error`); we collapse it into a
312
+ * compact class keyword by substring match against the parser errors
313
+ * we know about, then fall back to a truncated form of the original
314
+ * message. Lower-cased for stability under capitalization drift.
315
+ *
316
+ * Exported for tests.
317
+ */
318
+ export function classifyJobError(job) {
319
+ const raw = (job.error ?? "").trim();
320
+ if (!raw)
321
+ return "unknown";
322
+ const lower = raw.toLowerCase();
323
+ if (lower.includes("zip slip") || lower.includes("path traversal")) {
324
+ return "unsafe_archive";
325
+ }
326
+ if (lower.includes("too large") || lower.includes("max size")) {
327
+ return "size_limit";
328
+ }
329
+ if (lower.includes("unsupported source"))
330
+ return "unsupported_source";
331
+ if (lower.includes("no conversations"))
332
+ return "empty_export";
333
+ if (lower.includes("invalid json") || lower.includes("unexpected token")) {
334
+ return "invalid_json";
335
+ }
336
+ // ADM-ZIP throws strings like "ADM-ZIP: Invalid or unsupported zip
337
+ // format" when adm-zip can't open the archive at all (corrupt header,
338
+ // truncated CD). Distinct from `unsafe_archive` (security-blocked) —
339
+ // this is "the ZIP itself is broken before we can even look inside."
340
+ if (lower.includes("adm-zip") || lower.includes("invalid or unsupported zip")) {
341
+ return "invalid_archive";
342
+ }
343
+ if (lower.includes("parser"))
344
+ return "parser_error";
345
+ // Fallback: first ≤40 chars of the raw message, single-lined.
346
+ const snippet = raw.replace(/\s+/g, " ").slice(0, 40);
347
+ return snippet || "unknown";
348
+ }
66
349
  export function registerFriendStatusRoute(server, deps) {
67
350
  server.get("/status/friend", async (_request, reply) => {
68
351
  return reply.send(buildFriendStatusPayload(deps));
@@ -1 +1 @@
1
- {"version":3,"file":"friend-status.js","sourceRoot":"","sources":["../../../../src/server/routes/friend-status.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"friend-status.js","sourceRoot":"","sources":["../../../../src/server/routes/friend-status.ts"],"names":[],"mappings":"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"}
@@ -2,10 +2,10 @@
2
2
  * GET /stats/mcp-usage — unauthenticated MCP tool-call telemetry.
3
3
  *
4
4
  * SwiftBar pulls this on each tick. Returns per-window call counts +
5
- * a coarse `tokens_saved` estimate derived from a fixed per-tool table.
6
- * Mirrors the auth posture of `/health` and `/version` (no token
7
- * required) — the response carries only call counts and tool names,
8
- * never user content.
5
+ * `context_tokens_delivered_estimate` (≈ ceil(returned-text bytes / 4)
6
+ * over context-hit calls). Mirrors the auth posture of `/health` and
7
+ * `/version` (no token required) — the response carries only call
8
+ * counts and tool names, never user content.
9
9
  */
10
10
  import type { FastifyInstance } from "fastify";
11
11
  import type { ObservabilityDeps } from "../../observability/coverage.js";
@@ -1 +1 @@
1
- {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/server/routes/search.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAoFxD,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,eAAe,EACvB,SAAS,EAAE,iBAAiB,GAAG,SAAS,EACxC,cAAc,CAAC,EAAE,iBAAiB,EAClC,UAAU,CAAC,EAAE,SAAS,SAAS,EAAE,GAChC,IAAI,CA+LN"}
1
+ {"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../../../src/server/routes/search.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AA+FxD,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,eAAe,EACvB,SAAS,EAAE,iBAAiB,GAAG,SAAS,EACxC,cAAc,CAAC,EAAE,iBAAiB,EAClC,UAAU,CAAC,EAAE,SAAS,SAAS,EAAE,GAChC,IAAI,CA4TN"}
@@ -11,7 +11,7 @@ import { rerank } from "../../retrieval/reranker.js";
11
11
  import { lexicalSearchTables } from "../../retrieval/lexical.js";
12
12
  import { getTable, TABLE_NAMES } from "../../storage/tables.js";
13
13
  import { resolveCwdRoot, escapeLike } from "../../retrieval/cwd.js";
14
- import { buildCompactHit, buildCompactHitFromRow, fetchRawRowsForRanked, fetchRowByIdForTier, } from "../../retrieval/compact.js";
14
+ import { buildCompactHit, buildCompactHitFromRow, fetchRawRowsForRanked, fetchRowByIdForTier, fetchRowsByIdAcrossTables, fetchRowsByIdInTables, tablesForTier, } from "../../retrieval/compact.js";
15
15
  const COLD_TIER_THRESHOLD = 0.4;
16
16
  const TIER_VALUES = ["hot", "cold", "digest", "document", "structured_doc"];
17
17
  /**
@@ -19,12 +19,20 @@ const TIER_VALUES = ["hot", "cold", "digest", "document", "structured_doc"];
19
19
  *
20
20
  * Two modes:
21
21
  * - Retrieval mode: `query` is required. Runs hybrid search + rerank.
22
- * - Expansion mode: `detail="full"` + `id` + `tier` — skips retrieval
23
- * entirely and fetches the row by id from the table inferred from `tier`.
24
- * This makes compact-hit `expand` pointers round-trippable from the
25
- * payload alone, without the caller needing to re-run the search.
22
+ * - Exact-ID mode: `detail` of `"full"` or `"middle"` + `id` — skips
23
+ * retrieval entirely and fetches the row by id. Trust contract: this
24
+ * never silently falls back to ranked search. With `tier` (and optionally
25
+ * `source_table`) the lookup is scoped to the backing table(s); without
26
+ * `tier`, the route scans every supported table and returns either the
27
+ * unique match, an explicit `id_not_found`, or an explicit `ambiguous_id`
28
+ * error. `detail="middle"` returns the same exact-row response shape with
29
+ * a bounded head+tail excerpt instead of the full body — preserves the
30
+ * evidence bookends a fact-checker needs while bounding context spend.
26
31
  *
27
- * `query` becomes optional in expansion mode.
32
+ * `query` becomes optional in exact-ID mode. If both `query` and `id` are
33
+ * provided with a non-"summary" detail, the id wins — ranked retrieval is
34
+ * never run as a fallback. This is by design after the REX where a caller
35
+ * passed `query + id + detail="full"` and got a wrong-body response.
28
36
  */
29
37
  const SearchRequestSchema = z
30
38
  .object({
@@ -36,20 +44,20 @@ const SearchRequestSchema = z
36
44
  since: z.string().datetime().optional(),
37
45
  top_k: z.number().int().positive().default(10),
38
46
  cwd: z.string().optional(),
39
- detail: z.enum(["summary", "full"]).default("summary"),
40
- /** With detail="full", expand a single hit by id (bypasses ranking). */
47
+ detail: z.enum(["summary", "middle", "full"]).default("summary"),
48
+ /** With detail="middle"|"full", expand a single hit by id (bypasses ranking). */
41
49
  id: z.string().optional(),
42
- /** Tier of the id being expanded. Required in expansion mode. */
50
+ /** Tier of the id being expanded. Optional narrows the lookup. */
43
51
  tier: z.enum(TIER_VALUES).optional(),
44
52
  /**
45
53
  * Authoritative backing table for the id. When provided, disambiguates
46
54
  * rows that share an id across the cloud/local structured-doc tables.
47
- * Optional — absent requests fall back to tier-based first-match lookup.
55
+ * Optional — absent requests scan every supported table.
48
56
  */
49
57
  source_table: z.enum(TABLE_NAMES).optional(),
50
58
  })
51
- .refine((d) => !!d.query || (d.detail === "full" && !!d.id && !!d.tier), {
52
- message: "Either `query` is required, or provide `id`+`tier`+`detail=\"full\"` for expansion.",
59
+ .refine((d) => !!d.query || ((d.detail === "full" || d.detail === "middle") && !!d.id), {
60
+ message: 'Either `query` is required, or provide `id` + `detail="full"` (or `detail="middle"`) for direct lookup.',
53
61
  })
54
62
  .refine((d) => {
55
63
  // If both tier and source_table are provided in expansion mode, the
@@ -81,21 +89,133 @@ export function registerSearchRoute(server, embedding, localEmbedding, sourceDir
81
89
  }
82
90
  const { query, scope, client, since, top_k, cwd, detail, id: expandId, tier: expandTier, source_table: expandSourceTable, } = parsed.data;
83
91
  const searchScope = scope;
84
- // --- Expansion mode: direct id-based fetch, bypass retrieval/ranking ---
85
- // Triggered when caller follows a compact hit's `expand` pointer
86
- // verbatim: `{id, tier, source_table, detail: "full"}`. Guarantees the
87
- // requested row is returned even if it would rank below `top_k` in search,
88
- // and honors `source_table` to disambiguate cloud vs. local structured docs.
89
- if (detail === "full" && expandId && expandTier) {
90
- const fetched = await fetchRowByIdForTier(expandId, expandTier, expandSourceTable);
91
- const hit = fetched
92
- ? buildCompactHitFromRow(fetched.row, fetched.sourceTable, {
92
+ // --- Exact-ID mode: direct id-based fetch, never falls back to ranking ---
93
+ // Triggered whenever `detail="full"` + `id` are provided. The trust
94
+ // contract is strict: return the exact row, or fail loudly. Four sub-cases:
95
+ // 1. `tier` + `source_table` direct fetch from that table. Powers the
96
+ // round-trippable `expand` pointers carried by every compact hit
97
+ // (which always include `source_table`).
98
+ // 2. `tier` without `source_table` scan the tier's candidate tables;
99
+ // the result must be unique. Multiple matches → 409 `ambiguous_id`
100
+ // (matters for `document` / `structured_doc`, which map to both the
101
+ // cloud and local structured-docs tables). Zero → 404 `id_not_found`.
102
+ // 3. `source_table` without `tier` — direct fetch from that table.
103
+ // 4. Neither `tier` nor `source_table` — scan every supported table.
104
+ // Exactly one match returns the row; zero → 404 `id_not_found`;
105
+ // multiple → 409 `ambiguous_id` with the candidate tables.
106
+ if ((detail === "full" || detail === "middle") && expandId) {
107
+ if (expandTier) {
108
+ // With an explicit `source_table`, the lookup is already scoped to
109
+ // one table — first-match is unambiguous. Without one, fall through
110
+ // to a multi-table scan so a tier that maps to multiple backing
111
+ // tables (document / structured_doc → cloud + local) gets the same
112
+ // ambiguity guarantee as the bare-id path. We never want to silently
113
+ // return the cloud row when a local row with the same id exists.
114
+ if (expandSourceTable) {
115
+ const fetched = await fetchRowByIdForTier(expandId, expandTier, expandSourceTable);
116
+ if (!fetched) {
117
+ return reply.code(404).send({
118
+ error: "id_not_found",
119
+ id: expandId,
120
+ tier: expandTier,
121
+ source_table: expandSourceTable,
122
+ });
123
+ }
124
+ const hit = buildCompactHitFromRow(fetched.row, fetched.sourceTable, {
125
+ ...(query ? { query } : {}),
126
+ expandRoute: "/search",
127
+ detail,
128
+ });
129
+ return {
130
+ results: [hit],
131
+ degraded: false,
132
+ cwd_matched: false,
133
+ cwd_scope_applied_to: [],
134
+ };
135
+ }
136
+ const tierTables = tablesForTier(expandTier);
137
+ const tierMatches = await fetchRowsByIdInTables(expandId, tierTables);
138
+ if (tierMatches.length === 0) {
139
+ return reply.code(404).send({
140
+ error: "id_not_found",
141
+ id: expandId,
142
+ tier: expandTier,
143
+ });
144
+ }
145
+ if (tierMatches.length > 1) {
146
+ return reply.code(409).send({
147
+ error: "ambiguous_id",
148
+ id: expandId,
149
+ tier: expandTier,
150
+ message: "Multiple rows share this id across this tier's backing tables. Pass `source_table` to disambiguate.",
151
+ candidates: tierMatches.map((m) => ({ source_table: m.sourceTable })),
152
+ });
153
+ }
154
+ const onlyTierMatch = tierMatches[0];
155
+ const hit = buildCompactHitFromRow(onlyTierMatch.row, onlyTierMatch.sourceTable, {
156
+ ...(query ? { query } : {}),
157
+ expandRoute: "/search",
158
+ detail,
159
+ });
160
+ return {
161
+ results: [hit],
162
+ degraded: false,
163
+ cwd_matched: false,
164
+ cwd_scope_applied_to: [],
165
+ };
166
+ }
167
+ if (expandSourceTable) {
168
+ // Direct table fetch — no tier needed when caller already named the
169
+ // authoritative table.
170
+ const escaped = expandId.replace(/'/g, "''");
171
+ const rows = (await getTable(expandSourceTable)
172
+ .query()
173
+ .where(`id = '${escaped}'`)
174
+ .limit(1)
175
+ .toArray());
176
+ if (rows.length === 0) {
177
+ return reply.code(404).send({
178
+ error: "id_not_found",
179
+ id: expandId,
180
+ source_table: expandSourceTable,
181
+ });
182
+ }
183
+ const hit = buildCompactHitFromRow(rows[0], expandSourceTable, {
93
184
  ...(query ? { query } : {}),
94
185
  expandRoute: "/search",
95
- })
96
- : null;
186
+ detail,
187
+ });
188
+ return {
189
+ results: [hit],
190
+ degraded: false,
191
+ cwd_matched: false,
192
+ cwd_scope_applied_to: [],
193
+ };
194
+ }
195
+ // No tier, no source_table — scan every table. Must be uniquely-resolvable.
196
+ const matches = await fetchRowsByIdAcrossTables(expandId);
197
+ if (matches.length === 0) {
198
+ return reply.code(404).send({
199
+ error: "id_not_found",
200
+ id: expandId,
201
+ });
202
+ }
203
+ if (matches.length > 1) {
204
+ return reply.code(409).send({
205
+ error: "ambiguous_id",
206
+ id: expandId,
207
+ message: "Multiple rows share this id across tables. Pass `source_table` (or `tier`) to disambiguate.",
208
+ candidates: matches.map((m) => ({ source_table: m.sourceTable })),
209
+ });
210
+ }
211
+ const only = matches[0];
212
+ const hit = buildCompactHitFromRow(only.row, only.sourceTable, {
213
+ ...(query ? { query } : {}),
214
+ expandRoute: "/search",
215
+ detail,
216
+ });
97
217
  return {
98
- results: hit ? [hit] : [],
218
+ results: [hit],
99
219
  degraded: false,
100
220
  cwd_matched: false,
101
221
  cwd_scope_applied_to: [],