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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Synapse Arrows PTE. LTD.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # linksee-memory
2
+
3
+ > Local-first agent memory MCP. A cross-agent brain for Claude Code, Cursor, and ChatGPT Desktop — with a token-saving file diff cache that nobody else does.
4
+
5
+ [![npm](https://img.shields.io/npm/v/linksee-memory.svg)](https://www.npmjs.com/package/linksee-memory)
6
+ [![license](https://img.shields.io/npm/l/linksee-memory.svg)](./LICENSE)
7
+
8
+ ## What it does
9
+
10
+ Most "agent memory" services (Mem0, Letta, Zep) save a flat list of facts. Then the agent looks at "edited file X 30 times" and has no idea why. **linksee-memory keeps the WHY.**
11
+
12
+ It is a Model Context Protocol (MCP) server that gives any AI agent four superpowers:
13
+
14
+ | | Mem0 / Letta / Zep | Claude Code auto-memory | linksee-memory |
15
+ |---|---|---|---|
16
+ | Cross-agent | △ (cloud) | ❌ Claude only | ✅ single SQLite file |
17
+ | 6-layer WHY structure | ❌ flat | ❌ flat markdown | ✅ goal / context / emotion / impl / caveat / learning |
18
+ | File diff cache | ❌ | ❌ | ✅ AST-aware, 50-99% token savings on re-reads |
19
+ | Active forgetting | △ | ❌ | ✅ Ebbinghaus curve, caveat layer protected |
20
+ | Local-first / private | ❌ | ✅ | ✅ |
21
+
22
+ ## Three pillars
23
+
24
+ 1. **Token savings** via `read_smart` — sha256 + AST/heading/indent chunking. Re-reads return only diffs. **Measured 86% saved on a typical TS file edit, 99% saved on unchanged re-reads.**
25
+ 2. **Cross-agent portability** — single SQLite file at `~/.linksee-memory/memory.db`. Same brain for Claude Code, Cursor, ChatGPT Desktop.
26
+ 3. **WHY-first structured memory** — six explicit layers (`goal` / `context` / `emotion` / `implementation` / `caveat` / `learning`). Solves "flat fact memory is useless without goals".
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install -g linksee-memory
32
+ linksee-memory-import --help # bundled importer for Claude Code session history
33
+ ```
34
+
35
+ Or use `npx` ad hoc:
36
+
37
+ ```bash
38
+ npx linksee-memory # starts the MCP server on stdio
39
+ ```
40
+
41
+ The default database lives at `~/.linksee-memory/memory.db`. Override with the `LINKSEE_MEMORY_DIR` environment variable.
42
+
43
+ ## Register with Claude Code
44
+
45
+ ```bash
46
+ claude mcp add -s user linksee -- npx -y linksee-memory
47
+ ```
48
+
49
+ Restart Claude Code. Tools appear as `mcp__linksee__remember`, `mcp__linksee__recall`, `mcp__linksee__recall_file`, `mcp__linksee__read_smart`, `mcp__linksee__forget`, `mcp__linksee__consolidate`.
50
+
51
+ ### Optional: auto-capture every session (Stop hook)
52
+
53
+ Add to `~/.claude/settings.json` to record every Claude Code session to your local brain automatically:
54
+
55
+ ```json
56
+ {
57
+ "hooks": {
58
+ "Stop": [
59
+ {
60
+ "matcher": "",
61
+ "hooks": [
62
+ { "type": "command", "command": "npx -y linksee-memory-sync" }
63
+ ]
64
+ }
65
+ ]
66
+ }
67
+ }
68
+ ```
69
+
70
+ Each turn end takes ~100 ms. Failures are silent (Claude Code never blocks). Logs at `~/.linksee-memory/hook.log`.
71
+
72
+ ## Tools
73
+
74
+ | Tool | Purpose |
75
+ |---|---|
76
+ | `remember` | Store memory in 1 of 6 layers for an entity |
77
+ | `recall` | FTS5 + heat-score + momentum composite ranking, JP/EN trigram search |
78
+ | `recall_file` | Get the COMPLETE edit history of a file across all sessions, with per-edit user-intent context |
79
+ | `read_smart` | Diff-only file read. Returns full content on first read, ~50 tokens on unchanged re-reads, only changed chunks on real edits |
80
+ | `forget` | Explicit delete OR auto-sweep based on forgettingRisk (importance × heat × age) |
81
+ | `consolidate` | Sleep-mode compression: cluster cold low-importance memories → protected learning-layer summary |
82
+
83
+ ## The 6 memory layers
84
+
85
+ Each entity (person / company / project / file / concept) can have memories across six layers. The layer encodes meaning, not category:
86
+
87
+ ```json
88
+ {
89
+ "goal": { "primary": "...", "sub_tasks": [], "deadline": "..." },
90
+ "context": { "why_now": "...", "triggering_event": "...", "when": "..." },
91
+ "emotion": { "temperature": "hot|warm|cold", "user_tone": "..." },
92
+ "implementation": {
93
+ "success": [{ "what": "...", "evidence": "..." }],
94
+ "failure": [{ "what": "...", "why_failed": "..." }]
95
+ },
96
+ "caveat": [{ "rule": "...", "reason": "...", "from_incident": "..." }],
97
+ "learning":[{ "at": "...", "learned": "...", "prior_belief": "..." }]
98
+ }
99
+ ```
100
+
101
+ - `caveat` memories are auto-protected from forgetting (pain lessons, never lost).
102
+ - `goal` memories bypass decay while the goal is active.
103
+
104
+ ## Architecture
105
+
106
+ A single SQLite file (`better-sqlite3` + FTS5 trigram tokenizer for JP/EN) contains five layers:
107
+
108
+ - **Layer 1** — `entities` (facts: people / companies / projects / concepts / files)
109
+ - **Layer 2** — `edges` (associations, graph adjacency)
110
+ - **Layer 3** — `memories` (6-layer structured meanings per entity)
111
+ - **Layer 4** — `events` (time-series log for heat / momentum computation)
112
+ - **Layer 5** — `file_snapshots` + `session_file_edits` (diff cache + conversation↔file linkage)
113
+
114
+ The conversation↔file linkage is the key. Every file edit captured by the Stop hook is stored alongside the **user message that drove the edit**. So `recall_file("server.ts")` returns "this file was edited 30 times across 3 days, and here are the actual user instructions that motivated each change".
115
+
116
+ ## Why the design choices
117
+
118
+ - **Local-first** — your conversation history is private. Nothing leaves your machine.
119
+ - **Single file** — `memory.db` is one portable artifact. Backup = file copy.
120
+ - **MCP stdio** — works with every agent that speaks MCP, no plugins per host.
121
+ - **Reuses proven schemas** — `heat_score` / `momentum_score` ported from a production sales-intelligence codebase. Rule-based, no LLM dependency in the hot path.
122
+
123
+ ## Roadmap
124
+
125
+ - ✅ Core 6 MCP tools (`remember` / `recall` / `recall_file` / `forget` / `consolidate` / `read_smart`)
126
+ - ✅ Stop-hook auto-capture for Claude Code
127
+ - ✅ JP/EN trigram FTS5
128
+ - 🚧 `PreToolUse` hook to auto-intercept `Read` (zero-config token savings)
129
+ - 🚧 Cursor + ChatGPT Desktop adapters
130
+ - 🔮 Vector search via `sqlite-vec` once an embedding backend is chosen (Ollama / API / etc.)
131
+ - 🔮 Optional anonymized telemetry → MCP-quality intelligence layer
132
+
133
+ ## Comparison with Claude Code auto-memory
134
+
135
+ Claude Code ships a built-in memory feature at `~/.claude/projects/<path>/memory/*.md` — flat markdown notes for user preferences. linksee-memory **complements** it:
136
+
137
+ - auto-memory = your scrapbook of "remember I prefer X"
138
+ - linksee-memory = structured cross-agent brain with file diff cache and per-edit WHY
139
+
140
+ Use both.
141
+
142
+ ## License
143
+
144
+ MIT — Synapse Arrows PTE. LTD.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ // Batch importer: scan Claude Code session JSONL files and populate linksee-memory.
3
+ // Usage:
4
+ // node dist/bin/import-sessions.js [project_dir ...]
5
+ // node dist/bin/import-sessions.js --dry-run [project_dir ...]
6
+ // node dist/bin/import-sessions.js --all (scans all ~/.claude/projects/*)
7
+ // node dist/bin/import-sessions.js --session-file <path> (single jsonl — used by hook)
8
+ //
9
+ // All inserts are IDEMPOTENT: existing data for a given session_id is wiped before re-insert.
10
+ import { readdirSync, statSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+ import { openDb, runMigrations } from '../db/migrate.js';
14
+ import { parseSessionFile, projectNameFromCwd } from '../lib/session-parser.js';
15
+ import { extractSession } from '../lib/session-extractor.js';
16
+ const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
17
+ function usage() {
18
+ console.log(`Usage:
19
+ node dist/bin/import-sessions.js [--dry-run] [--all | <projectDir> [<projectDir> ...]]
20
+ --all : scan every project under ~/.claude/projects/*
21
+ --dry-run : parse + extract but do not write to DB
22
+ projectDir : absolute path to a project dir (must contain *.jsonl files)`);
23
+ }
24
+ function collectJsonlFiles(projectDir) {
25
+ try {
26
+ return readdirSync(projectDir)
27
+ .filter((f) => f.endsWith('.jsonl'))
28
+ .map((f) => join(projectDir, f))
29
+ .filter((p) => {
30
+ try {
31
+ return statSync(p).isFile();
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ });
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ }
42
+ // Idempotent wipe: remove all rows tied to a given session_id BEFORE re-inserting.
43
+ // Uses LIKE matching on the JSON-encoded source field for memories/events.
44
+ function wipeSession(db, sessionId) {
45
+ const sidNeedle = `%"session_id":"${sessionId}"%`;
46
+ const editDel = db.prepare('DELETE FROM session_file_edits WHERE session_id = ?').run(sessionId);
47
+ const memDel = db.prepare('DELETE FROM memories WHERE source LIKE ?').run(sidNeedle);
48
+ const evtDel = db.prepare('DELETE FROM events WHERE payload LIKE ?').run(sidNeedle);
49
+ return { memories: memDel.changes, edits: editDel.changes, events: evtDel.changes };
50
+ }
51
+ async function main() {
52
+ const args = process.argv.slice(2);
53
+ if (args.includes('-h') || args.includes('--help')) {
54
+ usage();
55
+ return;
56
+ }
57
+ const dryRun = args.includes('--dry-run');
58
+ const scanAll = args.includes('--all');
59
+ const sessionFileIdx = args.indexOf('--session-file');
60
+ const sessionFile = sessionFileIdx >= 0 ? args[sessionFileIdx + 1] : null;
61
+ const projectArgs = args.filter((a, i) => !a.startsWith('--') && (sessionFileIdx < 0 || i !== sessionFileIdx + 1));
62
+ // Single-file mode (used by the Stop hook)
63
+ if (sessionFile) {
64
+ const db = dryRun ? null : openDb();
65
+ if (db)
66
+ runMigrations(db);
67
+ let parsed;
68
+ try {
69
+ parsed = parseSessionFile(sessionFile);
70
+ }
71
+ catch (e) {
72
+ console.error(`[error] cannot parse ${sessionFile}: ${e?.message ?? e}`);
73
+ process.exit(1);
74
+ }
75
+ if (!parsed) {
76
+ console.log(`[skip] empty or invalid session: ${sessionFile}`);
77
+ return;
78
+ }
79
+ const projectName = projectNameFromCwd(parsed.project_cwd) || 'unknown';
80
+ const result = extractSession(parsed, projectName);
81
+ if (dryRun) {
82
+ console.log(`[dry] session ${result.session_id.slice(0, 8)} (${projectName}): ${result.memories.length} memories, ${result.file_edits.length} file_edits`);
83
+ return;
84
+ }
85
+ if (!db)
86
+ return;
87
+ // Idempotent: wipe any prior data for THIS session before re-inserting
88
+ const wiped = wipeSession(db, result.session_id);
89
+ // Resolve project entity
90
+ let projectEntityId;
91
+ const canonicalKey = parsed.project_cwd;
92
+ const existing = db.prepare('SELECT id FROM entities WHERE canonical_key = ? OR (kind = ? AND LOWER(name) = LOWER(?))').get(canonicalKey, 'project', projectName);
93
+ if (existing) {
94
+ projectEntityId = existing.id;
95
+ }
96
+ else {
97
+ const ins = db.prepare('INSERT INTO entities (kind, name, canonical_key) VALUES (?, ?, ?)').run('project', projectName, canonicalKey);
98
+ projectEntityId = Number(ins.lastInsertRowid);
99
+ }
100
+ const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source) VALUES (?, ?, ?, ?, ?)');
101
+ const insEdit = db.prepare(`INSERT INTO session_file_edits (session_id, memory_id, file_path, operation, turn_uuid, context_snippet, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
102
+ const insEvt = db.prepare('INSERT INTO events (entity_id, kind, payload, occurred_at) VALUES (?, ?, ?, ?)');
103
+ let inserted = { memories: 0, edits: 0 };
104
+ db.transaction(() => {
105
+ const memContentToId = new Map();
106
+ for (const m of result.memories) {
107
+ const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, JSON.stringify(m.source));
108
+ memContentToId.set(m.content, Number(res.lastInsertRowid));
109
+ inserted.memories++;
110
+ }
111
+ for (const fe of result.file_edits) {
112
+ const memId = fe.memory_content ? memContentToId.get(fe.memory_content) ?? null : null;
113
+ insEdit.run(fe.session_id, memId, fe.file_path, fe.operation, fe.turn_uuid ?? null, fe.context_snippet, fe.occurred_at);
114
+ inserted.edits++;
115
+ }
116
+ insEvt.run(projectEntityId, 'session_imported', JSON.stringify({
117
+ session_id: result.session_id,
118
+ memories: result.memories.length,
119
+ file_edits: result.file_edits.length,
120
+ stats: result.stats,
121
+ }), parsed.started_at || Math.floor(Date.now() / 1000));
122
+ })();
123
+ console.log(`[ok] ${result.session_id.slice(0, 8)} (${projectName}): wiped ${wiped.memories}m/${wiped.edits}e/${wiped.events}ev → inserted ${inserted.memories}m/${inserted.edits}e`);
124
+ db.close();
125
+ return;
126
+ }
127
+ let projectDirs = [];
128
+ if (scanAll) {
129
+ try {
130
+ projectDirs = readdirSync(CLAUDE_PROJECTS)
131
+ .map((d) => join(CLAUDE_PROJECTS, d))
132
+ .filter((p) => {
133
+ try {
134
+ return statSync(p).isDirectory();
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ });
140
+ }
141
+ catch (e) {
142
+ console.error(`Cannot scan ${CLAUDE_PROJECTS}:`, e);
143
+ process.exit(1);
144
+ }
145
+ }
146
+ else {
147
+ projectDirs = projectArgs;
148
+ }
149
+ if (projectDirs.length === 0) {
150
+ usage();
151
+ process.exit(1);
152
+ }
153
+ const db = dryRun ? null : openDb();
154
+ if (db)
155
+ runMigrations(db);
156
+ const agg = {
157
+ projects: 0,
158
+ sessions_parsed: 0,
159
+ sessions_skipped: 0,
160
+ memories_planned: 0,
161
+ memories_inserted: 0,
162
+ file_edits_inserted: 0,
163
+ errors: 0,
164
+ };
165
+ for (const projectDir of projectDirs) {
166
+ const files = collectJsonlFiles(projectDir);
167
+ if (files.length === 0) {
168
+ console.log(`[skip] no .jsonl in ${projectDir}`);
169
+ continue;
170
+ }
171
+ agg.projects++;
172
+ const dirName = projectDir.replace(/\\/g, '/').split('/').filter(Boolean).pop() ?? 'unknown';
173
+ console.log(`\n=== Project: ${dirName} (${files.length} session files) ===`);
174
+ let projectEntityId = null;
175
+ for (const f of files) {
176
+ let parsed;
177
+ try {
178
+ parsed = parseSessionFile(f);
179
+ }
180
+ catch (e) {
181
+ agg.errors++;
182
+ console.warn(` [error] ${f}: ${e?.message ?? e}`);
183
+ continue;
184
+ }
185
+ if (!parsed) {
186
+ agg.sessions_skipped++;
187
+ continue;
188
+ }
189
+ const projectName = projectNameFromCwd(parsed.project_cwd) || dirName;
190
+ const result = extractSession(parsed, projectName);
191
+ agg.sessions_parsed++;
192
+ agg.memories_planned += result.memories.length;
193
+ if (dryRun) {
194
+ console.log(` [dry] session ${result.session_id.slice(0, 8)} (${projectName}): ${result.memories.length} memories, ${result.file_edits.length} file_edits, stats=${JSON.stringify(result.stats)}`);
195
+ continue;
196
+ }
197
+ if (!db)
198
+ continue;
199
+ // Ensure entity exists (once per project)
200
+ if (projectEntityId === null) {
201
+ const canonicalKey = parsed.project_cwd;
202
+ const existing = db.prepare('SELECT id FROM entities WHERE canonical_key = ? OR (kind = ? AND LOWER(name) = LOWER(?))').get(canonicalKey, 'project', projectName);
203
+ if (existing) {
204
+ projectEntityId = existing.id;
205
+ }
206
+ else {
207
+ const ins = db.prepare('INSERT INTO entities (kind, name, canonical_key) VALUES (?, ?, ?)').run('project', projectName, canonicalKey);
208
+ projectEntityId = Number(ins.lastInsertRowid);
209
+ }
210
+ }
211
+ // Idempotent: wipe any prior data for THIS session before re-inserting (Phase B)
212
+ wipeSession(db, result.session_id);
213
+ // Insert memories + file_edits in a single transaction per session
214
+ const insMem = db.prepare('INSERT INTO memories (entity_id, layer, content, importance, source) VALUES (?, ?, ?, ?, ?)');
215
+ const insEdit = db.prepare(`INSERT INTO session_file_edits (session_id, memory_id, file_path, operation, turn_uuid, context_snippet, occurred_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
216
+ const insEvt = db.prepare('INSERT INTO events (entity_id, kind, payload, occurred_at) VALUES (?, ?, ?, ?)');
217
+ const tx = db.transaction(() => {
218
+ const memContentToId = new Map();
219
+ for (const m of result.memories) {
220
+ const srcJson = JSON.stringify(m.source);
221
+ const res = insMem.run(projectEntityId, m.layer, m.content, m.importance, srcJson);
222
+ memContentToId.set(m.content, Number(res.lastInsertRowid));
223
+ agg.memories_inserted++;
224
+ }
225
+ for (const fe of result.file_edits) {
226
+ const memId = fe.memory_content ? memContentToId.get(fe.memory_content) ?? null : null;
227
+ insEdit.run(fe.session_id, memId, fe.file_path, fe.operation, fe.turn_uuid ?? null, fe.context_snippet, fe.occurred_at);
228
+ agg.file_edits_inserted++;
229
+ }
230
+ insEvt.run(projectEntityId, 'session_imported', JSON.stringify({
231
+ session_id: result.session_id,
232
+ memories: result.memories.length,
233
+ file_edits: result.file_edits.length,
234
+ stats: result.stats,
235
+ }), parsed.started_at || Math.floor(Date.now() / 1000));
236
+ });
237
+ try {
238
+ tx();
239
+ }
240
+ catch (e) {
241
+ agg.errors++;
242
+ console.warn(` [tx error] ${f}: ${e?.message ?? e}`);
243
+ }
244
+ }
245
+ }
246
+ console.log(`\n=== Summary ===`);
247
+ console.log(` projects: ${agg.projects}`);
248
+ console.log(` sessions parsed: ${agg.sessions_parsed}`);
249
+ console.log(` sessions skipped: ${agg.sessions_skipped}`);
250
+ console.log(` memories planned: ${agg.memories_planned}`);
251
+ console.log(` memories inserted: ${agg.memories_inserted}${dryRun ? ' (DRY RUN — nothing written)' : ''}`);
252
+ console.log(` file_edits inserted:${agg.file_edits_inserted}${dryRun ? ' (DRY RUN)' : ''}`);
253
+ console.log(` errors: ${agg.errors}`);
254
+ if (db)
255
+ db.close();
256
+ }
257
+ main().catch((e) => { console.error(e); process.exit(1); });
258
+ //# sourceMappingURL=import-sessions.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // Stop-hook entrypoint for Claude Code.
3
+ //
4
+ // Claude Code invokes this script when an assistant turn finishes (Stop event).
5
+ // It receives JSON on stdin like: { session_id, transcript_path, cwd, ... }
6
+ // We use transcript_path (the active jsonl) and feed it to the importer.
7
+ //
8
+ // CONTRACT:
9
+ // - MUST exit 0 on success OR failure — never block Claude Code
10
+ // - MUST be silent on stdout (Claude does not need feedback)
11
+ // - All errors logged to ~/.linksee-memory/hook.log
12
+ import { spawnSync } from 'node:child_process';
13
+ import { mkdirSync, appendFileSync, existsSync, statSync, renameSync } from 'node:fs';
14
+ import { join, dirname } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { fileURLToPath } from 'node:url';
17
+ const LOG_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
18
+ const LOG_FILE = join(LOG_DIR, 'hook.log');
19
+ const LOG_MAX_BYTES = 1024 * 1024; // 1 MB → rotate
20
+ function log(msg) {
21
+ try {
22
+ mkdirSync(LOG_DIR, { recursive: true });
23
+ // Rotate if too big
24
+ if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > LOG_MAX_BYTES) {
25
+ try {
26
+ renameSync(LOG_FILE, LOG_FILE + '.1');
27
+ }
28
+ catch { /* ignore */ }
29
+ }
30
+ appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
31
+ }
32
+ catch { /* never throw from log */ }
33
+ }
34
+ async function readStdin() {
35
+ return new Promise((resolve) => {
36
+ let data = '';
37
+ process.stdin.setEncoding('utf8');
38
+ process.stdin.on('data', (chunk) => { data += chunk; });
39
+ process.stdin.on('end', () => resolve(data));
40
+ // If no stdin within 500ms (e.g. invoked manually), give up — Stop hook always provides JSON
41
+ setTimeout(() => resolve(data), 500);
42
+ });
43
+ }
44
+ async function main() {
45
+ const startedAt = Date.now();
46
+ let payload = {};
47
+ try {
48
+ const raw = await readStdin();
49
+ if (raw.trim())
50
+ payload = JSON.parse(raw);
51
+ }
52
+ catch (e) {
53
+ log(`stdin parse error: ${e?.message ?? e}`);
54
+ process.exit(0);
55
+ }
56
+ const transcriptPath = payload.transcript_path;
57
+ const sessionId = payload.session_id;
58
+ const cwd = payload.cwd;
59
+ const stopHookActive = !!payload.stop_hook_active;
60
+ if (!transcriptPath) {
61
+ log(`no transcript_path in payload (session=${sessionId ?? '?'}, keys=${Object.keys(payload).join(',')})`);
62
+ process.exit(0);
63
+ }
64
+ if (stopHookActive) {
65
+ // We're in a recursive Stop chain — do nothing
66
+ log(`stop_hook_active=true, skipping (session=${sessionId})`);
67
+ process.exit(0);
68
+ }
69
+ // Find the importer script — it lives next to us in dist/bin/
70
+ const __filename = fileURLToPath(import.meta.url);
71
+ const importerPath = join(dirname(__filename), 'import-sessions.js');
72
+ if (!existsSync(transcriptPath)) {
73
+ log(`transcript missing: ${transcriptPath}`);
74
+ process.exit(0);
75
+ }
76
+ const r = spawnSync(process.execPath, [importerPath, '--session-file', transcriptPath], {
77
+ encoding: 'utf8',
78
+ timeout: 30000,
79
+ });
80
+ const elapsed = Date.now() - startedAt;
81
+ if (r.error) {
82
+ log(`importer spawn error (session=${sessionId}, ${elapsed}ms): ${r.error.message}`);
83
+ }
84
+ else if (r.status !== 0) {
85
+ log(`importer exited ${r.status} (session=${sessionId}, ${elapsed}ms): ${(r.stderr || '').slice(0, 500)}`);
86
+ }
87
+ else {
88
+ const out = (r.stdout || '').trim().split('\n').slice(-1)[0] || '';
89
+ log(`ok (session=${sessionId}, cwd=${cwd}, ${elapsed}ms): ${out}`);
90
+ }
91
+ process.exit(0);
92
+ }
93
+ main().catch((e) => {
94
+ log(`unhandled: ${e?.message ?? e}`);
95
+ process.exit(0);
96
+ });
97
+ //# sourceMappingURL=sync-session.js.map
@@ -0,0 +1,4 @@
1
+ import Database from 'better-sqlite3';
2
+ export declare function getDbPath(): string;
3
+ export declare function openDb(): Database.Database;
4
+ export declare function runMigrations(db: Database.Database): void;
@@ -0,0 +1,48 @@
1
+ import Database from 'better-sqlite3';
2
+ import { readFileSync } from 'node:fs';
3
+ import { mkdirSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
7
+ const DEFAULT_DB_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
8
+ const DB_PATH = join(DEFAULT_DB_DIR, 'memory.db');
9
+ export function getDbPath() {
10
+ return DB_PATH;
11
+ }
12
+ export function openDb() {
13
+ mkdirSync(DEFAULT_DB_DIR, { recursive: true });
14
+ const db = new Database(DB_PATH);
15
+ db.pragma('journal_mode = WAL');
16
+ db.pragma('foreign_keys = ON');
17
+ return db;
18
+ }
19
+ export function runMigrations(db) {
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const schemaPath = join(dirname(__filename), 'schema.sql');
22
+ const sql = readFileSync(schemaPath, 'utf8');
23
+ // v3 → v4: rebuild memories_fts with trigram tokenizer for JP/CJK support
24
+ const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
25
+ if (versionRow && Number(versionRow.value) < 4) {
26
+ // Drop old triggers + table; CREATE statements below will recreate them
27
+ db.exec(`
28
+ DROP TRIGGER IF EXISTS trg_memories_fts_ai;
29
+ DROP TRIGGER IF EXISTS trg_memories_fts_ad;
30
+ DROP TRIGGER IF EXISTS trg_memories_fts_au;
31
+ DROP TABLE IF EXISTS memories_fts;
32
+ `);
33
+ }
34
+ db.exec(sql);
35
+ // After v4 schema is applied, repopulate FTS index from existing memories
36
+ if (versionRow && Number(versionRow.value) < 4) {
37
+ db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
38
+ }
39
+ }
40
+ // CLI entrypoint
41
+ if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('migrate.ts') || process.argv[1]?.endsWith('migrate.js')) {
42
+ const db = openDb();
43
+ runMigrations(db);
44
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
45
+ console.log(`[linksee-memory] migrated ${DB_PATH} (schema v${row?.value ?? '?'})`);
46
+ db.close();
47
+ }
48
+ //# sourceMappingURL=migrate.js.map