memonaut 0.0.0 → 0.2.0

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 (81) hide show
  1. package/LICENSE +661 -0
  2. package/dist/cli-main.d.ts +14 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +551 -0
  5. package/dist/cli-main.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +16 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config.d.ts +38 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +86 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/db.d.ts +37 -0
  15. package/dist/db.d.ts.map +1 -0
  16. package/dist/db.js +184 -0
  17. package/dist/db.js.map +1 -0
  18. package/dist/format.d.ts +22 -0
  19. package/dist/format.d.ts.map +1 -0
  20. package/dist/format.js +143 -0
  21. package/dist/format.js.map +1 -0
  22. package/dist/glob.d.ts +17 -0
  23. package/dist/glob.d.ts.map +1 -0
  24. package/dist/glob.js +77 -0
  25. package/dist/glob.js.map +1 -0
  26. package/dist/index.d.ts +13 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +15 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/indexer.d.ts +50 -0
  31. package/dist/indexer.d.ts.map +1 -0
  32. package/dist/indexer.js +404 -0
  33. package/dist/indexer.js.map +1 -0
  34. package/dist/lineage.d.ts +31 -0
  35. package/dist/lineage.d.ts.map +1 -0
  36. package/dist/lineage.js +94 -0
  37. package/dist/lineage.js.map +1 -0
  38. package/dist/model.d.ts +121 -0
  39. package/dist/model.d.ts.map +1 -0
  40. package/dist/model.js +41 -0
  41. package/dist/model.js.map +1 -0
  42. package/dist/pi-source.d.ts +47 -0
  43. package/dist/pi-source.d.ts.map +1 -0
  44. package/dist/pi-source.js +309 -0
  45. package/dist/pi-source.js.map +1 -0
  46. package/dist/quiet.d.ts +7 -0
  47. package/dist/quiet.d.ts.map +1 -0
  48. package/dist/quiet.js +19 -0
  49. package/dist/quiet.js.map +1 -0
  50. package/dist/regex.d.ts +75 -0
  51. package/dist/regex.d.ts.map +1 -0
  52. package/dist/regex.js +242 -0
  53. package/dist/regex.js.map +1 -0
  54. package/dist/ripgrep.d.ts +52 -0
  55. package/dist/ripgrep.d.ts.map +1 -0
  56. package/dist/ripgrep.js +217 -0
  57. package/dist/ripgrep.js.map +1 -0
  58. package/dist/search.d.ts +114 -0
  59. package/dist/search.d.ts.map +1 -0
  60. package/dist/search.js +309 -0
  61. package/dist/search.js.map +1 -0
  62. package/dist/silence-sqlite-warning.d.ts +2 -0
  63. package/dist/silence-sqlite-warning.d.ts.map +1 -0
  64. package/dist/silence-sqlite-warning.js +9 -0
  65. package/dist/silence-sqlite-warning.js.map +1 -0
  66. package/package.json +57 -2
  67. package/src/cli-main.ts +618 -0
  68. package/src/cli.ts +17 -0
  69. package/src/config.ts +130 -0
  70. package/src/db.ts +213 -0
  71. package/src/format.ts +185 -0
  72. package/src/glob.ts +79 -0
  73. package/src/index.ts +19 -0
  74. package/src/indexer.ts +534 -0
  75. package/src/lineage.ts +111 -0
  76. package/src/model.ts +157 -0
  77. package/src/pi-source.ts +328 -0
  78. package/src/quiet.ts +22 -0
  79. package/src/regex.ts +378 -0
  80. package/src/ripgrep.ts +263 -0
  81. package/src/search.ts +504 -0
