@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
@@ -5,6 +5,15 @@ import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
5
5
  import { IMAGE_MIME_TYPES, isImageMimeType, resolveBinaryMimeType } from "./mime-types.js";
6
6
  const TEXT_MIME_TYPES = {
7
7
  ".json": "application/json",
8
+ // HTML is textual, so it classifies as `kind: "text"` and its bytes arrive as UTF-8.
9
+ // Naming the mime honestly is what lets the client render it instead of showing source.
10
+ ".html": "text/html",
11
+ ".htm": "text/html",
12
+ ".xhtml": "application/xhtml+xml",
13
+ ".css": "text/css",
14
+ ".csv": "text/csv",
15
+ ".xml": "application/xml",
16
+ ".md": "text/markdown",
8
17
  };
9
18
  const DEFAULT_TEXT_MIME_TYPE = "text/plain";
10
19
  const FILE_TYPE_SAMPLE_BYTES = 8192;
@@ -0,0 +1,53 @@
1
+ /** Max characters indexed from a single tool input/result block. */
2
+ export declare const TOOL_BLOCK_CAP: number;
3
+ /** Which FTS column a block feeds. */
4
+ export type Lane = "text" | "tool";
5
+ export interface ExtractedBlock {
6
+ lane: Lane;
7
+ /** "user" | "assistant" | "system" | "tool" — informational, not indexed. */
8
+ role: string;
9
+ text: string;
10
+ /** True when the source payload exceeded TOOL_BLOCK_CAP and was cut. */
11
+ truncated: boolean;
12
+ }
13
+ export interface ExtractedRecord {
14
+ blocks: ExtractedBlock[];
15
+ /** ISO timestamp for the record, when the provider supplies one. */
16
+ timestamp: string | null;
17
+ /** Session id carried in the record, when the provider supplies one. */
18
+ sid: string | null;
19
+ /**
20
+ * The TRUE working directory, read from the record itself.
21
+ *
22
+ * This must never be reverse-engineered from Claude's project-dir slug.
23
+ * The slug replaces every non-alphanumeric character with "-", so it is a
24
+ * lossy, irreversible encoding: `/home/claudiao/super-repo` and
25
+ * `/home/claudiao/super/repo` produce the SAME slug. Decoding it back
26
+ * yields `super/repo`, a directory that does not exist — and since the
27
+ * command palette resumes a session INTO its cwd, a wrong cwd makes the
28
+ * hit unresumable. That is the defect that made the old search results
29
+ * unusable even when they were correct.
30
+ *
31
+ * Slug-derived cwd is kept only as a last-resort display fallback for
32
+ * transcripts that carry no cwd at all.
33
+ */
34
+ cwd: string | null;
35
+ }
36
+ export declare function extractClaudeRecord(record: unknown): ExtractedRecord;
37
+ export declare function extractCodexRecord(record: unknown): ExtractedRecord;
38
+ export interface PaseoAgentMeta {
39
+ agentId: string;
40
+ provider: string | null;
41
+ cwd: string | null;
42
+ title: string | null;
43
+ /** runtimeInfo.sessionId — the join key into the provider's transcript. */
44
+ linkedSessionId: string | null;
45
+ workspaceId: string | null;
46
+ labels: Record<string, string>;
47
+ updatedAt: string | null;
48
+ }
49
+ export declare function extractPaseoAgent(record: unknown): {
50
+ meta: PaseoAgentMeta | null;
51
+ blocks: ExtractedBlock[];
52
+ };
53
+ //# sourceMappingURL=extract.d.ts.map
@@ -0,0 +1,222 @@
1
+ // MARRA CUSTOMIZATION — universal transcript text extraction for session FTS.
2
+ //
3
+ // One job: turn a raw transcript record (whatever the provider's shape) into
4
+ // zero or more `ExtractedBlock`s of searchable text. Nothing here touches the
5
+ // filesystem or SQLite, so it is trivially unit-testable per provider.
6
+ //
7
+ // Two text lanes, deliberately kept apart:
8
+ // • "text" — prose a human or the assistant actually wrote.
9
+ // • "tool" — tool names, tool inputs, tool results.
10
+ // They land in separate FTS5 columns so the ranker can weight typed prose
11
+ // above tool spew. Searching a phrase you typed should surface the message
12
+ // where you typed it, not the 400 grep hits that happen to contain it.
13
+ //
14
+ // Tool payloads are capped per block (see TOOL_BLOCK_CAP). Measured on the
15
+ // real 4.6 GB corpus: tool traffic is 92% of all indexable text, and capping
16
+ // at 8 KB cuts it 64% (1.12 GB → 0.40 GB) while keeping the identifying head
17
+ // of every result — the command, the path, the first ~100 lines of output.
18
+ // Nobody finds anything by searching the 700th KB of a file dump. Blocks that
19
+ // were cut set `truncated`, so coverage claims stay honest.
20
+ /** Max characters indexed from a single tool input/result block. */
21
+ export const TOOL_BLOCK_CAP = 8 * 1024;
22
+ const EMPTY = { blocks: [], timestamp: null, sid: null, cwd: null };
23
+ function cap(raw) {
24
+ if (raw.length <= TOOL_BLOCK_CAP)
25
+ return { text: raw, truncated: false };
26
+ return { text: raw.slice(0, TOOL_BLOCK_CAP), truncated: true };
27
+ }
28
+ /** JSON.stringify that never throws (cycles, BigInt, exotic values). */
29
+ function safeStringify(value) {
30
+ if (typeof value === "string")
31
+ return value;
32
+ if (value === null || value === undefined)
33
+ return "";
34
+ try {
35
+ return JSON.stringify(value) ?? "";
36
+ }
37
+ catch {
38
+ return String(value);
39
+ }
40
+ }
41
+ function pushProse(out, role, text) {
42
+ if (typeof text !== "string")
43
+ return;
44
+ const trimmed = text.trim();
45
+ if (trimmed.length === 0)
46
+ return;
47
+ out.push({ lane: "text", role, text: trimmed, truncated: false });
48
+ }
49
+ function pushTool(out, role, raw) {
50
+ const trimmed = raw.trim();
51
+ if (trimmed.length === 0)
52
+ return;
53
+ const { text, truncated } = cap(trimmed);
54
+ out.push({ lane: "tool", role, text, truncated });
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Claude Code — ~/.claude/projects/<cwd-slug>/<sid>.jsonl
58
+ // ---------------------------------------------------------------------------
59
+ //
60
+ // One JSON object per line. Conversation records carry `message: { role,
61
+ // content }` where content is a string or an array of Anthropic content
62
+ // blocks. Non-conversation lines (hook attachments, queue-operation,
63
+ // custom-title) have no `message` and are skipped — they are 74% of the raw
64
+ // bytes and contain nothing a human typed.
65
+ export function extractClaudeRecord(record) {
66
+ if (typeof record !== "object" || record === null)
67
+ return EMPTY;
68
+ const rec = record;
69
+ const message = rec.message;
70
+ if (typeof message !== "object" || message === null)
71
+ return EMPTY;
72
+ const msg = message;
73
+ const role = typeof msg.role === "string" ? msg.role : "unknown";
74
+ const timestamp = typeof rec.timestamp === "string" ? rec.timestamp : null;
75
+ const sid = typeof rec.sessionId === "string" ? rec.sessionId : null;
76
+ // The authoritative cwd. Present on ~74% of records; the indexer keeps the
77
+ // first non-null it sees per file.
78
+ const cwd = typeof rec.cwd === "string" && rec.cwd.length > 0 ? rec.cwd : null;
79
+ const blocks = [];
80
+ const content = msg.content;
81
+ if (typeof content === "string") {
82
+ pushProse(blocks, role, content);
83
+ return { blocks, timestamp, sid, cwd };
84
+ }
85
+ if (!Array.isArray(content))
86
+ return { blocks, timestamp, sid, cwd };
87
+ for (const block of content) {
88
+ pushClaudeContentBlock(blocks, role, block);
89
+ }
90
+ return { blocks, timestamp, sid, cwd };
91
+ }
92
+ /** Route a single Anthropic content block into the right lane. */
93
+ function pushClaudeContentBlock(out, role, block) {
94
+ // Some records carry bare strings inside the content array.
95
+ if (typeof block === "string") {
96
+ pushProse(out, role, block);
97
+ return;
98
+ }
99
+ if (typeof block !== "object" || block === null)
100
+ return;
101
+ const b = block;
102
+ switch (b.type) {
103
+ case "text":
104
+ pushProse(out, role, b.text);
105
+ return;
106
+ case "thinking":
107
+ pushProse(out, "thinking", b.thinking);
108
+ return;
109
+ case "tool_use": {
110
+ const name = typeof b.name === "string" ? b.name : "tool";
111
+ pushTool(out, "tool_use", `${name} ${safeStringify(b.input)}`);
112
+ return;
113
+ }
114
+ case "tool_result":
115
+ pushTool(out, "tool_result", safeStringify(b.content));
116
+ return;
117
+ default:
118
+ return;
119
+ }
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Codex CLI — ~/.codex/sessions/**/rollout-*.jsonl
123
+ // ---------------------------------------------------------------------------
124
+ //
125
+ // UNVERIFIED AGAINST REAL DATA. `~/.codex/` on the dev box holds only
126
+ // `prompts/` — zero session rollouts — so this adapter is unit-tested against
127
+ // a fixture and MUST be re-validated the first time a real Codex session
128
+ // exists. It is written defensively: unknown shapes yield no blocks rather
129
+ // than garbage.
130
+ //
131
+ // Codex rollout lines wrap a payload: `{ type, payload: { type, role,
132
+ // content: [{ type: "input_text" | "output_text", text }] } }`, with function
133
+ // calls as `function_call` / `function_call_output`.
134
+ export function extractCodexRecord(record) {
135
+ if (typeof record !== "object" || record === null)
136
+ return EMPTY;
137
+ const rec = record;
138
+ const payloadRaw = typeof rec.payload === "object" && rec.payload !== null ? rec.payload : rec;
139
+ const p = payloadRaw;
140
+ const timestamp = typeof rec.timestamp === "string" ? rec.timestamp : null;
141
+ const sid = typeof rec.session_id === "string" ? rec.session_id : null;
142
+ const cwd = typeof rec.cwd === "string" && rec.cwd.length > 0 ? rec.cwd : null;
143
+ const role = typeof p.role === "string" ? p.role : "unknown";
144
+ const blocks = [];
145
+ pushCodexPayload(blocks, role, p);
146
+ return { blocks, timestamp, sid, cwd };
147
+ }
148
+ /** Route one Codex rollout payload into the right lane. */
149
+ function pushCodexPayload(out, role, p) {
150
+ switch (typeof p.type === "string" ? p.type : "") {
151
+ case "function_call": {
152
+ const name = typeof p.name === "string" ? p.name : "tool";
153
+ pushTool(out, "tool_use", `${name} ${safeStringify(p.arguments)}`);
154
+ return;
155
+ }
156
+ case "function_call_output":
157
+ pushTool(out, "tool_result", safeStringify(p.output));
158
+ return;
159
+ case "reasoning":
160
+ for (const s of Array.isArray(p.summary) ? p.summary : []) {
161
+ if (typeof s === "object" && s !== null) {
162
+ pushProse(out, "thinking", s.text);
163
+ }
164
+ }
165
+ return;
166
+ default:
167
+ pushCodexContent(out, role, p.content);
168
+ }
169
+ }
170
+ function pushCodexContent(out, role, content) {
171
+ if (typeof content === "string") {
172
+ pushProse(out, role, content);
173
+ return;
174
+ }
175
+ if (!Array.isArray(content))
176
+ return;
177
+ for (const block of content) {
178
+ if (typeof block === "string") {
179
+ pushProse(out, role, block);
180
+ continue;
181
+ }
182
+ if (typeof block !== "object" || block === null)
183
+ continue;
184
+ // input_text / output_text / text all carry `.text`.
185
+ pushProse(out, role, block.text);
186
+ }
187
+ }
188
+ export function extractPaseoAgent(record) {
189
+ if (typeof record !== "object" || record === null)
190
+ return { meta: null, blocks: [] };
191
+ const r = record;
192
+ const agentId = typeof r.id === "string" ? r.id : null;
193
+ if (!agentId)
194
+ return { meta: null, blocks: [] };
195
+ const runtime = typeof r.runtimeInfo === "object" && r.runtimeInfo !== null
196
+ ? r.runtimeInfo
197
+ : {};
198
+ const rawLabels = typeof r.labels === "object" && r.labels !== null ? r.labels : {};
199
+ const labels = {};
200
+ for (const [k, v] of Object.entries(rawLabels)) {
201
+ if (typeof v === "string")
202
+ labels[k] = v;
203
+ }
204
+ const meta = {
205
+ agentId,
206
+ provider: typeof r.provider === "string" ? r.provider : null,
207
+ cwd: typeof r.cwd === "string" ? r.cwd : null,
208
+ title: typeof r.title === "string" ? r.title : null,
209
+ linkedSessionId: typeof runtime.sessionId === "string" ? runtime.sessionId : null,
210
+ workspaceId: typeof r.workspaceId === "string" ? r.workspaceId : null,
211
+ labels,
212
+ updatedAt: typeof r.updatedAt === "string" ? r.updatedAt : null,
213
+ };
214
+ const blocks = [];
215
+ pushProse(blocks, "title", meta.title);
216
+ const labelText = Object.entries(labels)
217
+ .map(([k, v]) => `${k}:${v}`)
218
+ .join(" ");
219
+ pushProse(blocks, "labels", labelText);
220
+ return { meta, blocks };
221
+ }
222
+ //# sourceMappingURL=extract.js.map
@@ -0,0 +1,83 @@
1
+ /** Bump when the schema or extraction changes in a way that invalidates rows. */
2
+ export declare const INDEX_SCHEMA_VERSION = 1;
3
+ export interface FileState {
4
+ path: string;
5
+ size: number;
6
+ mtimeMs: number;
7
+ /** Bytes already consumed. Next pass reads from here. */
8
+ offset: number;
9
+ }
10
+ export interface ChunkInput {
11
+ path: string;
12
+ sid: string;
13
+ source: string;
14
+ cwd: string;
15
+ timestamp: string | null;
16
+ /** Typed prose. Empty string when this chunk is tool-only. */
17
+ text: string;
18
+ /** Tool names, inputs, results. Empty string when prose-only. */
19
+ tool: string;
20
+ truncated: boolean;
21
+ }
22
+ export interface SearchHit {
23
+ sid: string;
24
+ source: string;
25
+ cwd: string;
26
+ timestamp: string;
27
+ snippet: string;
28
+ path: string;
29
+ /** True when the winning snippet came from tool traffic, not typed prose. */
30
+ fromTool: boolean;
31
+ score: number;
32
+ }
33
+ export interface IndexStats {
34
+ files: number;
35
+ chunks: number;
36
+ truncatedChunks: number;
37
+ bytesConsumed: number;
38
+ }
39
+ export declare class SessionIndexStore {
40
+ private readonly db;
41
+ private insertChunkStmt;
42
+ private upsertFileStmt;
43
+ constructor(dbPath: string, options?: {
44
+ readOnly?: boolean;
45
+ });
46
+ private migrate;
47
+ /** Byte offsets already consumed, keyed by path. */
48
+ getFileStates(): Map<string, FileState>;
49
+ /**
50
+ * Drop every chunk for a file. Used when a transcript is rewritten or
51
+ * truncated (size shrank), where the append-only assumption no longer holds
52
+ * and resuming from the old offset would index garbage.
53
+ */
54
+ forgetFile(path: string): void;
55
+ /** Drop files that no longer exist on disk, so search never offers a dead session. */
56
+ pruneMissing(existingPaths: Set<string>): number;
57
+ /** Append chunks and record the new offset for a file, atomically. */
58
+ commitFile(state: FileState, source: string, sid: string, cwd: string, chunks: ChunkInput[]): void;
59
+ stats(): IndexStats;
60
+ /**
61
+ * FTS5 MATCH over both lanes, ranked with typed prose weighted above tool
62
+ * traffic. Returns at most one hit per session (the best-scoring chunk), so
63
+ * the palette shows 20 distinct sessions rather than 20 hits in one file.
64
+ */
65
+ search(params: {
66
+ query: string;
67
+ limit?: number;
68
+ sources?: string[];
69
+ }): SearchHit[];
70
+ close(): void;
71
+ }
72
+ /**
73
+ * Turn user input into a safe FTS5 MATCH expression.
74
+ *
75
+ * Users type prose, not query DSL. Raw input reaches FTS5's parser and a bare
76
+ * `-` or an unbalanced `"` throws mid-keystroke, which reads as "search is
77
+ * broken". Every token is quoted (making it a literal) and multi-token input
78
+ * becomes an implicit AND, which is what people expect from a search box.
79
+ * A trailing `*` on the last token gives prefix matching so results appear
80
+ * while still typing.
81
+ */
82
+ export declare function toMatchExpression(raw: string): string | null;
83
+ //# sourceMappingURL=index-store.d.ts.map
@@ -0,0 +1,241 @@
1
+ // MARRA CUSTOMIZATION — SQLite FTS5 store for universal session search.
2
+ //
3
+ // Replaces the `recall` CLI, which failed structurally rather than by bad
4
+ // tuning: it spawned a subprocess per keystroke, re-indexed its whole Tantivy
5
+ // store on every spawn, and took a global writer lock while doing it. On a
6
+ // loaded host (39-agent fleet, load ~100) every cmd+K keystroke timed out, so
7
+ // the feature was disabled by default in 73ae98f3.
8
+ //
9
+ // All three of those go away here:
10
+ // • no subprocess — node:sqlite is in-process (verified FTS5 on v24)
11
+ // • no reindex-per-query — writes happen only in the background indexer
12
+ // • no global lock — WAL, and the query path is read-only
13
+ //
14
+ // Schema notes:
15
+ // `files` — one row per transcript file, carrying the byte offset we have
16
+ // consumed. Transcripts are append-only, so a pass reads only
17
+ // [offset, EOF). The 214 MB monster is read once, ever.
18
+ // `chunks` — FTS5, two indexed columns so typed prose can outrank tool spew.
19
+ // `unicode61 remove_diacritics 2` is required, not cosmetic:
20
+ // without it "periodo" does not match "Período" and PT-BR search
21
+ // silently half-works.
22
+ import { DatabaseSync } from "node:sqlite";
23
+ /** Bump when the schema or extraction changes in a way that invalidates rows. */
24
+ export const INDEX_SCHEMA_VERSION = 1;
25
+ /** bm25 weights: typed prose counts 10x a tool payload hit. */
26
+ const WEIGHT_TEXT = 10.0;
27
+ const WEIGHT_TOOL = 1.0;
28
+ const MIN_QUERY_LEN = 2;
29
+ const DEFAULT_LIMIT = 20;
30
+ const MAX_LIMIT = 100;
31
+ export class SessionIndexStore {
32
+ constructor(dbPath, options = {}) {
33
+ this.insertChunkStmt = null;
34
+ this.upsertFileStmt = null;
35
+ this.db = new DatabaseSync(dbPath, options.readOnly ? { readOnly: true } : {});
36
+ if (!options.readOnly) {
37
+ this.db.exec("PRAGMA journal_mode = WAL");
38
+ // Durability is not worth the fsync cost here: the index is a derived
39
+ // cache. A torn write costs a re-index of that file, nothing more.
40
+ this.db.exec("PRAGMA synchronous = NORMAL");
41
+ this.migrate();
42
+ }
43
+ }
44
+ migrate() {
45
+ this.db.exec(`
46
+ CREATE TABLE IF NOT EXISTS meta (
47
+ key TEXT PRIMARY KEY,
48
+ value TEXT NOT NULL
49
+ );
50
+ CREATE TABLE IF NOT EXISTS files (
51
+ path TEXT PRIMARY KEY,
52
+ source TEXT NOT NULL,
53
+ sid TEXT NOT NULL,
54
+ cwd TEXT NOT NULL,
55
+ size INTEGER NOT NULL,
56
+ mtime INTEGER NOT NULL,
57
+ offset INTEGER NOT NULL
58
+ );
59
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
60
+ text,
61
+ tool,
62
+ path UNINDEXED,
63
+ sid UNINDEXED,
64
+ source UNINDEXED,
65
+ cwd UNINDEXED,
66
+ ts UNINDEXED,
67
+ truncated UNINDEXED,
68
+ tokenize = 'unicode61 remove_diacritics 2'
69
+ );
70
+ `);
71
+ const row = this.db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
72
+ const current = row ? Number.parseInt(row.value, 10) : 0;
73
+ if (current !== INDEX_SCHEMA_VERSION) {
74
+ // Derived data — a schema change just rebuilds rather than migrating.
75
+ this.db.exec("DELETE FROM chunks; DELETE FROM files;");
76
+ this.db
77
+ .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)")
78
+ .run(String(INDEX_SCHEMA_VERSION));
79
+ }
80
+ }
81
+ /** Byte offsets already consumed, keyed by path. */
82
+ getFileStates() {
83
+ const rows = this.db.prepare("SELECT path, size, mtime, offset FROM files").all();
84
+ const map = new Map();
85
+ for (const r of rows) {
86
+ map.set(r.path, { path: r.path, size: r.size, mtimeMs: r.mtime, offset: r.offset });
87
+ }
88
+ return map;
89
+ }
90
+ /**
91
+ * Drop every chunk for a file. Used when a transcript is rewritten or
92
+ * truncated (size shrank), where the append-only assumption no longer holds
93
+ * and resuming from the old offset would index garbage.
94
+ */
95
+ forgetFile(path) {
96
+ this.db.prepare("DELETE FROM chunks WHERE path = ?").run(path);
97
+ this.db.prepare("DELETE FROM files WHERE path = ?").run(path);
98
+ }
99
+ /** Drop files that no longer exist on disk, so search never offers a dead session. */
100
+ pruneMissing(existingPaths) {
101
+ const rows = this.db.prepare("SELECT path FROM files").all();
102
+ let dropped = 0;
103
+ for (const r of rows) {
104
+ if (!existingPaths.has(r.path)) {
105
+ this.forgetFile(r.path);
106
+ dropped += 1;
107
+ }
108
+ }
109
+ return dropped;
110
+ }
111
+ /** Append chunks and record the new offset for a file, atomically. */
112
+ commitFile(state, source, sid, cwd, chunks) {
113
+ this.insertChunkStmt ?? (this.insertChunkStmt = this.db.prepare(`INSERT INTO chunks (text, tool, path, sid, source, cwd, ts, truncated)
114
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`));
115
+ this.upsertFileStmt ?? (this.upsertFileStmt = this.db.prepare(`INSERT INTO files (path, source, sid, cwd, size, mtime, offset)
116
+ VALUES (?, ?, ?, ?, ?, ?, ?)
117
+ ON CONFLICT(path) DO UPDATE SET
118
+ size = excluded.size, mtime = excluded.mtime, offset = excluded.offset`));
119
+ this.db.exec("BEGIN");
120
+ try {
121
+ for (const c of chunks) {
122
+ this.insertChunkStmt.run(c.text, c.tool, c.path, c.sid, c.source, c.cwd, c.timestamp ?? "", c.truncated ? 1 : 0);
123
+ }
124
+ this.upsertFileStmt.run(state.path, source, sid, cwd, state.size, Math.floor(state.mtimeMs), state.offset);
125
+ this.db.exec("COMMIT");
126
+ }
127
+ catch (err) {
128
+ this.db.exec("ROLLBACK");
129
+ throw err;
130
+ }
131
+ }
132
+ stats() {
133
+ const f = this.db.prepare("SELECT COUNT(*) c, COALESCE(SUM(offset),0) b FROM files").get();
134
+ const ch = this.db.prepare("SELECT COUNT(*) c FROM chunks").get();
135
+ // FTS5 stores every column value as text, so the integer 1 written by
136
+ // commitFile comes back as '1' or 1 depending on the binding path. CAST
137
+ // makes the comparison independent of that — without it this counter
138
+ // silently reads 0 and we would under-report truncation.
139
+ const tr = this.db
140
+ .prepare("SELECT COUNT(*) c FROM chunks WHERE CAST(truncated AS INTEGER) = 1")
141
+ .get();
142
+ return { files: f.c, chunks: ch.c, truncatedChunks: tr.c, bytesConsumed: f.b };
143
+ }
144
+ /**
145
+ * FTS5 MATCH over both lanes, ranked with typed prose weighted above tool
146
+ * traffic. Returns at most one hit per session (the best-scoring chunk), so
147
+ * the palette shows 20 distinct sessions rather than 20 hits in one file.
148
+ */
149
+ search(params) {
150
+ const query = params.query.trim();
151
+ if (query.length < MIN_QUERY_LEN)
152
+ return [];
153
+ const limit = Math.min(Math.max(params.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
154
+ const matchExpr = toMatchExpression(query);
155
+ if (!matchExpr)
156
+ return [];
157
+ const sourceFilter = params.sources && params.sources.length > 0
158
+ ? ` AND source IN (${params.sources.map(() => "?").join(",")})`
159
+ : "";
160
+ // Rank first, then pick the best chunk per session. Over-fetch so that a
161
+ // single chatty session cannot crowd out the rest of the result set.
162
+ const sql = `
163
+ SELECT sid, source, cwd, ts,
164
+ snippet(chunks, 0, '‹', '›', '…', 14) AS snip_text,
165
+ snippet(chunks, 1, '‹', '›', '…', 14) AS snip_tool,
166
+ path,
167
+ bm25(chunks, ${WEIGHT_TEXT}, ${WEIGHT_TOOL}) AS score
168
+ FROM chunks
169
+ WHERE chunks MATCH ?${sourceFilter}
170
+ ORDER BY score
171
+ LIMIT ?
172
+ `;
173
+ const args = [matchExpr];
174
+ if (params.sources && params.sources.length > 0)
175
+ args.push(...params.sources);
176
+ args.push(limit * 20);
177
+ let rows;
178
+ try {
179
+ rows = this.db.prepare(sql).all(...args);
180
+ }
181
+ catch {
182
+ // Malformed MATCH expression (unbalanced quote mid-typing, etc.) — an
183
+ // empty result is the honest answer, not a 500 in the palette.
184
+ return [];
185
+ }
186
+ const bySession = new Map();
187
+ for (const r of rows) {
188
+ const sid = String(r.sid ?? "");
189
+ if (!sid || bySession.has(sid))
190
+ continue;
191
+ bySession.set(sid, toSearchHit(sid, r));
192
+ if (bySession.size >= limit)
193
+ break;
194
+ }
195
+ return [...bySession.values()];
196
+ }
197
+ close() {
198
+ this.db.close();
199
+ }
200
+ }
201
+ /**
202
+ * Map one ranked FTS5 row to a hit. The prose snippet wins when the term
203
+ * appeared in typed text; the tool snippet is the fallback, flagged so the UI
204
+ * can say where the match came from.
205
+ */
206
+ function toSearchHit(sid, r) {
207
+ const snipText = String(r.snip_text ?? "").trim();
208
+ const snipTool = String(r.snip_tool ?? "").trim();
209
+ const fromTool = snipText.length === 0;
210
+ return {
211
+ sid,
212
+ source: String(r.source ?? ""),
213
+ cwd: String(r.cwd ?? ""),
214
+ timestamp: String(r.ts ?? ""),
215
+ snippet: (fromTool ? snipTool : snipText).replace(/\s+/g, " ").slice(0, 300),
216
+ path: String(r.path ?? ""),
217
+ fromTool,
218
+ score: Number(r.score ?? 0),
219
+ };
220
+ }
221
+ /**
222
+ * Turn user input into a safe FTS5 MATCH expression.
223
+ *
224
+ * Users type prose, not query DSL. Raw input reaches FTS5's parser and a bare
225
+ * `-` or an unbalanced `"` throws mid-keystroke, which reads as "search is
226
+ * broken". Every token is quoted (making it a literal) and multi-token input
227
+ * becomes an implicit AND, which is what people expect from a search box.
228
+ * A trailing `*` on the last token gives prefix matching so results appear
229
+ * while still typing.
230
+ */
231
+ export function toMatchExpression(raw) {
232
+ const tokens = raw
233
+ .trim()
234
+ .split(/\s+/)
235
+ .map((t) => t.replace(/"/g, ""))
236
+ .filter((t) => t.length > 0);
237
+ if (tokens.length === 0)
238
+ return null;
239
+ return tokens.map((t, i) => (i === tokens.length - 1 ? `"${t}"*` : `"${t}"`)).join(" AND ");
240
+ }
241
+ //# sourceMappingURL=index-store.js.map
@@ -0,0 +1,69 @@
1
+ import type pino from "pino";
2
+ import { SessionIndexStore } from "./index-store.js";
3
+ import { type ExtractedRecord } from "./extract.js";
4
+ export interface DiscoveredFile {
5
+ path: string;
6
+ source: string;
7
+ sid: string;
8
+ cwd: string;
9
+ }
10
+ /**
11
+ * How a provider lays out a file on disk.
12
+ *
13
+ * "jsonl" — append-only, one JSON record per line. Incremental byte offsets
14
+ * apply; this is the case that makes a 214 MB transcript cheap.
15
+ * "json" — a single pretty-printed JSON document per file, REWRITTEN in
16
+ * place on every update (paseo agent records are 46 lines each).
17
+ * Byte offsets are meaningless here: a partial read is invalid JSON,
18
+ * so these files are always parsed whole and replaced wholesale.
19
+ */
20
+ export type SourceFormat = "jsonl" | "json";
21
+ export interface SourceAdapter {
22
+ name: string;
23
+ /** On-disk layout. Defaults to "jsonl". */
24
+ format?: SourceFormat;
25
+ /** Enumerate transcript files for this provider. Missing root → []. */
26
+ discover(): Promise<DiscoveredFile[]>;
27
+ /** Parse one raw record into extractable blocks. */
28
+ extract(record: unknown): ExtractedRecord;
29
+ }
30
+ export declare function createClaudeAdapter(root?: string): SourceAdapter;
31
+ export declare function createCodexAdapter(root?: string): SourceAdapter;
32
+ export declare function createPaseoAdapter(root?: string): SourceAdapter;
33
+ export declare function defaultAdapters(): SourceAdapter[];
34
+ export interface IndexPassResult {
35
+ /** Files left mid-read because they hit maxBytesPerFile this pass. */
36
+ filesPartial: number;
37
+ filesScanned: number;
38
+ filesUpdated: number;
39
+ filesReset: number;
40
+ filesPruned: number;
41
+ bytesRead: number;
42
+ chunksAdded: number;
43
+ errors: number;
44
+ durationMs: number;
45
+ }
46
+ export interface IndexPassOptions {
47
+ adapters?: SourceAdapter[];
48
+ logger?: pino.Logger;
49
+ /** Abort cooperatively between files (daemon shutdown). */
50
+ signal?: AbortSignal;
51
+ /** Bytes per file per pass. Keeps the first pass over a 214 MB file bounded. */
52
+ maxBytesPerFile?: number;
53
+ }
54
+ /**
55
+ * One incremental pass over every discovered transcript. Safe to call
56
+ * repeatedly; work is proportional to bytes appended since the last call, not
57
+ * to corpus size.
58
+ */
59
+ export declare function runIndexPass(store: SessionIndexStore, options?: IndexPassOptions): Promise<IndexPassResult>;
60
+ /**
61
+ * Default on-disk location for the index (a derived cache, safe to delete).
62
+ *
63
+ * MUST resolve through `resolvePaseoHome()` and not `os.homedir()`: a daemon
64
+ * started with PASEO_HOME pointing elsewhere (tests, a second instance, an
65
+ * isolated E2E run) would otherwise write its index into the real ~/.paseo and
66
+ * silently share state with the user's live daemon.
67
+ */
68
+ export declare function defaultIndexPath(): string;
69
+ //# sourceMappingURL=indexer.d.ts.map