linksee-memory 0.0.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.
@@ -0,0 +1,303 @@
1
+ // Parse a Claude Code session JSONL into a structured session model.
2
+ // Input: path to a *.jsonl file under ~/.claude/projects/<project>/
3
+ // Output: normalized session with user messages, tool calls, file ops.
4
+ import { readFileSync } from 'node:fs';
5
+ function extractTextFromContent(content) {
6
+ if (typeof content === 'string')
7
+ return content;
8
+ if (!Array.isArray(content))
9
+ return '';
10
+ const parts = [];
11
+ for (const block of content) {
12
+ if (typeof block === 'string') {
13
+ parts.push(block);
14
+ continue;
15
+ }
16
+ if (block?.type === 'text' && typeof block.text === 'string')
17
+ parts.push(block.text);
18
+ }
19
+ return parts.join('\n');
20
+ }
21
+ function extractToolCalls(content) {
22
+ if (!Array.isArray(content))
23
+ return undefined;
24
+ const calls = [];
25
+ for (const block of content) {
26
+ if (block?.type === 'tool_use') {
27
+ calls.push({ name: block.name, input: block.input, tool_use_id: block.id });
28
+ }
29
+ }
30
+ return calls.length > 0 ? calls : undefined;
31
+ }
32
+ function extractToolResults(content) {
33
+ if (!Array.isArray(content))
34
+ return undefined;
35
+ const results = [];
36
+ for (const block of content) {
37
+ if (block?.type === 'tool_result') {
38
+ const rawOutput = typeof block.content === 'string'
39
+ ? block.content
40
+ : Array.isArray(block.content)
41
+ ? block.content.map((c) => (typeof c === 'string' ? c : c?.text ?? '')).join('\n')
42
+ : '';
43
+ results.push({
44
+ tool_use_id: block.tool_use_id,
45
+ is_error: !!block.is_error,
46
+ output_preview: rawOutput.slice(0, 300),
47
+ });
48
+ }
49
+ }
50
+ return results.length > 0 ? results : undefined;
51
+ }
52
+ // Detect noise messages we should skip for "meaningful user text"
53
+ export function isMetaOrNoise(text) {
54
+ if (!text)
55
+ return true;
56
+ const t = text.trim();
57
+ if (t.length === 0)
58
+ return true;
59
+ if (t.startsWith('<local-command-caveat>'))
60
+ return true;
61
+ if (t.startsWith('<system-reminder>'))
62
+ return true;
63
+ if (t.startsWith('<command-message>'))
64
+ return true;
65
+ if (t.startsWith('<command-name>'))
66
+ return true;
67
+ if (t.startsWith('<scheduled-task'))
68
+ return true; // cron-triggered, no real user intent
69
+ if (t.startsWith('<automated-'))
70
+ return true; // other automation wrappers
71
+ if (t.startsWith('<bash-') || t.startsWith('<ide-'))
72
+ return true;
73
+ if (t.startsWith('<task-notification>'))
74
+ return true; // background task completion notices
75
+ if (t.startsWith('<local-command-stdout>'))
76
+ return true; // shell command output capture
77
+ if (t.startsWith('<local-command-stderr>'))
78
+ return true;
79
+ if (t.startsWith('<command-stdout>') || t.startsWith('<command-stderr>'))
80
+ return true;
81
+ // Claude Code's auto-injected context-continuation summaries — these are NOT user warnings
82
+ if (t.startsWith('This session is being continued from a previous conversation'))
83
+ return true;
84
+ if (t.includes('Caveat: The messages below were generated by the user while running local commands'))
85
+ return true;
86
+ if (t.match(/^\/(compact|clear|exit|help|quit|login|logout|mcp)(\s|$)/))
87
+ return true;
88
+ // Reminders that are pure tool hints
89
+ if (t.includes('The TodoWrite tool hasn\'t been used recently'))
90
+ return true;
91
+ if (t.includes('PostToolUse:Write hook'))
92
+ return true;
93
+ return false;
94
+ }
95
+ // Detect content that's clearly a paste-back of assistant output / external system text,
96
+ // not the user's own thought. Stricter than isMetaOrNoise — used for caveat/learning extraction
97
+ // where we want only authentic user reflection, not pasted material.
98
+ export function isPastedExternalContent(text) {
99
+ if (!text)
100
+ return false;
101
+ const t = text.trim();
102
+ // Assistant-style bullet/result markers at start
103
+ if (/^[●○◯◆■▶►]\s/.test(t))
104
+ return true;
105
+ if (/^Attempt #\d+ failed/i.test(t))
106
+ return true;
107
+ // Box-drawing characters (table dumps from CLI)
108
+ if (/[┌┐└┘├┤┬┴┼━─│]/.test(t.slice(0, 200)) && t.length > 100)
109
+ return true;
110
+ // System prompts being pasted in
111
+ if (/^You are (a|an|the) [A-Z]\w+/.test(t))
112
+ return true;
113
+ // Marketing emails / docs
114
+ if (/^(Welcome to|Hey there,|Hi there,|Dear)\s/i.test(t))
115
+ return true;
116
+ if (/^(Add|Enable|Disable|Configure) (organization|2FA|members|users)/i.test(t))
117
+ return true;
118
+ // Service status pages / logs
119
+ if (/^(Service offline|Failed|Success|Deployment|Build|Logs?|Error:)/.test(t.slice(0, 80)))
120
+ return true;
121
+ // Extractor-internal duplicate (was generating same caveat per session)
122
+ if (t === 'Review errors before repeating this workflow.')
123
+ return true;
124
+ // Pasted from forums/Reddit/etc. — block when "your post" / "Rule N:" patterns dominate
125
+ if (/Rule \d+:/.test(t.slice(0, 200)) && /your (post|submission)/i.test(t))
126
+ return true;
127
+ // Stack-trace like
128
+ if (/^\s*at\s+\w+.*\(.*:\d+:\d+\)/m.test(t.slice(0, 500)))
129
+ return true;
130
+ return false;
131
+ }
132
+ // Detect if a session is entirely/mostly automated (scheduled task, CI, etc.)
133
+ export function isAutomatedSession(firstUserText) {
134
+ return /^\s*<scheduled-task|^\s*<automated-/.test(firstUserText);
135
+ }
136
+ export function parseSessionFile(jsonlPath) {
137
+ const raw = readFileSync(jsonlPath, 'utf8');
138
+ const lines = raw.split('\n').filter(Boolean);
139
+ if (lines.length === 0)
140
+ return null;
141
+ let session_id = '';
142
+ let project_cwd = '';
143
+ let git_branch;
144
+ let minTs = Number.MAX_SAFE_INTEGER;
145
+ let maxTs = 0;
146
+ const turns = [];
147
+ const file_ops = [];
148
+ let turn_count_user = 0;
149
+ let turn_count_assistant = 0;
150
+ let errors_count = 0;
151
+ // Track last meaningful user text so we can attach it as context to file edits
152
+ let lastUserText = '';
153
+ // First pass: build turns
154
+ // Also keep a map of tool_use_id → (turn_uuid, name, input) so we can join results
155
+ const toolUseIndex = new Map();
156
+ for (const line of lines) {
157
+ let event;
158
+ try {
159
+ event = JSON.parse(line);
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ if (!session_id && event.sessionId)
165
+ session_id = event.sessionId;
166
+ if (!project_cwd && event.cwd)
167
+ project_cwd = event.cwd;
168
+ if (!git_branch && event.gitBranch)
169
+ git_branch = event.gitBranch;
170
+ const ts = event.timestamp ? Math.floor(new Date(event.timestamp).getTime() / 1000) : 0;
171
+ if (ts > 0) {
172
+ if (ts < minTs)
173
+ minTs = ts;
174
+ if (ts > maxTs)
175
+ maxTs = ts;
176
+ }
177
+ if (event.type !== 'user' && event.type !== 'assistant')
178
+ continue;
179
+ if (event.isMeta || event.isSidechain)
180
+ continue;
181
+ const message = event.message;
182
+ if (!message)
183
+ continue;
184
+ const role = message.role;
185
+ if (role !== 'user' && role !== 'assistant')
186
+ continue;
187
+ const text = extractTextFromContent(message.content);
188
+ const tool_calls = role === 'assistant' ? extractToolCalls(message.content) : undefined;
189
+ const tool_results = role === 'user' ? extractToolResults(message.content) : undefined;
190
+ if (role === 'user')
191
+ turn_count_user++;
192
+ else
193
+ turn_count_assistant++;
194
+ if (tool_results) {
195
+ for (const tr of tool_results)
196
+ if (tr.is_error)
197
+ errors_count++;
198
+ }
199
+ const turn = {
200
+ uuid: event.uuid ?? '',
201
+ role,
202
+ timestamp: ts,
203
+ text,
204
+ tool_calls,
205
+ tool_results,
206
+ };
207
+ turns.push(turn);
208
+ // Track latest user intent for "why" attribution
209
+ if (role === 'user' && !isMetaOrNoise(text) && !tool_results) {
210
+ lastUserText = text.trim();
211
+ }
212
+ // Capture file operations from assistant tool calls
213
+ if (role === 'assistant' && tool_calls) {
214
+ for (const call of tool_calls) {
215
+ toolUseIndex.set(call.tool_use_id, {
216
+ turn_uuid: turn.uuid,
217
+ name: call.name,
218
+ input: call.input,
219
+ timestamp: ts,
220
+ });
221
+ const op = classifyToolOp(call.name);
222
+ if (op && call.input?.file_path) {
223
+ file_ops.push({
224
+ operation: op,
225
+ path: call.input.file_path,
226
+ turn_uuid: turn.uuid,
227
+ timestamp: ts,
228
+ preceding_user_text: lastUserText.slice(0, 600),
229
+ tool_input_preview: summarizeToolInput(call.name, call.input),
230
+ });
231
+ }
232
+ else if (call.name === 'Bash' && typeof call.input?.command === 'string') {
233
+ // capture git/build commands as 'bash' ops — no file_path but still useful
234
+ const cmd = call.input.command;
235
+ // extract file-like paths from the command if any, else store whole command
236
+ file_ops.push({
237
+ operation: 'bash',
238
+ path: extractFirstPathFromCommand(cmd) ?? '(shell)',
239
+ turn_uuid: turn.uuid,
240
+ timestamp: ts,
241
+ preceding_user_text: lastUserText.slice(0, 600),
242
+ tool_input_preview: cmd.slice(0, 200),
243
+ });
244
+ }
245
+ }
246
+ }
247
+ }
248
+ if (!session_id || turns.length === 0)
249
+ return null;
250
+ return {
251
+ session_id,
252
+ project_cwd: project_cwd || '(unknown)',
253
+ started_at: minTs === Number.MAX_SAFE_INTEGER ? 0 : minTs,
254
+ ended_at: maxTs,
255
+ git_branch,
256
+ turns,
257
+ file_ops,
258
+ turn_count_user,
259
+ turn_count_assistant,
260
+ errors_count,
261
+ };
262
+ }
263
+ function classifyToolOp(name) {
264
+ if (name === 'Read')
265
+ return 'read';
266
+ if (name === 'Edit' || name === 'MultiEdit' || name === 'NotebookEdit')
267
+ return 'edit';
268
+ if (name === 'Write')
269
+ return 'write';
270
+ return null;
271
+ }
272
+ function summarizeToolInput(name, input) {
273
+ try {
274
+ if (name === 'Edit') {
275
+ const old_s = (input.old_string ?? '').toString();
276
+ const new_s = (input.new_string ?? '').toString();
277
+ return `OLD(${old_s.length}ch): ${old_s.slice(0, 80)}... NEW(${new_s.length}ch): ${new_s.slice(0, 80)}...`;
278
+ }
279
+ if (name === 'Write') {
280
+ const c = (input.content ?? '').toString();
281
+ return `WRITE(${c.length}ch): ${c.slice(0, 120)}...`;
282
+ }
283
+ if (name === 'Read') {
284
+ return `READ ${input.file_path ?? ''}${input.offset ? ` +${input.offset}` : ''}`;
285
+ }
286
+ return JSON.stringify(input).slice(0, 200);
287
+ }
288
+ catch {
289
+ return '';
290
+ }
291
+ }
292
+ function extractFirstPathFromCommand(cmd) {
293
+ // heuristic: look for something that looks like a path in the command
294
+ const m = cmd.match(/[A-Za-z]:[\\/][\w\-\.\\/]+|(?<![A-Za-z0-9])\.?\/?[\w\-\.]+\/[\w\-\.\/]+/);
295
+ return m ? m[0] : null;
296
+ }
297
+ // Utility: project_cwd → project name (last path segment)
298
+ export function projectNameFromCwd(cwd) {
299
+ if (!cwd)
300
+ return 'unknown';
301
+ return cwd.replace(/\\/g, '/').replace(/\/$/, '').split('/').filter(Boolean).pop() ?? 'unknown';
302
+ }
303
+ //# sourceMappingURL=session-parser.js.map
@@ -0,0 +1,5 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare function handleReadSmart(db: Database.Database, args: {
3
+ path: string;
4
+ force?: boolean;
5
+ }): string;
@@ -0,0 +1,132 @@
1
+ // read_smart handler: the flagship token-saving feature.
2
+ // Returns full content on first read, "unchanged" metadata on re-read (~50 tokens),
3
+ // or only the changed chunks + unchanged summary on real modifications.
4
+ import { readFileSync, statSync } from 'node:fs';
5
+ import { chunkFile, hashFile } from '../lib/file-chunker.js';
6
+ // Rough estimate: 1 token ≈ 4 chars for English+code, closer to 2-3 for JP.
7
+ // Use 0.3 as a blended average.
8
+ const TOKENS_PER_CHAR = 0.3;
9
+ function estimateTokens(content) {
10
+ return Math.ceil(content.length * TOKENS_PER_CHAR);
11
+ }
12
+ function toMeta(c) {
13
+ return { id: c.id, kind: c.kind, start_line: c.start_line, end_line: c.end_line, hash: c.hash };
14
+ }
15
+ export function handleReadSmart(db, args) {
16
+ const { path, force = false } = args;
17
+ let stat;
18
+ try {
19
+ stat = statSync(path);
20
+ }
21
+ catch (e) {
22
+ return JSON.stringify({ ok: false, error: `File not found: ${path}` });
23
+ }
24
+ const mtime = Math.floor(stat.mtimeMs / 1000);
25
+ const size = stat.size;
26
+ const prior = db.prepare('SELECT * FROM file_snapshots WHERE path = ?').get(path);
27
+ // --- CASE A: first read or force ---
28
+ if (!prior || force) {
29
+ const content = readFileSync(path, 'utf8');
30
+ const fileHash = hashFile(content);
31
+ const chunks = chunkFile(path, content);
32
+ const chunkMeta = chunks.map(toMeta);
33
+ db.prepare(`INSERT INTO file_snapshots (path, content_hash, mtime, size_bytes, chunks, last_read_at, read_count)
34
+ VALUES (?, ?, ?, ?, ?, unixepoch(), 1)
35
+ ON CONFLICT(path) DO UPDATE SET
36
+ content_hash = excluded.content_hash,
37
+ mtime = excluded.mtime,
38
+ size_bytes = excluded.size_bytes,
39
+ chunks = excluded.chunks,
40
+ last_read_at = unixepoch(),
41
+ read_count = read_count + 1`).run(path, fileHash, mtime, size, JSON.stringify(chunkMeta));
42
+ return JSON.stringify({
43
+ ok: true,
44
+ status: force ? 'forced_full' : 'first_read',
45
+ path,
46
+ content,
47
+ chunks: chunkMeta,
48
+ bytes: size,
49
+ tokens_approx: estimateTokens(content),
50
+ tokens_saved: 0,
51
+ });
52
+ }
53
+ // --- CASE B: mtime unchanged → content guaranteed unchanged (fast path) ---
54
+ if (prior.mtime === mtime) {
55
+ db.prepare('UPDATE file_snapshots SET last_read_at = unixepoch(), read_count = read_count + 1 WHERE path = ?').run(path);
56
+ const storedChunks = JSON.parse(prior.chunks);
57
+ const factRows = db.prepare('SELECT fact, layer, chunk_hash FROM file_facts WHERE file_path = ?').all(path);
58
+ // Token savings = what a full read would have cost
59
+ const savedTokens = Math.round(size * TOKENS_PER_CHAR);
60
+ return JSON.stringify({
61
+ ok: true,
62
+ status: 'unchanged',
63
+ path,
64
+ last_read_at: new Date(prior.last_read_at * 1000).toISOString(),
65
+ chunk_count: storedChunks.length,
66
+ chunks: storedChunks,
67
+ file_facts: factRows,
68
+ tokens_saved: savedTokens,
69
+ note: 'File unchanged since last read. Call with force:true if full content is needed.',
70
+ });
71
+ }
72
+ // --- CASE C: mtime changed → compute hash, maybe false alarm ---
73
+ const content = readFileSync(path, 'utf8');
74
+ const fileHash = hashFile(content);
75
+ if (fileHash === prior.content_hash) {
76
+ db.prepare('UPDATE file_snapshots SET mtime = ?, last_read_at = unixepoch(), read_count = read_count + 1 WHERE path = ?').run(mtime, path);
77
+ return JSON.stringify({
78
+ ok: true,
79
+ status: 'unchanged_content',
80
+ path,
81
+ note: 'mtime changed but sha256 identical (file was touched but not modified).',
82
+ tokens_saved: Math.round(size * TOKENS_PER_CHAR),
83
+ });
84
+ }
85
+ // --- CASE D: real diff ---
86
+ const newChunks = chunkFile(path, content);
87
+ const oldChunks = JSON.parse(prior.chunks);
88
+ const oldById = new Map(oldChunks.map((c) => [c.id, c]));
89
+ const changedChunks = [];
90
+ const unchangedChunks = [];
91
+ const seenIds = new Set();
92
+ for (const c of newChunks) {
93
+ seenIds.add(c.id);
94
+ const prev = oldById.get(c.id);
95
+ if (!prev) {
96
+ changedChunks.push({ id: c.id, kind: c.kind, status: 'added', start_line: c.start_line, end_line: c.end_line, content: c.content });
97
+ }
98
+ else if (prev.hash !== c.hash) {
99
+ changedChunks.push({ id: c.id, kind: c.kind, status: 'modified', start_line: c.start_line, end_line: c.end_line, content: c.content });
100
+ }
101
+ else {
102
+ unchangedChunks.push({ id: c.id, kind: c.kind, start_line: c.start_line, end_line: c.end_line, hash: c.hash });
103
+ }
104
+ }
105
+ const removedChunks = oldChunks
106
+ .filter((c) => !seenIds.has(c.id))
107
+ .map((c) => ({ id: c.id, kind: c.kind, prev_lines: `${c.start_line}-${c.end_line}` }));
108
+ const newChunkMeta = newChunks.map(toMeta);
109
+ db.prepare(`UPDATE file_snapshots SET content_hash = ?, mtime = ?, size_bytes = ?, chunks = ?, last_read_at = unixepoch(), read_count = read_count + 1 WHERE path = ?`).run(fileHash, mtime, size, JSON.stringify(newChunkMeta), path);
110
+ const fullTokens = estimateTokens(content);
111
+ const returnedTokens = changedChunks.reduce((s, c) => s + estimateTokens(c.content), 0) + 80; // ~80 for the envelope
112
+ const savedTokens = Math.max(0, fullTokens - returnedTokens);
113
+ const pctSaved = fullTokens > 0 ? Math.round((savedTokens / fullTokens) * 100) : 0;
114
+ return JSON.stringify({
115
+ ok: true,
116
+ status: 'modified',
117
+ path,
118
+ changed_chunks: changedChunks,
119
+ unchanged_chunks: unchangedChunks,
120
+ removed_chunks: removedChunks,
121
+ summary: {
122
+ changed: changedChunks.length,
123
+ unchanged: unchangedChunks.length,
124
+ removed: removedChunks.length,
125
+ tokens_full: fullTokens,
126
+ tokens_returned: returnedTokens,
127
+ tokens_saved: savedTokens,
128
+ pct_saved: pctSaved,
129
+ },
130
+ });
131
+ }
132
+ //# sourceMappingURL=read-smart.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};