package/src/model.ts ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Shared vocabulary.
3
+ *
4
+ * The domain has three nouns that are easy to conflate, so they are named
5
+ * apart here and used consistently everywhere else:
6
+ *
7
+ * - **file**: one transcript file on disk (a pi `.jsonl` session).
8
+ * - **entry**: one message/event inside a transcript. An entry is identified by
9
+ * `(lineage, entryKey)`, NEVER by `entryKey` alone (see `lineage`).
10
+ * - **thread**: a file read as a root-to-leaf path. Forking copies a prefix of
11
+ * entries into a new file, so ONE entry can belong to MANY threads.
12
+ *
13
+ * A **lineage** is a fork family: a root file plus every file transitively
14
+ * forked from it. Entry ids are only 8 hex chars and DO collide across
15
+ * unrelated transcripts (38 measured collisions in a 463k-entry corpus), so the
16
+ * lineage is what makes an entry key meaningful.
17
+ */
18
+
19
+ /** How much of a transcript gets indexed. See `docs/DESIGN.md`. */
20
+ export type Tier = 'slim' | 'default' | 'full';
21
+
22
+ export const TIERS: Tier[] = ['slim', 'default', 'full'];
23
+
24
+ /** Kinds of extracted text, used for weighting and for `--kind` filters. */
25
+ export type ChunkKind =
26
+ | 'user'
27
+ | 'assistant'
28
+ | 'thinking'
29
+ | 'toolCall'
30
+ | 'toolResult'
31
+ | 'bash'
32
+ | 'summary'
33
+ | 'name';
34
+
35
+ /** Lowest tier at which each chunk kind is indexed. */
36
+ export const KIND_TIER: Record<ChunkKind, Tier> = {
37
+ user: 'slim',
38
+ assistant: 'slim',
39
+ summary: 'slim',
40
+ name: 'slim',
41
+ thinking: 'default',
42
+ toolCall: 'default',
43
+ bash: 'default',
44
+ toolResult: 'full',
45
+ };
46
+
47
+ /** Relevance multipliers applied on top of bm25. */
48
+ export const KIND_WEIGHT: Record<ChunkKind, number> = {
49
+ name: 3,
50
+ user: 2.5,
51
+ summary: 2,
52
+ assistant: 1.5,
53
+ bash: 1,
54
+ toolCall: 0.6,
55
+ thinking: 0.5,
56
+ toolResult: 0.4,
57
+ };
58
+
59
+ /** The header line of a transcript file. */
60
+ export interface TranscriptHeader {
61
+ /** Session UUID as recorded by the producing agent. */
62
+ sessionUuid?: string;
63
+ /** Working directory the session ran in. Authoritative; the folder name is lossy. */
64
+ cwd?: string;
65
+ /** Absolute path of the file this one was forked from, if any. */
66
+ parentPath?: string;
67
+ started?: string;
68
+ version?: number;
69
+ }
70
+
71
+ /** One message/event parsed out of a transcript. */
72
+ export interface ParsedEntry {
73
+ /** Producer-assigned id, unique only within a lineage. */
74
+ entryKey: string;
75
+ parentKey: string | null;
76
+ role: string;
77
+ tool: string | null;
78
+ ts: string | null;
79
+ byteOffset: number;
80
+ byteLength: number;
81
+ /** Set when the entry names the session (`session_info`). */
82
+ sessionName?: string;
83
+ chunks: ExtractedChunk[];
84
+ }
85
+
86
+ export interface ExtractedChunk {
87
+ kind: ChunkKind;
88
+ text: string;
89
+ }
90
+
91
+ /** A transcript file plus everything the indexer learned from its header. */
92
+ export interface FileMeta {
93
+ path: string;
94
+ source: string;
95
+ header: TranscriptHeader;
96
+ size: number;
97
+ mtime: number;
98
+ /** Cheap fingerprint of the first bytes; guards the append-only assumption. */
99
+ headHash: string;
100
+ private: boolean;
101
+ }
102
+
103
+ export interface SearchHit {
104
+ entryId: number;
105
+ entryKey: string;
106
+ lineageId: number;
107
+ role: string;
108
+ tool: string | null;
109
+ ts: string | null;
110
+ /**
111
+ * Which extracted text this hit is about. Always set on the full-text path,
112
+ * where a hit IS a chunk. Absent on the regex path when the match landed
113
+ * outside any extracted text (see `matchedIn`), because inventing a kind
114
+ * there would be a guess presented as a fact.
115
+ */
116
+ kind?: ChunkKind;
117
+ snippet: string;
118
+ score: number;
119
+ /** Threads carrying this entry, most recently active first (may be capped). */
120
+ threads: ThreadRef[];
121
+ /** How many threads carry this entry in total, before any display cap. */
122
+ threadTotal: number;
123
+ /** Further matches in the same lineage that were folded into this group. */
124
+ otherHits: number;
125
+ /**
126
+ * Regex path only: where the matched bytes actually live.
127
+ *
128
+ * - `index`: in extracted text that is also indexed, so a full-text query
129
+ * could in principle have found it too.
130
+ * - `transcript`: in what someone actually said or ran, but NOT in the index.
131
+ * Either the tier excludes that kind (tool output, by default) or the text
132
+ * sits past where the chunk was truncated. This is the whole reason to
133
+ * reach for a regex search.
134
+ * - `structure`: in the transcript's JSON scaffolding, a key, an id or an
135
+ * escape sequence, rather than in anything anyone wrote. Distinct from
136
+ * `transcript` on purpose: the content around it may well be indexed, and
137
+ * claiming otherwise would be exactly the dishonesty the flag exists to
138
+ * prevent.
139
+ */
140
+ matchedIn?: 'index' | 'transcript' | 'structure';
141
+ }
142
+
143
+ export interface ThreadRef {
144
+ fileId: number;
145
+ path: string;
146
+ name: string | null;
147
+ cwd: string | null;
148
+ project: string | null;
149
+ lastActivity: string | null;
150
+ entryCount: number;
151
+ /** Position of the matched entry within this thread. */
152
+ seq: number;
153
+ /** Entries this thread accumulated after the matched entry. */
154
+ after: number;
155
+ /** True when this thread is the lineage root (not itself a fork). */
156
+ isRoot: boolean;
157
+ }
@@ -0,0 +1,328 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type {
4
+ ExtractedChunk,
5
+ ParsedEntry,
6
+ Tier,
7
+ TranscriptHeader,
8
+ } from './model.js';
9
+ import {KIND_TIER} from './model.js';
10
+
11
+ /** Recursively collect transcript files under a root. */
12
+ export function listTranscripts(root: string): string[] {
13
+ const out: string[] = [];
14
+ const walk = (dir: string) => {
15
+ let entries: fs.Dirent[];
16
+ try {
17
+ entries = fs.readdirSync(dir, {withFileTypes: true});
18
+ } catch {
19
+ return;
20
+ }
21
+ for (const e of entries) {
22
+ const full = path.join(dir, e.name);
23
+ if (e.isDirectory()) walk(full);
24
+ else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(full);
25
+ }
26
+ };
27
+ walk(root);
28
+ out.sort();
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * FNV-1a over the first bytes of a file: guards the append-only assumption.
34
+ *
35
+ * The token embeds how many bytes were actually hashed, because a transcript
36
+ * shorter than the window grows past it on the next append. `matchesStoredHead`
37
+ * re-hashes exactly that many bytes, so "the prefix is unchanged" stays a real
38
+ * check instead of a coincidence.
39
+ */
40
+ export function headFingerprint(file: string, bytes = HEAD_BYTES): string {
41
+ let fd: number | undefined;
42
+ try {
43
+ fd = fs.openSync(file, 'r');
44
+ const buf = Buffer.allocUnsafe(bytes);
45
+ const read = fs.readSync(fd, buf, 0, bytes, 0);
46
+ let h = 0x811c9dc5;
47
+ for (let i = 0; i < read; i++) {
48
+ h ^= buf[i];
49
+ h = Math.imul(h, 0x01000193) >>> 0;
50
+ }
51
+ return h.toString(16) + ':' + read;
52
+ } catch {
53
+ return '';
54
+ } finally {
55
+ if (fd !== undefined) fs.closeSync(fd);
56
+ }
57
+ }
58
+
59
+ /** Read only the first line of a transcript, without loading the whole file. */
60
+ export const HEAD_BYTES = 4096;
61
+
62
+ /** True when the first bytes of `file` still hash to the stored token. */
63
+ export function matchesStoredHead(
64
+ file: string,
65
+ stored: string | null | undefined,
66
+ ): boolean {
67
+ if (!stored) return false;
68
+ const colon = stored.lastIndexOf(':');
69
+ if (colon < 0) return false;
70
+ const length = Number(stored.slice(colon + 1));
71
+ if (!Number.isFinite(length) || length <= 0) return false;
72
+ return headFingerprint(file, length) === stored;
73
+ }
74
+
75
+ export function readHeader(file: string): TranscriptHeader | null {
76
+ let fd: number | undefined;
77
+ try {
78
+ fd = fs.openSync(file, 'r');
79
+ const chunks: Buffer[] = [];
80
+ const buf = Buffer.allocUnsafe(8192);
81
+ let total = 0;
82
+ while (total < 1 << 20) {
83
+ const read = fs.readSync(fd, buf, 0, buf.length, total);
84
+ if (read <= 0) break;
85
+ const slice = buf.subarray(0, read);
86
+ const nl = slice.indexOf(0x0a);
87
+ if (nl >= 0) {
88
+ chunks.push(Buffer.from(slice.subarray(0, nl)));
89
+ break;
90
+ }
91
+ chunks.push(Buffer.from(slice));
92
+ total += read;
93
+ }
94
+ const line = Buffer.concat(chunks).toString('utf8');
95
+ if (!line.trim()) return null;
96
+ const obj = JSON.parse(line) as Record<string, unknown>;
97
+ if (obj.type !== 'session') return null;
98
+ return {
99
+ sessionUuid: typeof obj.id === 'string' ? obj.id : undefined,
100
+ cwd: typeof obj.cwd === 'string' ? obj.cwd : undefined,
101
+ parentPath:
102
+ typeof obj.parentSession === 'string' ? obj.parentSession : undefined,
103
+ started: typeof obj.timestamp === 'string' ? obj.timestamp : undefined,
104
+ version: typeof obj.version === 'number' ? obj.version : undefined,
105
+ };
106
+ } catch {
107
+ return null;
108
+ } finally {
109
+ if (fd !== undefined) fs.closeSync(fd);
110
+ }
111
+ }
112
+
113
+ function truncate(text: string, max: number): string {
114
+ if (text.length <= max) return text;
115
+ return text.slice(0, max) + `\n…[truncated ${text.length - max} chars]`;
116
+ }
117
+
118
+ function textOf(content: unknown): string {
119
+ if (typeof content === 'string') return content;
120
+ if (!Array.isArray(content)) return '';
121
+ const parts: string[] = [];
122
+ for (const c of content) {
123
+ if (typeof c === 'string') parts.push(c);
124
+ else if (
125
+ c &&
126
+ typeof c === 'object' &&
127
+ (c as {type?: string}).type === 'text'
128
+ ) {
129
+ parts.push(String((c as {text?: string}).text ?? ''));
130
+ }
131
+ }
132
+ return parts.join('\n');
133
+ }
134
+
135
+ export interface ExtractOptions {
136
+ tier: Tier;
137
+ toolResultHeadBytes: number;
138
+ toolArgsHeadBytes: number;
139
+ }
140
+
141
+ const TIER_RANK: Record<Tier, number> = {slim: 0, default: 1, full: 2};
142
+
143
+ function wants(kind: keyof typeof KIND_TIER, tier: Tier): boolean {
144
+ return TIER_RANK[KIND_TIER[kind]] <= TIER_RANK[tier];
145
+ }
146
+
147
+ /**
148
+ * Turn one raw transcript line into an entry plus its indexable chunks.
149
+ *
150
+ * Returns null for lines that carry no identity (the header) or nothing worth
151
+ * remembering. Non-message entries that still shape a session (`session_info`,
152
+ * `compaction`, `branch_summary`) ARE kept: a session's name and its compaction
153
+ * summaries are some of the highest-signal text in the whole corpus.
154
+ */
155
+ export function extractEntry(
156
+ obj: Record<string, unknown>,
157
+ byteOffset: number,
158
+ byteLength: number,
159
+ opts: ExtractOptions,
160
+ ): ParsedEntry | null {
161
+ const entryKey = typeof obj.id === 'string' ? obj.id : null;
162
+ if (!entryKey) return null;
163
+ const type = String(obj.type ?? '');
164
+ const base = {
165
+ entryKey,
166
+ parentKey: typeof obj.parentId === 'string' ? obj.parentId : null,
167
+ ts: typeof obj.timestamp === 'string' ? obj.timestamp : null,
168
+ byteOffset,
169
+ byteLength,
170
+ };
171
+ const chunks: ExtractedChunk[] = [];
172
+
173
+ if (type === 'session_info') {
174
+ const name = typeof obj.name === 'string' ? obj.name : '';
175
+ if (name && wants('name', opts.tier))
176
+ chunks.push({kind: 'name', text: name});
177
+ return {
178
+ ...base,
179
+ role: 'session_info',
180
+ tool: null,
181
+ sessionName: name || undefined,
182
+ chunks,
183
+ };
184
+ }
185
+
186
+ if (type === 'compaction' || type === 'branch_summary') {
187
+ const summary = typeof obj.summary === 'string' ? obj.summary : '';
188
+ if (summary && wants('summary', opts.tier))
189
+ chunks.push({kind: 'summary', text: summary});
190
+ return {...base, role: type, tool: null, chunks};
191
+ }
192
+
193
+ if (type === 'custom_message') {
194
+ const text = textOf(obj.content);
195
+ if (text && wants('assistant', opts.tier))
196
+ chunks.push({kind: 'assistant', text});
197
+ return {...base, role: 'custom_message', tool: null, chunks};
198
+ }
199
+
200
+ if (type !== 'message') {
201
+ // model_change, thinking_level_change, label, custom: structural only.
202
+ return {...base, role: type || 'unknown', tool: null, chunks};
203
+ }
204
+
205
+ const message = (obj.message ?? {}) as Record<string, unknown>;
206
+ const role = String(message.role ?? 'unknown');
207
+ let tool: string | null = null;
208
+
209
+ if (role === 'user') {
210
+ const text = textOf(message.content);
211
+ if (text && wants('user', opts.tier)) chunks.push({kind: 'user', text});
212
+ } else if (role === 'assistant') {
213
+ for (const c of (message.content ?? []) as Array<Record<string, unknown>>) {
214
+ if (!c || typeof c !== 'object') continue;
215
+ if (c.type === 'text' && wants('assistant', opts.tier)) {
216
+ chunks.push({kind: 'assistant', text: String(c.text ?? '')});
217
+ } else if (c.type === 'thinking' && wants('thinking', opts.tier)) {
218
+ chunks.push({kind: 'thinking', text: String(c.thinking ?? '')});
219
+ } else if (c.type === 'toolCall') {
220
+ tool = tool ?? (typeof c.name === 'string' ? c.name : null);
221
+ if (wants('toolCall', opts.tier)) {
222
+ const args =
223
+ c.arguments === undefined ? '' : JSON.stringify(c.arguments);
224
+ const text = `${String(c.name ?? '')} ${args}`.trim();
225
+ if (text)
226
+ chunks.push({
227
+ kind: 'toolCall',
228
+ text: truncate(text, opts.toolArgsHeadBytes),
229
+ });
230
+ }
231
+ }
232
+ }
233
+ } else if (role === 'toolResult') {
234
+ tool = typeof message.toolName === 'string' ? message.toolName : null;
235
+ if (wants('toolResult', opts.tier)) {
236
+ const text = textOf(message.content);
237
+ if (text)
238
+ chunks.push({
239
+ kind: 'toolResult',
240
+ text: truncate(text, opts.toolResultHeadBytes),
241
+ });
242
+ }
243
+ } else if (role === 'bashExecution') {
244
+ const command = String(message.command ?? '');
245
+ if (wants('bash', opts.tier)) {
246
+ const output = String(message.output ?? '');
247
+ const text = [command, truncate(output, opts.toolResultHeadBytes)]
248
+ .filter(Boolean)
249
+ .join('\n');
250
+ if (text) chunks.push({kind: 'bash', text});
251
+ }
252
+ } else if (role === 'compactionSummary' || role === 'branchSummary') {
253
+ const summary = String(message.summary ?? '');
254
+ if (summary && wants('summary', opts.tier))
255
+ chunks.push({kind: 'summary', text: summary});
256
+ } else if (role === 'custom') {
257
+ const text = textOf(message.content);
258
+ if (text && wants('assistant', opts.tier))
259
+ chunks.push({kind: 'assistant', text});
260
+ }
261
+
262
+ return {...base, role, tool, chunks};
263
+ }
264
+
265
+ export interface ParseResult {
266
+ entries: ParsedEntry[];
267
+ /** Byte offset just past the last COMPLETE line consumed. */
268
+ endOffset: number;
269
+ malformed: number;
270
+ }
271
+
272
+ /**
273
+ * Parse a transcript from `fromOffset` to the end.
274
+ *
275
+ * A trailing partial line (a session being written right now) is left
276
+ * unconsumed, so `endOffset` is always a safe resume point.
277
+ */
278
+ export function parseTranscript(
279
+ file: string,
280
+ fromOffset: number,
281
+ opts: ExtractOptions,
282
+ ): ParseResult {
283
+ const buf = fs.readFileSync(file);
284
+ const entries: ParsedEntry[] = [];
285
+ let malformed = 0;
286
+ let offset = fromOffset;
287
+ let consumed = fromOffset;
288
+ while (offset < buf.length) {
289
+ const nl = buf.indexOf(0x0a, offset);
290
+ if (nl < 0) break; // partial trailing line: stop, resume from `consumed`
291
+ const line = buf.subarray(offset, nl).toString('utf8');
292
+ const lineStart = offset;
293
+ offset = nl + 1;
294
+ consumed = offset;
295
+ const trimmed = line.trim();
296
+ if (!trimmed) continue;
297
+ let obj: Record<string, unknown>;
298
+ try {
299
+ obj = JSON.parse(trimmed) as Record<string, unknown>;
300
+ } catch {
301
+ malformed++;
302
+ continue;
303
+ }
304
+ if (obj.type === 'session') continue;
305
+ const entry = extractEntry(obj, lineStart, nl - lineStart, opts);
306
+ if (entry) entries.push(entry);
307
+ }
308
+ return {entries, endOffset: consumed, malformed};
309
+ }
310
+
311
+ /** Read one raw line back from a transcript, for on-demand expansion of a hit. */
312
+ export function readRawEntry(
313
+ file: string,
314
+ byteOffset: number,
315
+ byteLength: number,
316
+ ): unknown | null {
317
+ let fd: number | undefined;
318
+ try {
319
+ fd = fs.openSync(file, 'r');
320
+ const buf = Buffer.allocUnsafe(byteLength);
321
+ const read = fs.readSync(fd, buf, 0, byteLength, byteOffset);
322
+ return JSON.parse(buf.subarray(0, read).toString('utf8')) as unknown;
323
+ } catch {
324
+ return null;
325
+ } finally {
326
+ if (fd !== undefined) fs.closeSync(fd);
327
+ }
328
+ }
package/src/quiet.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * `node:sqlite` is still flagged experimental and prints a warning on first
3
+ * use. That is noise in a CLI the user runs fifty times a day, so it is
4
+ * silenced here and ONLY here, narrowly: any other warning still gets through.
5
+ */
6
+ export function silenceSqliteWarning(): void {
7
+ const proc = process as unknown as {
8
+ emit: (name: string, ...args: unknown[]) => boolean;
9
+ };
10
+ const original = proc.emit.bind(process);
11
+ proc.emit = (name: string, ...args: unknown[]): boolean => {
12
+ if (name === 'warning') {
13
+ const warning = args[0] as {name?: string; message?: string} | undefined;
14
+ if (
15
+ warning?.name === 'ExperimentalWarning' &&
16
+ /SQLite/i.test(warning.message ?? '')
17
+ )
18
+ return false;
19
+ }
20
+ return original(name, ...args);
21
+ };
22
+ }