@hyperdrive.bot/paseo-server 0.2.8 → 0.3.1

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 (79) hide show
  1. package/dist/server/server/file-explorer/service.js +9 -0
  2. package/dist/server/server/search/extract.d.ts +53 -0
  3. package/dist/server/server/search/extract.js +222 -0
  4. package/dist/server/server/search/index-store.d.ts +83 -0
  5. package/dist/server/server/search/index-store.js +241 -0
  6. package/dist/server/server/search/indexer.d.ts +69 -0
  7. package/dist/server/server/search/indexer.js +362 -0
  8. package/dist/server/server/search/resolve-cwd.d.ts +40 -0
  9. package/dist/server/server/search/resolve-cwd.js +109 -0
  10. package/dist/server/server/search/session-search-service.d.ts +59 -0
  11. package/dist/server/server/search/session-search-service.js +171 -0
  12. package/dist/server/server/session.js +10 -7
  13. package/dist/server/server/workspace/recall-search-feature.js +25 -16
  14. package/dist/server/server/workspace/sessions-search-route.d.ts +18 -46
  15. package/dist/server/server/workspace/sessions-search-route.js +27 -163
  16. package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js → desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js} +5 -5
  17. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js.br +0 -0
  18. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js.gz +0 -0
  19. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js.map.br +0 -0
  20. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js.map.gz +0 -0
  21. package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js → desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js} +3 -3
  22. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js.br +0 -0
  23. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js.gz +0 -0
  24. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js.map.br +0 -0
  25. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js.map.gz +0 -0
  26. package/dist/server/web-ui/_expo/static/js/web/{index-e2ffd4d4419cad7f5153525e95f645b8.js → index-fad30dd59837afbdf33b7d9190c754e9.js} +246 -245
  27. package/dist/server/web-ui/_expo/static/js/web/index-fad30dd59837afbdf33b7d9190c754e9.js.br +0 -0
  28. package/dist/server/web-ui/_expo/static/js/web/index-fad30dd59837afbdf33b7d9190c754e9.js.gz +0 -0
  29. package/dist/server/web-ui/_expo/static/js/web/index-fad30dd59837afbdf33b7d9190c754e9.js.map.br +0 -0
  30. package/dist/server/web-ui/_expo/static/js/web/{index-e2ffd4d4419cad7f5153525e95f645b8.js.map.gz → index-fad30dd59837afbdf33b7d9190c754e9.js.map.gz} +0 -0
  31. package/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js → indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js} +3 -3
  32. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js.br +0 -0
  33. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js.gz +0 -0
  34. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js.map.br +0 -0
  35. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js.map.gz +0 -0
  36. package/dist/server/web-ui/_expo/static/js/web/{native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js → native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js} +5 -5
  37. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js.br +0 -0
  38. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js.gz +0 -0
  39. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js.map.br +0 -0
  40. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js.map.gz +0 -0
  41. package/dist/server/web-ui/_expo/static/js/web/{profiler-8ca15c1659e234673816bc7bad0f88b6.js → profiler-24af32fdd64b094ad2dc78cf1625bd07.js} +11 -11
  42. package/dist/server/web-ui/_expo/static/js/web/profiler-24af32fdd64b094ad2dc78cf1625bd07.js.br +0 -0
  43. package/dist/server/web-ui/_expo/static/js/web/profiler-24af32fdd64b094ad2dc78cf1625bd07.js.gz +0 -0
  44. package/dist/server/web-ui/_expo/static/js/web/profiler-24af32fdd64b094ad2dc78cf1625bd07.js.map.br +0 -0
  45. package/dist/server/web-ui/_expo/static/js/web/{profiler-8ca15c1659e234673816bc7bad0f88b6.js.map.gz → profiler-24af32fdd64b094ad2dc78cf1625bd07.js.map.gz} +0 -0
  46. package/dist/server/web-ui/_expo/static/js/web/{startRecording-1a240d0212111d3eb294a9407e136e6a.js → startRecording-d113d3bf7d809773ff5c95198366c905.js} +60 -60
  47. package/dist/server/web-ui/_expo/static/js/web/startRecording-d113d3bf7d809773ff5c95198366c905.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/startRecording-d113d3bf7d809773ff5c95198366c905.js.gz +0 -0
  49. package/dist/server/web-ui/_expo/static/js/web/startRecording-d113d3bf7d809773ff5c95198366c905.js.map.br +0 -0
  50. package/dist/server/web-ui/_expo/static/js/web/{startRecording-1a240d0212111d3eb294a9407e136e6a.js.map.gz → startRecording-d113d3bf7d809773ff5c95198366c905.js.map.gz} +0 -0
  51. package/dist/server/web-ui/index.html +1 -1
  52. package/dist/server/web-ui/index.html.br +0 -0
  53. package/dist/server/web-ui/index.html.gz +0 -0
  54. package/package.json +2 -2
  55. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js.br +0 -0
  56. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js.gz +0 -0
  57. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js.map.br +0 -0
  58. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js.map.gz +0 -0
  59. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js.br +0 -0
  60. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js.gz +0 -0
  61. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js.map.br +0 -0
  62. package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js.map.gz +0 -0
  63. package/dist/server/web-ui/_expo/static/js/web/index-e2ffd4d4419cad7f5153525e95f645b8.js.br +0 -0
  64. package/dist/server/web-ui/_expo/static/js/web/index-e2ffd4d4419cad7f5153525e95f645b8.js.gz +0 -0
  65. package/dist/server/web-ui/_expo/static/js/web/index-e2ffd4d4419cad7f5153525e95f645b8.js.map.br +0 -0
  66. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js.br +0 -0
  67. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js.gz +0 -0
  68. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js.map.br +0 -0
  69. package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js.map.gz +0 -0
  70. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js.br +0 -0
  71. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js.gz +0 -0
  72. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js.map.br +0 -0
  73. package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js.map.gz +0 -0
  74. package/dist/server/web-ui/_expo/static/js/web/profiler-8ca15c1659e234673816bc7bad0f88b6.js.br +0 -0
  75. package/dist/server/web-ui/_expo/static/js/web/profiler-8ca15c1659e234673816bc7bad0f88b6.js.gz +0 -0
  76. package/dist/server/web-ui/_expo/static/js/web/profiler-8ca15c1659e234673816bc7bad0f88b6.js.map.br +0 -0
  77. package/dist/server/web-ui/_expo/static/js/web/startRecording-1a240d0212111d3eb294a9407e136e6a.js.br +0 -0
  78. package/dist/server/web-ui/_expo/static/js/web/startRecording-1a240d0212111d3eb294a9407e136e6a.js.gz +0 -0
  79. package/dist/server/web-ui/_expo/static/js/web/startRecording-1a240d0212111d3eb294a9407e136e6a.js.map.br +0 -0
