@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.
- package/dist/server/server/file-explorer/service.js +9 -0
- package/dist/server/server/search/extract.d.ts +53 -0
- package/dist/server/server/search/extract.js +222 -0
- package/dist/server/server/search/index-store.d.ts +83 -0
- package/dist/server/server/search/index-store.js +241 -0
- package/dist/server/server/search/indexer.d.ts +69 -0
- package/dist/server/server/search/indexer.js +362 -0
- package/dist/server/server/search/resolve-cwd.d.ts +40 -0
- package/dist/server/server/search/resolve-cwd.js +109 -0
- package/dist/server/server/search/session-search-service.d.ts +59 -0
- package/dist/server/server/search/session-search-service.js +171 -0
- package/dist/server/server/session.js +10 -7
- package/dist/server/server/workspace/recall-search-feature.js +25 -16
- package/dist/server/server/workspace/sessions-search-route.d.ts +18 -46
- package/dist/server/server/workspace/sessions-search-route.js +27 -163
- package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js → desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js} +5 -5
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-dd96011e23063ea32a8d93c8e75c11b7.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js → desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js} +3 -3
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-1526526b7e379374c85e4d816bbb7742.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-e2ffd4d4419cad7f5153525e95f645b8.js → index-fad30dd59837afbdf33b7d9190c754e9.js} +246 -245
- package/dist/server/web-ui/_expo/static/js/web/index-fad30dd59837afbdf33b7d9190c754e9.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-fad30dd59837afbdf33b7d9190c754e9.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-fad30dd59837afbdf33b7d9190c754e9.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-e2ffd4d4419cad7f5153525e95f645b8.js.map.gz → index-fad30dd59837afbdf33b7d9190c754e9.js.map.gz} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js → indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js} +3 -3
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-d12fb34cd3595fa34df2d42d9121d0d7.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js → native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js} +5 -5
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-c04241cce5bea0c64d4e31fea6e45288.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{profiler-8ca15c1659e234673816bc7bad0f88b6.js → profiler-24af32fdd64b094ad2dc78cf1625bd07.js} +11 -11
- package/dist/server/web-ui/_expo/static/js/web/profiler-24af32fdd64b094ad2dc78cf1625bd07.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-24af32fdd64b094ad2dc78cf1625bd07.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-24af32fdd64b094ad2dc78cf1625bd07.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{profiler-8ca15c1659e234673816bc7bad0f88b6.js.map.gz → profiler-24af32fdd64b094ad2dc78cf1625bd07.js.map.gz} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{startRecording-1a240d0212111d3eb294a9407e136e6a.js → startRecording-d113d3bf7d809773ff5c95198366c905.js} +60 -60
- package/dist/server/web-ui/_expo/static/js/web/startRecording-d113d3bf7d809773ff5c95198366c905.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-d113d3bf7d809773ff5c95198366c905.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-d113d3bf7d809773ff5c95198366c905.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{startRecording-1a240d0212111d3eb294a9407e136e6a.js.map.gz → startRecording-d113d3bf7d809773ff5c95198366c905.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +2 -2
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-bridge-998e1be07f321f26e398086e261fbee3.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/desktop-attachment-store-7e067a0e3c8d6639d94a1ce72c9ec0d2.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-e2ffd4d4419cad7f5153525e95f645b8.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-e2ffd4d4419cad7f5153525e95f645b8.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-e2ffd4d4419cad7f5153525e95f645b8.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/indexeddb-attachment-store-7fead1a370b17c2a180153dbfdd444f3.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/native-file-attachment-store-8a937b8ecc2b5546131709e2bdda97cb.js.map.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-8ca15c1659e234673816bc7bad0f88b6.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-8ca15c1659e234673816bc7bad0f88b6.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/profiler-8ca15c1659e234673816bc7bad0f88b6.js.map.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-1a240d0212111d3eb294a9407e136e6a.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/startRecording-1a240d0212111d3eb294a9407e136e6a.js.gz +0 -0
- 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
|