@@ -0,0 +1,362 @@
1
+ // MARRA CUSTOMIZATION — incremental, provider-agnostic session indexer.
2
+ //
3
+ // Discovers transcripts from every installed provider, reads only the bytes
4
+ // appended since the last pass, extracts searchable text, and commits to the
5
+ // FTS5 store. Runs on a background timer (see session-search-service.ts), so
6
+ // indexing never happens on the query path — that coupling is exactly what
7
+ // made the recall-based search unusable on a loaded host.
8
+ //
9
+ // `runIndexPass` is a pure function of (store, adapters), so it can move to a
10
+ // worker_thread unchanged if a future corpus makes passes expensive. Measured
11
+ // today: a steady-state pass over 8,117 files reads ~0 bytes in 0.9 s, which
12
+ // does not justify the thread.
13
+ //
14
+ // The append-only byte-offset scheme is the core of it. Claude transcripts
15
+ // grow to 214 MB; re-reading them per pass is what killed the old design.
16
+ // Here a file is read once in full, then each subsequent pass touches only the
17
+ // tail. Safety valve: if a file SHRANK, the append-only assumption is broken
18
+ // (rewritten/rotated), so we forget it and re-read from zero.
19
+ //
20
+ // Partial trailing lines matter. A transcript is often mid-append when we read
21
+ // it, leaving a truncated final line that will not parse. We advance the
22
+ // offset only to the last complete newline, so the fragment is re-read intact
23
+ // on the next pass instead of being dropped.
24
+ import { createReadStream } from "node:fs";
25
+ import fs from "node:fs/promises";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import { resolvePaseoHome } from "../paseo-home.js";
29
+ import { extractClaudeRecord, extractCodexRecord, extractPaseoAgent, } from "./extract.js";
30
+ // ---------------------------------------------------------------------------
31
+ // Discovery helpers
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * Claude encodes the session cwd in the project dir name by replacing every
35
+ * non-alphanumeric character with "-". That is lossy (a real "-" and a "/"
36
+ * both become "-"), so this is a best-effort display value — matching what
37
+ * all-sessions-route.ts already does, deliberately, so both agree.
38
+ */
39
+ function cwdFromClaudeSlug(slug) {
40
+ return slug.startsWith("-") ? `/${slug.slice(1).replace(/-/g, "/")}` : slug;
41
+ }
42
+ async function listDir(dir) {
43
+ try {
44
+ return await fs.readdir(dir);
45
+ }
46
+ catch {
47
+ return [];
48
+ }
49
+ }
50
+ async function walkFiles(root, suffix) {
51
+ const out = [];
52
+ const stack = [root];
53
+ while (stack.length > 0) {
54
+ const dir = stack.pop();
55
+ let entries;
56
+ try {
57
+ entries = await fs.readdir(dir, { withFileTypes: true });
58
+ }
59
+ catch {
60
+ continue;
61
+ }
62
+ for (const e of entries) {
63
+ const full = path.join(dir, e.name);
64
+ if (e.isDirectory())
65
+ stack.push(full);
66
+ else if (e.isFile() && e.name.endsWith(suffix))
67
+ out.push(full);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+ export function createClaudeAdapter(root) {
73
+ const projectsRoot = root ?? path.join(os.homedir(), ".claude", "projects");
74
+ return {
75
+ name: "claude",
76
+ extract: extractClaudeRecord,
77
+ async discover() {
78
+ const out = [];
79
+ for (const slug of await listDir(projectsRoot)) {
80
+ const dir = path.join(projectsRoot, slug);
81
+ const cwd = cwdFromClaudeSlug(slug);
82
+ // Two layouts live under a project dir, and a two-level walk finds
83
+ // only the first (measured: 5,449 of 6,957 files — 1,508 silently
84
+ // missing):
85
+ // <slug>/<sid>.jsonl — main session
86
+ // <slug>/<sid>/subagents/agent-<id>.jsonl — subagent runs
87
+ // Subagent transcripts are real content, so they are indexed and
88
+ // attributed to the PARENT session id: a hit inside a subagent should
89
+ // open the conversation that spawned it, not a fragment nobody can
90
+ // navigate to.
91
+ for (const entry of await walkFiles(dir, ".jsonl")) {
92
+ const rel = path.relative(dir, entry);
93
+ const segments = rel.split(path.sep);
94
+ const sid = segments.length === 1
95
+ ? path.basename(entry, ".jsonl") // <sid>.jsonl
96
+ : segments[0]; // <sid>/subagents/agent-*.jsonl
97
+ out.push({ path: entry, source: "claude", sid, cwd });
98
+ }
99
+ }
100
+ return out;
101
+ },
102
+ };
103
+ }
104
+ export function createCodexAdapter(root) {
105
+ // UNVERIFIED against real data: ~/.codex/ on the dev box has no sessions,
106
+ // only prompts/. Adapter is fixture-tested; re-validate on first real run.
107
+ const sessionsRoot = root ?? path.join(os.homedir(), ".codex", "sessions");
108
+ return {
109
+ name: "codex",
110
+ extract: extractCodexRecord,
111
+ async discover() {
112
+ const files = await walkFiles(sessionsRoot, ".jsonl");
113
+ return files.map((p) => {
114
+ const base = path.basename(p, ".jsonl");
115
+ // rollout-2026-07-29T10-11-12-<uuid>.jsonl → trailing uuid is the sid.
116
+ const m = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i.exec(base);
117
+ return { path: p, source: "codex", sid: m?.[1] ?? base, cwd: "" };
118
+ });
119
+ },
120
+ };
121
+ }
122
+ export function createPaseoAdapter(root) {
123
+ // resolvePaseoHome(), not os.homedir(): a daemon running with PASEO_HOME set
124
+ // must index ITS OWN agents, not the ones belonging to the user's live daemon.
125
+ const agentsRoot = root ?? path.join(resolvePaseoHome(), "agents");
126
+ return {
127
+ name: "paseo",
128
+ // Whole-document JSON, pretty-printed and rewritten in place on every
129
+ // agent update — NOT append-only JSONL. Parsing it line-by-line yields
130
+ // nothing (each line is a JSON fragment).
131
+ format: "json",
132
+ // Paseo records are metadata-only (median 2 KB, no transcript text). The
133
+ // adapter indexes title + labels so agents are findable by name, and the
134
+ // record's runtimeInfo.sessionId is what links a hit to real transcript
135
+ // text held by the provider adapters.
136
+ extract(record) {
137
+ const { meta, blocks } = extractPaseoAgent(record);
138
+ // The agent record's own cwd is authoritative for a paseo hit.
139
+ return { blocks, timestamp: meta?.updatedAt ?? null, sid: null, cwd: meta?.cwd ?? null };
140
+ },
141
+ async discover() {
142
+ const files = await walkFiles(agentsRoot, ".json");
143
+ return files.map((p) => ({
144
+ path: p,
145
+ source: "paseo",
146
+ sid: path.basename(p, ".json"),
147
+ cwd: "",
148
+ }));
149
+ },
150
+ };
151
+ }
152
+ export function defaultAdapters() {
153
+ return [createClaudeAdapter(), createCodexAdapter(), createPaseoAdapter()];
154
+ }
155
+ // ---------------------------------------------------------------------------
156
+ // Incremental pass
157
+ // ---------------------------------------------------------------------------
158
+ /** Read [start, end) of a file as UTF-8 without loading the whole thing. */
159
+ async function readRange(filePath, start, end) {
160
+ if (end <= start)
161
+ return "";
162
+ return await new Promise((resolve, reject) => {
163
+ const chunks = [];
164
+ const stream = createReadStream(filePath, { start, end: end - 1 });
165
+ stream.on("data", (c) => chunks.push(c));
166
+ stream.on("error", reject);
167
+ stream.on("close", () => resolve(Buffer.concat(chunks).toString("utf-8")));
168
+ });
169
+ }
170
+ const DEFAULT_MAX_BYTES_PER_FILE = 64 * 1024 * 1024;
171
+ /**
172
+ * One incremental pass over every discovered transcript. Safe to call
173
+ * repeatedly; work is proportional to bytes appended since the last call, not
174
+ * to corpus size.
175
+ */
176
+ export async function runIndexPass(store, options = {}) {
177
+ const started = Date.now();
178
+ const adapters = options.adapters ?? defaultAdapters();
179
+ const log = options.logger;
180
+ const maxBytes = options.maxBytesPerFile ?? DEFAULT_MAX_BYTES_PER_FILE;
181
+ const result = {
182
+ filesPartial: 0,
183
+ filesScanned: 0,
184
+ filesUpdated: 0,
185
+ filesReset: 0,
186
+ filesPruned: 0,
187
+ bytesRead: 0,
188
+ chunksAdded: 0,
189
+ errors: 0,
190
+ durationMs: 0,
191
+ };
192
+ const known = store.getFileStates();
193
+ const seen = new Set();
194
+ for (const adapter of adapters) {
195
+ const discovered = await adapter.discover();
196
+ for (const file of discovered) {
197
+ if (options.signal?.aborted) {
198
+ result.durationMs = Date.now() - started;
199
+ return result;
200
+ }
201
+ seen.add(file.path);
202
+ result.filesScanned += 1;
203
+ await indexOneFile({ store, adapter, file, known, result, maxBytes, log });
204
+ }
205
+ }
206
+ result.filesPruned = store.pruneMissing(seen);
207
+ result.durationMs = Date.now() - started;
208
+ log?.info(result, "session-index: pass complete");
209
+ return result;
210
+ }
211
+ /**
212
+ * Decide how much of one file still needs reading, and where from.
213
+ * Returns null when there is nothing to do this pass.
214
+ */
215
+ function planRead(args, isWholeDoc, stat) {
216
+ const { store, file, known, result } = args;
217
+ const prior = known.get(file.path);
218
+ // Unchanged since last pass — nothing appended, skip without reading.
219
+ if (prior && stat.size === prior.size && Math.floor(stat.mtimeMs) === prior.mtimeMs) {
220
+ return null;
221
+ }
222
+ let start = prior?.offset ?? 0;
223
+ if (isWholeDoc) {
224
+ // Whole-document files are rewritten in place, so any change invalidates
225
+ // every chunk we hold for them. Drop and re-read from zero.
226
+ if (prior)
227
+ store.forgetFile(file.path);
228
+ start = 0;
229
+ }
230
+ else if (prior && stat.size < prior.offset) {
231
+ // Shrank: append-only assumption broken (rewritten or rotated).
232
+ store.forgetFile(file.path);
233
+ start = 0;
234
+ result.filesReset += 1;
235
+ }
236
+ return stat.size <= start ? null : start;
237
+ }
238
+ /** Parse the consumable text into chunks, resolving the session's true cwd. */
239
+ function buildChunks(args, consumable, isWholeDoc) {
240
+ const { adapter, file, result, log } = args;
241
+ // The cwd a hit is resumed into. Start from the slug-derived guess, but
242
+ // upgrade to the first authoritative cwd the transcript itself reports —
243
+ // the slug is a lossy encoding and decoding it yields directories that do
244
+ // not exist (`super-repo` → `super/repo`), which is precisely what makes a
245
+ // search hit unresumable.
246
+ let resolvedCwd = file.cwd;
247
+ let cwdIsAuthoritative = false;
248
+ const chunks = [];
249
+ for (const line of isWholeDoc ? [consumable] : consumable.split("\n")) {
250
+ if (line.trim().length === 0)
251
+ continue;
252
+ let record;
253
+ try {
254
+ record = JSON.parse(line);
255
+ }
256
+ catch {
257
+ continue; // Non-JSON noise, not an error worth counting.
258
+ }
259
+ let extracted;
260
+ try {
261
+ extracted = adapter.extract(record);
262
+ }
263
+ catch (err) {
264
+ result.errors += 1;
265
+ log?.debug({ err, path: file.path }, "session-index: extract failed");
266
+ continue;
267
+ }
268
+ if (!cwdIsAuthoritative && extracted.cwd) {
269
+ resolvedCwd = extracted.cwd;
270
+ cwdIsAuthoritative = true;
271
+ }
272
+ for (const block of extracted.blocks) {
273
+ chunks.push({
274
+ path: file.path,
275
+ sid: extracted.sid ?? file.sid,
276
+ source: file.source,
277
+ cwd: resolvedCwd,
278
+ timestamp: extracted.timestamp,
279
+ text: block.lane === "text" ? block.text : "",
280
+ tool: block.lane === "tool" ? block.text : "",
281
+ truncated: block.truncated,
282
+ });
283
+ }
284
+ }
285
+ return { chunks, resolvedCwd };
286
+ }
287
+ /** Read the pending tail of one file and commit its chunks. */
288
+ async function indexOneFile(args) {
289
+ const { store, adapter, file, result, maxBytes, log } = args;
290
+ let stat;
291
+ try {
292
+ stat = await fs.stat(file.path);
293
+ }
294
+ catch {
295
+ return;
296
+ }
297
+ const isWholeDoc = (adapter.format ?? "jsonl") === "json";
298
+ const start = planRead(args, isWholeDoc, stat);
299
+ if (start === null)
300
+ return;
301
+ const end = Math.min(stat.size, start + maxBytes);
302
+ let raw;
303
+ try {
304
+ raw = await readRange(file.path, start, end);
305
+ }
306
+ catch (err) {
307
+ result.errors += 1;
308
+ log?.warn({ err, path: file.path }, "session-index: read failed");
309
+ return;
310
+ }
311
+ result.bytesRead += end - start;
312
+ // JSONL: advance only to the last complete newline, so a mid-append fragment
313
+ // is left unconsumed and re-read intact on the next pass. Whole-doc: the
314
+ // entire read IS the record; there is nothing to defer.
315
+ let consumable;
316
+ if (isWholeDoc) {
317
+ consumable = raw;
318
+ }
319
+ else {
320
+ const lastNewline = raw.lastIndexOf("\n");
321
+ consumable = lastNewline >= 0 ? raw.slice(0, lastNewline + 1) : "";
322
+ }
323
+ const consumedBytes = Buffer.byteLength(consumable, "utf-8");
324
+ if (consumedBytes === 0)
325
+ return;
326
+ const { chunks, resolvedCwd } = buildChunks(args, consumable, isWholeDoc);
327
+ const nextState = {
328
+ path: file.path,
329
+ size: stat.size,
330
+ // Only claim the mtime as fully-consumed when we reached EOF; a capped
331
+ // read must stay "dirty" so the next pass continues the same file.
332
+ mtimeMs: start + consumedBytes >= stat.size ? Math.floor(stat.mtimeMs) : 0,
333
+ offset: start + consumedBytes,
334
+ };
335
+ try {
336
+ store.commitFile(nextState, file.source, file.sid, resolvedCwd, chunks);
337
+ result.filesUpdated += 1;
338
+ result.chunksAdded += chunks.length;
339
+ // A file capped by maxBytesPerFile is NOT fully searchable yet. Count it so
340
+ // "indexing complete" can never be claimed while tails are missing —
341
+ // silent partial coverage is the failure mode that makes a search box look
342
+ // like it works while quietly losing your history.
343
+ if (nextState.offset < stat.size)
344
+ result.filesPartial += 1;
345
+ }
346
+ catch (err) {
347
+ result.errors += 1;
348
+ log?.warn({ err, path: file.path }, "session-index: commit failed");
349
+ }
350
+ }
351
+ /**
352
+ * Default on-disk location for the index (a derived cache, safe to delete).
353
+ *
354
+ * MUST resolve through `resolvePaseoHome()` and not `os.homedir()`: a daemon
355
+ * started with PASEO_HOME pointing elsewhere (tests, a second instance, an
356
+ * isolated E2E run) would otherwise write its index into the real ~/.paseo and
357
+ * silently share state with the user's live daemon.
358
+ */
359
+ export function defaultIndexPath() {
360
+ return path.join(resolvePaseoHome(), "session-index.sqlite");
361
+ }
362
+ //# sourceMappingURL=indexer.js.map
@@ -0,0 +1,40 @@
1
+ export interface ResolveCwdDeps {
2
+ /** Override for tests. Defaults to `fs.stat` + isDirectory. */
3
+ dirExists?: (candidate: string) => Promise<boolean>;
4
+ /** Override the current home. Defaults to `os.homedir()`. */
5
+ homeDir?: string;
6
+ }
7
+ /**
8
+ * Split `/Users/claudinho/super-repo` into `{ home: "/Users/claudinho",
9
+ * rest: "super-repo" }`. Returns null when the path is not under a recognised
10
+ * home layout (`/opt/...`, a bare `/tmp/x`, a Windows path, …), in which case
11
+ * no remap is attempted.
12
+ */
13
+ export declare function splitHome(cwd: string): {
14
+ home: string;
15
+ rest: string;
16
+ } | null;
17
+ export interface ResolvedCwd {
18
+ /** The cwd to resume into. */
19
+ cwd: string;
20
+ /** How we got there — surfaced so the UI can be honest about a remap. */
21
+ resolution: "direct" | "remapped" | "unresolved";
22
+ }
23
+ /**
24
+ * Resolve a recorded cwd to one that exists on THIS machine.
25
+ *
26
+ * - `direct` — the recorded cwd exists; use it unchanged.
27
+ * - `remapped` — it did not exist, but the same relative path under the
28
+ * current home does. Resume there.
29
+ * - `unresolved` — neither exists. The original value is returned untouched;
30
+ * callers should surface it as not-resumable-here rather
31
+ * than silently resuming somewhere else.
32
+ */
33
+ export declare function resolveResumableCwd(recordedCwd: string, deps?: ResolveCwdDeps): Promise<ResolvedCwd>;
34
+ /**
35
+ * Batch resolver with a per-call cache. A search returns up to 100 hits that
36
+ * routinely share a handful of cwds, so this collapses ~100 stat storms into
37
+ * one stat per distinct directory.
38
+ */
39
+ export declare function createCwdResolver(deps?: ResolveCwdDeps): (recordedCwd: string) => Promise<ResolvedCwd>;
40
+ //# sourceMappingURL=resolve-cwd.d.ts.map
@@ -0,0 +1,109 @@
1
+ // MARRA CUSTOMIZATION — make a search hit's cwd actually resumable.
2
+ //
3
+ // A hit is only useful if clicking it reopens the session, and the palette
4
+ // resumes a session INTO its cwd. Two things broke that before:
5
+ //
6
+ // 1. A FABRICATED cwd. Claude's project-dir slug replaces every
7
+ // non-alphanumeric character with "-", so it is lossy and irreversible:
8
+ // `super-repo` and `super/repo` produce the same slug. Decoding it back
9
+ // yielded `/home/claudiao/super/repo`, which does not exist. Fixed
10
+ // upstream of this file — the indexer now reads the authoritative `cwd`
11
+ // out of the transcript records themselves.
12
+ //
13
+ // 2. A CROSS-MACHINE cwd. History moves between machines while paths do not.
14
+ // Measured on the real corpus: 3,465 of 6,970 Claude sessions carry a
15
+ // `/Users/claudinho/...` macOS path that has no meaning on this Linux
16
+ // box. Correct value, wrong machine — 48.2% resumable in place.
17
+ //
18
+ // This module handles (2): if the recorded cwd is absent but the same
19
+ // path under the CURRENT home exists, resume there. Measured effect on the
20
+ // real corpus: 48.2% → 96.1% resumable (3,341 sessions recovered, 269 left
21
+ // genuinely unresolvable — directories that exist on no machine here).
22
+ //
23
+ // Deliberately conservative: it only ever remaps when the target directory
24
+ // EXISTS. It never invents a path, and an unresolvable hit keeps its original
25
+ // cwd so the UI shows the truth rather than a plausible lie.
26
+ import { promises as fsp } from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+ /**
30
+ * Home-directory prefixes seen across this user's machines. Order matters
31
+ * only for determinism; the first candidate that EXISTS wins.
32
+ */
33
+ const KNOWN_HOME_PREFIXES = ["/Users", "/home", "/root"];
34
+ async function defaultDirExists(candidate) {
35
+ try {
36
+ const stats = await fsp.stat(candidate);
37
+ return stats.isDirectory();
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /**
44
+ * Split `/Users/claudinho/super-repo` into `{ home: "/Users/claudinho",
45
+ * rest: "super-repo" }`. Returns null when the path is not under a recognised
46
+ * home layout (`/opt/...`, a bare `/tmp/x`, a Windows path, …), in which case
47
+ * no remap is attempted.
48
+ */
49
+ export function splitHome(cwd) {
50
+ for (const prefix of KNOWN_HOME_PREFIXES) {
51
+ if (!cwd.startsWith(`${prefix}/`))
52
+ continue;
53
+ const after = cwd.slice(prefix.length + 1);
54
+ const slash = after.indexOf("/");
55
+ // `/Users/claudinho` with no trailing path — a home dir itself.
56
+ if (slash === -1)
57
+ return { home: `${prefix}/${after}`, rest: "" };
58
+ return { home: `${prefix}/${after.slice(0, slash)}`, rest: after.slice(slash + 1) };
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Resolve a recorded cwd to one that exists on THIS machine.
64
+ *
65
+ * - `direct` — the recorded cwd exists; use it unchanged.
66
+ * - `remapped` — it did not exist, but the same relative path under the
67
+ * current home does. Resume there.
68
+ * - `unresolved` — neither exists. The original value is returned untouched;
69
+ * callers should surface it as not-resumable-here rather
70
+ * than silently resuming somewhere else.
71
+ */
72
+ export async function resolveResumableCwd(recordedCwd, deps = {}) {
73
+ const cwd = recordedCwd?.trim() ?? "";
74
+ if (cwd.length === 0)
75
+ return { cwd: recordedCwd ?? "", resolution: "unresolved" };
76
+ const dirExists = deps.dirExists ?? defaultDirExists;
77
+ if (await dirExists(cwd))
78
+ return { cwd, resolution: "direct" };
79
+ const home = deps.homeDir ?? os.homedir();
80
+ const split = splitHome(cwd);
81
+ if (!split)
82
+ return { cwd, resolution: "unresolved" };
83
+ // Already under the current home and still missing — the directory is gone,
84
+ // not relocated. Remapping would produce the same failing path.
85
+ if (split.home === home)
86
+ return { cwd, resolution: "unresolved" };
87
+ const candidate = split.rest.length === 0 ? home : path.join(home, split.rest);
88
+ if (await dirExists(candidate))
89
+ return { cwd: candidate, resolution: "remapped" };
90
+ return { cwd, resolution: "unresolved" };
91
+ }
92
+ /**
93
+ * Batch resolver with a per-call cache. A search returns up to 100 hits that
94
+ * routinely share a handful of cwds, so this collapses ~100 stat storms into
95
+ * one stat per distinct directory.
96
+ */
97
+ export function createCwdResolver(deps = {}) {
98
+ const cache = new Map();
99
+ return (recordedCwd) => {
100
+ const key = recordedCwd ?? "";
101
+ const hit = cache.get(key);
102
+ if (hit)
103
+ return hit;
104
+ const pending = resolveResumableCwd(key, deps);
105
+ cache.set(key, pending);
106
+ return pending;
107
+ };
108
+ }
109
+ //# sourceMappingURL=resolve-cwd.js.map
@@ -0,0 +1,59 @@
1
+ import type pino from "pino";
2
+ import { type SourceAdapter } from "./indexer.js";
3
+ /** Matches the pre-existing recall contract so no transport or client changes. */
4
+ export interface SearchResultEntry {
5
+ sid: string;
6
+ source: string;
7
+ cwd: string;
8
+ timestamp: string;
9
+ snippet: string;
10
+ resumeCommand: string;
11
+ }
12
+ export interface SearchResultsResponse {
13
+ query: string;
14
+ results: SearchResultEntry[];
15
+ }
16
+ export interface SessionSearchServiceOptions {
17
+ indexPath?: string;
18
+ adapters?: SourceAdapter[];
19
+ logger?: pino.Logger;
20
+ /** Gap between background passes. Default 60s. */
21
+ intervalMs?: number;
22
+ /** Skip the timer (tests drive passes manually). */
23
+ manual?: boolean;
24
+ }
25
+ export declare class SessionSearchService {
26
+ private readonly store;
27
+ private readonly adapters;
28
+ private readonly logger;
29
+ private readonly intervalMs;
30
+ private timer;
31
+ private readonly abort;
32
+ private passInFlight;
33
+ /** False until the first pass finishes, so callers can be honest about warm-up. */
34
+ private warm;
35
+ constructor(options?: SessionSearchServiceOptions);
36
+ private start;
37
+ /** One indexing pass. Overlapping calls join the in-flight pass. */
38
+ runPass(): Promise<void>;
39
+ /** True once at least one indexing pass has completed. */
40
+ isWarm(): boolean;
41
+ stats(): import("./index-store.js").IndexStats;
42
+ /**
43
+ * Query the index and return hits in the transport's existing shape.
44
+ *
45
+ * Every hit's cwd is resolved to a directory that exists on THIS machine
46
+ * before it is returned, because the palette resumes a session into its cwd
47
+ * — a hit pointing at another machine's path is a result you cannot click.
48
+ */
49
+ search(params: {
50
+ query: string;
51
+ limit?: number;
52
+ sources?: string;
53
+ }): Promise<SearchResultsResponse>;
54
+ close(): void;
55
+ }
56
+ export declare function getSessionSearchService(options?: SessionSearchServiceOptions): SessionSearchService;
57
+ /** Test seam: drop the singleton so a fixture instance can take its place. */
58
+ export declare function resetSessionSearchServiceForTests(): void;
59
+ //# sourceMappingURL=session-search-service.d.ts.map