parallelclaw 1.0.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.
- package/CHANGELOG.md +204 -0
- package/HELP.md +600 -0
- package/LICENSE +21 -0
- package/MULTI_MACHINE.md +152 -0
- package/README.md +417 -0
- package/README.ru.md +740 -0
- package/SYNC.md +844 -0
- package/bot/README.md +173 -0
- package/bot/config.js +66 -0
- package/bot/inbox.js +153 -0
- package/bot/index.js +294 -0
- package/bot/nexara.js +61 -0
- package/bot/poll.js +304 -0
- package/bot/search.js +155 -0
- package/bot/telegram.js +96 -0
- package/ingest.js +2712 -0
- package/lib/cli/index.js +1987 -0
- package/lib/config.js +220 -0
- package/lib/db-init.js +158 -0
- package/lib/hook/install.js +268 -0
- package/lib/import-telegram.js +158 -0
- package/lib/ingest-file.js +779 -0
- package/lib/notify-click-action.js +281 -0
- package/lib/openclaw-channel.js +643 -0
- package/lib/parse-cursor.js +172 -0
- package/lib/parse-obsidian.js +256 -0
- package/lib/parse-telegram-html.js +384 -0
- package/lib/parse.js +175 -0
- package/lib/render-markdown.js +0 -0
- package/lib/store-doc/canonicalize.js +116 -0
- package/lib/store-doc/detect.js +209 -0
- package/lib/store-doc/extract-title.js +162 -0
- package/lib/sync/auth.js +80 -0
- package/lib/sync/cert.js +144 -0
- package/lib/sync/cli.js +906 -0
- package/lib/sync/client.js +138 -0
- package/lib/sync/config.js +130 -0
- package/lib/sync/pair.js +145 -0
- package/lib/sync/pull.js +158 -0
- package/lib/sync/push.js +305 -0
- package/lib/sync/replicate.js +335 -0
- package/lib/sync/server.js +224 -0
- package/lib/sync/service.js +726 -0
- package/lib/tasks.js +215 -0
- package/lib/telegram-decisions.js +165 -0
- package/lib/telegram-discovery.js +373 -0
- package/lib/telegram-notify.js +272 -0
- package/lib/telegram-pending.js +200 -0
- package/lib/web/index.js +265 -0
- package/lib/web/routes/conversation.js +193 -0
- package/lib/web/routes/conversations.js +180 -0
- package/lib/web/routes/dashboard.js +175 -0
- package/lib/web/routes/pending.js +277 -0
- package/lib/web/routes/settings.js +226 -0
- package/lib/web/static/style.css +393 -0
- package/lib/web/templates.js +234 -0
- package/package.json +84 -0
- package/server.js +3816 -0
- package/skills/install-memex/README.md +109 -0
- package/skills/install-memex/SKILL.md +342 -0
- package/skills/install-memex/examples.md +294 -0
- package/skills/install-memex-claw/SKILL.md +423 -0
package/lib/config.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memex configuration: ~/.memex/config.json
|
|
3
|
+
*
|
|
4
|
+
* Schema:
|
|
5
|
+
* {
|
|
6
|
+
* sources: {
|
|
7
|
+
* claude_code: true | false,
|
|
8
|
+
* claude_cowork: true | false,
|
|
9
|
+
* cursor: true | false,
|
|
10
|
+
* obsidian: true | false | { enabled: bool, vaults: string[] }
|
|
11
|
+
* }
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Behavior:
|
|
15
|
+
* - File missing → defaults below (everything ON if its data exists). Preserves
|
|
16
|
+
* backward compat for users who installed before config was a thing.
|
|
17
|
+
* - File present but partial → merged with defaults.
|
|
18
|
+
* - Env var MEMEX_OBSIDIAN_VAULTS overrides config.sources.obsidian.vaults
|
|
19
|
+
* (useful for cron/scripts without touching the file).
|
|
20
|
+
*
|
|
21
|
+
* CLI source names accept both "claude-code" and "claude_code" forms;
|
|
22
|
+
* normalizeSourceName() canonicalises to underscore (matches JSON keys).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
26
|
+
import { homedir, hostname } from 'node:os';
|
|
27
|
+
import { join, dirname, resolve } from 'node:path';
|
|
28
|
+
|
|
29
|
+
const HOME = homedir();
|
|
30
|
+
const MEMEX_DIR = process.env.MEMEX_DIR || join(HOME, '.memex');
|
|
31
|
+
export const CONFIG_PATH = join(MEMEX_DIR, 'config.json');
|
|
32
|
+
|
|
33
|
+
export const KNOWN_SOURCES = ['claude_code', 'claude_cowork', 'cursor', 'obsidian', 'openclaw'];
|
|
34
|
+
|
|
35
|
+
/** What the daemon does when no config file exists — preserve current behavior. */
|
|
36
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
37
|
+
sources: {
|
|
38
|
+
claude_code: true,
|
|
39
|
+
claude_cowork: true,
|
|
40
|
+
cursor: true,
|
|
41
|
+
obsidian: { enabled: true, vaults: [] }, // empty vaults → autodetect
|
|
42
|
+
openclaw: true, // v0.10.14+: auto-capture from ~/.openclaw/agents/main/sessions/
|
|
43
|
+
},
|
|
44
|
+
search: {
|
|
45
|
+
// Half-life in days for the temporal recency boost in memex_search.
|
|
46
|
+
// Score = bm25 * exp(-age_days / half_life). 30d ≈ recent week dominates,
|
|
47
|
+
// month-old halved, 3-month-old in long tail. Set to 0 to disable.
|
|
48
|
+
half_life_days: 30,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Origin = this node's identity stamped onto every LOCALLY-captured row
|
|
54
|
+
* (v0.14 provenance). Sanitised to [a-z0-9-] (max 24) so callers may bake it
|
|
55
|
+
* into prepared SQL as a literal. Resolution order:
|
|
56
|
+
* 1. MEMEX_ORIGIN env (tests, plugins, one-off overrides)
|
|
57
|
+
* 2. config.json `origin` (set once; users rename their node here)
|
|
58
|
+
* 3. short hostname — derived AND persisted, so a later hostname change
|
|
59
|
+
* doesn't silently fork the node's identity.
|
|
60
|
+
* Rows arriving via SYNC keep the origin they were stamped with at capture —
|
|
61
|
+
* never the local one. Pre-provenance rows stay NULL ("unknown era").
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeOrigin(value) {
|
|
64
|
+
const s = String(value || '').split('.')[0].toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
|
|
66
|
+
return s || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getOrigin(config) {
|
|
70
|
+
const fromEnv = sanitizeOrigin(process.env.MEMEX_ORIGIN);
|
|
71
|
+
if (fromEnv) return fromEnv;
|
|
72
|
+
const cfg = config || loadConfig();
|
|
73
|
+
const fromCfg = sanitizeOrigin(cfg.origin);
|
|
74
|
+
if (fromCfg) return fromCfg;
|
|
75
|
+
const derived = sanitizeOrigin(hostname()) || 'node';
|
|
76
|
+
cfg.origin = derived;
|
|
77
|
+
try { saveConfig(cfg); } catch (_) { /* read-only env — still usable */ }
|
|
78
|
+
return derived;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Returns the configured default half-life (days) for recency boost. 0 disables. */
|
|
82
|
+
export function getSearchHalfLifeDays(config) {
|
|
83
|
+
const v = config && config.search && config.search.half_life_days;
|
|
84
|
+
if (typeof v !== 'number' || !isFinite(v) || v < 0) return 30;
|
|
85
|
+
return v;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Normalise a CLI source name. Accepts "claude-code", "claude_code", "code"
|
|
90
|
+
* (alias), "cowork" (alias). Returns canonical name or null.
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeSourceName(input) {
|
|
93
|
+
if (!input) return null;
|
|
94
|
+
const s = String(input).toLowerCase().replace(/-/g, '_');
|
|
95
|
+
const aliases = {
|
|
96
|
+
code: 'claude_code',
|
|
97
|
+
cowork: 'claude_cowork',
|
|
98
|
+
};
|
|
99
|
+
const canonical = aliases[s] || s;
|
|
100
|
+
return KNOWN_SOURCES.includes(canonical) ? canonical : null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function loadConfig() {
|
|
104
|
+
if (!existsSync(CONFIG_PATH)) return clone(DEFAULT_CONFIG);
|
|
105
|
+
let raw;
|
|
106
|
+
try { raw = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')); }
|
|
107
|
+
catch (_) { return clone(DEFAULT_CONFIG); }
|
|
108
|
+
return mergeWithDefaults(raw, DEFAULT_CONFIG);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function saveConfig(config) {
|
|
112
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
113
|
+
const tmp = CONFIG_PATH + '.tmp';
|
|
114
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2));
|
|
115
|
+
renameSync(tmp, CONFIG_PATH);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Is a given named source enabled?
|
|
120
|
+
* - boolean → that
|
|
121
|
+
* - object with .enabled → that
|
|
122
|
+
* - undefined → default-on
|
|
123
|
+
*/
|
|
124
|
+
export function isSourceEnabled(name, config) {
|
|
125
|
+
const v = config.sources && config.sources[name];
|
|
126
|
+
if (v === undefined || v === null) return true;
|
|
127
|
+
if (typeof v === 'boolean') return v;
|
|
128
|
+
if (typeof v === 'object' && 'enabled' in v) return !!v.enabled;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Mutate config to set source enabled/disabled. Preserves nested structure for obsidian. */
|
|
133
|
+
export function setSourceEnabled(name, enabled, config) {
|
|
134
|
+
if (!config.sources) config.sources = {};
|
|
135
|
+
const existing = config.sources[name];
|
|
136
|
+
if (typeof existing === 'object' && existing !== null) {
|
|
137
|
+
existing.enabled = !!enabled;
|
|
138
|
+
} else {
|
|
139
|
+
config.sources[name] = !!enabled;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Get configured Obsidian vault list (config + env var). Returns absolute paths. */
|
|
144
|
+
export function obsidianVaultsFromConfig(config) {
|
|
145
|
+
const out = [];
|
|
146
|
+
const fromConfig = config.sources && config.sources.obsidian;
|
|
147
|
+
if (fromConfig && typeof fromConfig === 'object' && Array.isArray(fromConfig.vaults)) {
|
|
148
|
+
for (const v of fromConfig.vaults) out.push(expandTilde(v));
|
|
149
|
+
}
|
|
150
|
+
const fromEnv = (process.env.MEMEX_OBSIDIAN_VAULTS || '')
|
|
151
|
+
.split(',')
|
|
152
|
+
.map((s) => s.trim())
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.map(expandTilde);
|
|
155
|
+
// Dedup, env wins
|
|
156
|
+
return [...new Set([...fromEnv, ...out])];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function addObsidianVault(path, config) {
|
|
160
|
+
const abs = resolve(expandTilde(path));
|
|
161
|
+
if (!config.sources) config.sources = {};
|
|
162
|
+
if (!config.sources.obsidian || typeof config.sources.obsidian !== 'object') {
|
|
163
|
+
config.sources.obsidian = { enabled: true, vaults: [] };
|
|
164
|
+
}
|
|
165
|
+
if (!Array.isArray(config.sources.obsidian.vaults)) {
|
|
166
|
+
config.sources.obsidian.vaults = [];
|
|
167
|
+
}
|
|
168
|
+
if (!config.sources.obsidian.vaults.includes(abs)) {
|
|
169
|
+
config.sources.obsidian.vaults.push(abs);
|
|
170
|
+
}
|
|
171
|
+
return abs;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function removeObsidianVault(path, config) {
|
|
175
|
+
const abs = resolve(expandTilde(path));
|
|
176
|
+
const obs = config.sources && config.sources.obsidian;
|
|
177
|
+
if (!obs || typeof obs !== 'object' || !Array.isArray(obs.vaults)) return false;
|
|
178
|
+
const before = obs.vaults.length;
|
|
179
|
+
obs.vaults = obs.vaults.filter((v) => resolve(expandTilde(v)) !== abs);
|
|
180
|
+
return obs.vaults.length !== before;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// -------------------- Internal helpers --------------------
|
|
184
|
+
function clone(o) {
|
|
185
|
+
return JSON.parse(JSON.stringify(o));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function mergeWithDefaults(parsed, defaults) {
|
|
189
|
+
const out = clone(defaults);
|
|
190
|
+
if (!parsed || typeof parsed !== 'object') return out;
|
|
191
|
+
if (parsed.sources && typeof parsed.sources === 'object') {
|
|
192
|
+
for (const key of KNOWN_SOURCES) {
|
|
193
|
+
if (key in parsed.sources) out.sources[key] = parsed.sources[key];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (parsed.search && typeof parsed.search === 'object') {
|
|
197
|
+
if (typeof parsed.search.half_life_days === 'number') {
|
|
198
|
+
out.search.half_life_days = parsed.search.half_life_days;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// v0.11.11 experimental sync — passthrough whatever lives under "sync".
|
|
202
|
+
// The lib/sync/config.js module owns the shape and defaults; we just
|
|
203
|
+
// make sure round-tripping the file doesn't drop it on the floor.
|
|
204
|
+
if (parsed.sync && typeof parsed.sync === 'object') {
|
|
205
|
+
out.sync = parsed.sync;
|
|
206
|
+
}
|
|
207
|
+
// v0.14 provenance — this node's identity (see getOrigin). A plain string
|
|
208
|
+
// key; must survive load/save round-trips or the node forks identity.
|
|
209
|
+
if (typeof parsed.origin === 'string' && parsed.origin) {
|
|
210
|
+
out.origin = parsed.origin;
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function expandTilde(p) {
|
|
216
|
+
if (!p) return p;
|
|
217
|
+
if (p === '~' || p === '~/') return HOME;
|
|
218
|
+
if (p.startsWith('~/')) return join(HOME, p.slice(2));
|
|
219
|
+
return p;
|
|
220
|
+
}
|
package/lib/db-init.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-init for memex.db, factored out of server.js so the daemon
|
|
3
|
+
* (memex-sync install) and any other entry point can create the DB
|
|
4
|
+
* idempotently before the MCP server is ever spawned.
|
|
5
|
+
*
|
|
6
|
+
* Why: on a clean machine the MCP server is the first writer that opens
|
|
7
|
+
* the DB. If a user runs `memex-sync install` + `memex-sync scan` then
|
|
8
|
+
* tries `memex overview` BEFORE restarting their MCP client, the CLI
|
|
9
|
+
* (which opens the DB in read-only mode) errors with "memex.db not
|
|
10
|
+
* found". Fix: have the daemon initialise the DB at install-time —
|
|
11
|
+
* empty tables, but openable.
|
|
12
|
+
*
|
|
13
|
+
* Public:
|
|
14
|
+
* initializeDb(dbPath) → Database
|
|
15
|
+
* Opens the DB (creating the file if missing), runs every migration
|
|
16
|
+
* in the right order, and returns the better-sqlite3 handle. Caller
|
|
17
|
+
* is responsible for closing it.
|
|
18
|
+
*
|
|
19
|
+
* Idempotent — safe to call against an existing DB with content. Every
|
|
20
|
+
* CREATE / ALTER / DROP+CREATE is wrapped to swallow "already exists"
|
|
21
|
+
* style errors.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { mkdirSync } from 'node:fs';
|
|
25
|
+
import { dirname } from 'node:path';
|
|
26
|
+
import Database from 'better-sqlite3';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create or open ~/.memex/data/memex.db, apply all schema migrations,
|
|
30
|
+
* return the handle. Same code path that server.js used to run inline
|
|
31
|
+
* — extracted so memex-sync (and tests, and tooling) can run it too.
|
|
32
|
+
*/
|
|
33
|
+
export function initializeDb(dbPath) {
|
|
34
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
35
|
+
const db = new Database(dbPath);
|
|
36
|
+
db.pragma('journal_mode = WAL');
|
|
37
|
+
db.pragma('synchronous = NORMAL');
|
|
38
|
+
|
|
39
|
+
// Base tables + indices + FTS5 virtual table. CREATE IF NOT EXISTS
|
|
40
|
+
// makes this safe against existing DBs.
|
|
41
|
+
db.exec(`
|
|
42
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
source TEXT NOT NULL,
|
|
45
|
+
conversation_id TEXT NOT NULL,
|
|
46
|
+
msg_id TEXT,
|
|
47
|
+
role TEXT,
|
|
48
|
+
sender TEXT,
|
|
49
|
+
text TEXT,
|
|
50
|
+
ts INTEGER,
|
|
51
|
+
metadata TEXT,
|
|
52
|
+
UNIQUE(source, conversation_id, msg_id)
|
|
53
|
+
);
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_messages_source ON messages(source);
|
|
57
|
+
|
|
58
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
|
|
59
|
+
text, sender, conversation_id, source,
|
|
60
|
+
content=messages, content_rowid=id,
|
|
61
|
+
tokenize='unicode61 remove_diacritics 2'
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
65
|
+
conversation_id TEXT PRIMARY KEY,
|
|
66
|
+
source TEXT NOT NULL,
|
|
67
|
+
title TEXT,
|
|
68
|
+
first_ts INTEGER,
|
|
69
|
+
last_ts INTEGER,
|
|
70
|
+
message_count INTEGER DEFAULT 0
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS imports (
|
|
74
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
75
|
+
file_name TEXT,
|
|
76
|
+
source TEXT,
|
|
77
|
+
imported_at INTEGER,
|
|
78
|
+
message_count INTEGER
|
|
79
|
+
);
|
|
80
|
+
`);
|
|
81
|
+
|
|
82
|
+
// ALTER-style migrations — these run idempotently by swallowing the
|
|
83
|
+
// "duplicate column" error. Same set + same order as server.js.
|
|
84
|
+
const safeAlter = (sql) => {
|
|
85
|
+
try { db.exec(sql); }
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (!String(err.message).includes('duplicate column name')) throw err;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
safeAlter(`ALTER TABLE conversations ADD COLUMN archived_at INTEGER`);
|
|
91
|
+
safeAlter(`ALTER TABLE conversations ADD COLUMN parent_conversation_id TEXT`);
|
|
92
|
+
safeAlter(`ALTER TABLE conversations ADD COLUMN project_path TEXT`);
|
|
93
|
+
safeAlter(`ALTER TABLE conversations ADD COLUMN pending_parent_uuid TEXT`);
|
|
94
|
+
safeAlter(`ALTER TABLE messages ADD COLUMN edited_at INTEGER`);
|
|
95
|
+
safeAlter(`ALTER TABLE messages ADD COLUMN uuid TEXT`);
|
|
96
|
+
// v0.11 — channel-aware routing for OpenClaw + future multi-channel sources.
|
|
97
|
+
// Values used today:
|
|
98
|
+
// 'telegram' — Telegram message captured via OpenClaw (or native TG export)
|
|
99
|
+
// 'kimi-web' — Kimi web chat through OpenClaw
|
|
100
|
+
// 'system' — OpenClaw's own diagnostic / Exec output
|
|
101
|
+
// NULL — channel not detected / source has no channel concept
|
|
102
|
+
// (Claude Code, Cowork, Cursor, Obsidian — all NULL)
|
|
103
|
+
safeAlter(`ALTER TABLE messages ADD COLUMN channel TEXT`);
|
|
104
|
+
// v0.14 — provenance: WHICH NODE captured this row. Stamped at capture time
|
|
105
|
+
// (lib/config.js getOrigin — sanitised hostname / config `origin`), carried
|
|
106
|
+
// verbatim over sync like `channel`. NULL = pre-provenance era or unknown.
|
|
107
|
+
// In a synced mesh, two nodes' captures can share source AND conversation_id
|
|
108
|
+
// (e.g. two OpenClaw instances bridging the same Telegram account) — origin
|
|
109
|
+
// is the only way to tell whose row it is. See SYNC.md backlog #6.
|
|
110
|
+
safeAlter(`ALTER TABLE messages ADD COLUMN origin TEXT`);
|
|
111
|
+
|
|
112
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_parent ON conversations(parent_conversation_id)`);
|
|
113
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_project
|
|
114
|
+
ON conversations(project_path) WHERE project_path IS NOT NULL`);
|
|
115
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_uuid
|
|
116
|
+
ON messages(uuid) WHERE uuid IS NOT NULL`);
|
|
117
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_channel
|
|
118
|
+
ON messages(channel) WHERE channel IS NOT NULL`);
|
|
119
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_origin
|
|
120
|
+
ON messages(origin) WHERE origin IS NOT NULL`);
|
|
121
|
+
|
|
122
|
+
// Pre-0.4 imports tables could have duplicate rows from re-running the
|
|
123
|
+
// server. Collapse before installing the UNIQUE index.
|
|
124
|
+
db.exec(`
|
|
125
|
+
DELETE FROM imports
|
|
126
|
+
WHERE id NOT IN (
|
|
127
|
+
SELECT MAX(id) FROM imports GROUP BY file_name, source, message_count
|
|
128
|
+
)
|
|
129
|
+
`);
|
|
130
|
+
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_imports_unique
|
|
131
|
+
ON imports(file_name, source, message_count)`);
|
|
132
|
+
|
|
133
|
+
// FTS5 triggers (rewritten 0.6) — exclude role IN ('boundary','summary')
|
|
134
|
+
// from messages_fts so synthetic compaction summaries don't double-count
|
|
135
|
+
// against the original raw turns. Drop+create is idempotent.
|
|
136
|
+
db.exec(`
|
|
137
|
+
DROP TRIGGER IF EXISTS messages_fts_ai;
|
|
138
|
+
DROP TRIGGER IF EXISTS messages_fts_ad;
|
|
139
|
+
DROP TRIGGER IF EXISTS messages_fts_au;
|
|
140
|
+
CREATE TRIGGER messages_fts_ai AFTER INSERT ON messages
|
|
141
|
+
WHEN new.role NOT IN ('boundary', 'summary')
|
|
142
|
+
BEGIN
|
|
143
|
+
INSERT INTO messages_fts(rowid, text, sender, conversation_id, source)
|
|
144
|
+
VALUES (new.id, new.text, new.sender, new.conversation_id, new.source);
|
|
145
|
+
END;
|
|
146
|
+
CREATE TRIGGER messages_fts_ad AFTER DELETE ON messages BEGIN
|
|
147
|
+
DELETE FROM messages_fts WHERE rowid = old.id;
|
|
148
|
+
END;
|
|
149
|
+
CREATE TRIGGER messages_fts_au AFTER UPDATE ON messages BEGIN
|
|
150
|
+
DELETE FROM messages_fts WHERE rowid = old.id;
|
|
151
|
+
INSERT INTO messages_fts(rowid, text, sender, conversation_id, source)
|
|
152
|
+
SELECT new.id, new.text, new.sender, new.conversation_id, new.source
|
|
153
|
+
WHERE new.role NOT IN ('boundary', 'summary');
|
|
154
|
+
END;
|
|
155
|
+
`);
|
|
156
|
+
|
|
157
|
+
return db;
|
|
158
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code SessionStart hook installer for memex auto-context.
|
|
3
|
+
*
|
|
4
|
+
* When the user opens a new Claude Code session, Claude Code looks at
|
|
5
|
+
* ~/.claude/settings.json for `hooks.SessionStart` entries and runs each
|
|
6
|
+
* command before showing the user the first prompt. The stdout of those
|
|
7
|
+
* commands gets injected into Claude's context as a system message.
|
|
8
|
+
*
|
|
9
|
+
* Memex's hook calls `memex context` which outputs a markdown summary of
|
|
10
|
+
* recent memex activity relevant to the current pwd. End result: Claude
|
|
11
|
+
* "knows" what you were doing in this project without you having to ask.
|
|
12
|
+
*
|
|
13
|
+
* Idempotency: install operations are safe to re-run. We detect our entry
|
|
14
|
+
* by command-string match — if any SessionStart hook command starts with
|
|
15
|
+
* MEMEX_COMMAND_MARKER, we treat it as ours and don't add another.
|
|
16
|
+
*
|
|
17
|
+
* Atomicity: we always write to a .tmp file first, then rename. Never
|
|
18
|
+
* touch the user's existing hooks for other tools.
|
|
19
|
+
*
|
|
20
|
+
* Cross-client: only Claude Code and OpenClaw have SessionStart natively.
|
|
21
|
+
* For Cursor, fallback strategies (MCP resource, skills,
|
|
22
|
+
* system prompt) are tracked separately — not part of this module.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
readFileSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
} from 'node:fs';
|
|
34
|
+
import { execSync } from 'node:child_process';
|
|
35
|
+
|
|
36
|
+
const HOME = homedir();
|
|
37
|
+
const CLAUDE_DIR = join(HOME, '.claude');
|
|
38
|
+
const SETTINGS_PATH = join(CLAUDE_DIR, 'settings.json');
|
|
39
|
+
|
|
40
|
+
// Command marker — every memex hook command starts with this. Used to
|
|
41
|
+
// detect our own entry for idempotency / uninstall, without collision
|
|
42
|
+
// risk against other tools' hooks (gstack, custom user hooks, etc.).
|
|
43
|
+
const MEMEX_COMMAND_MARKER = 'memex context';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns the absolute path to the `memex` binary that should be used in
|
|
47
|
+
* the hook command. Tries multiple strategies in order:
|
|
48
|
+
* 1. `which memex` (npm-global install)
|
|
49
|
+
* 2. process.execPath + this module's known location (current invocation)
|
|
50
|
+
* 3. fallback to bare "memex" (relies on PATH at hook execution time)
|
|
51
|
+
*
|
|
52
|
+
* Returns the resolved path string.
|
|
53
|
+
*/
|
|
54
|
+
export function resolveMemexBinPath() {
|
|
55
|
+
try {
|
|
56
|
+
const which = execSync('which memex', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
57
|
+
if (which && existsSync(which)) return which;
|
|
58
|
+
} catch (_) {}
|
|
59
|
+
// Fallback: rely on PATH at hook-execution time. Claude Code loads
|
|
60
|
+
// user shell environment for hooks, so PATH usually works.
|
|
61
|
+
return 'memex';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read ~/.claude/settings.json safely. Returns:
|
|
66
|
+
* { exists: bool, valid: bool, data: object, raw: string|null }
|
|
67
|
+
*/
|
|
68
|
+
export function readSettings() {
|
|
69
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
70
|
+
return { exists: false, valid: true, data: {}, raw: null };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const raw = readFileSync(SETTINGS_PATH, 'utf-8');
|
|
74
|
+
const data = JSON.parse(raw);
|
|
75
|
+
return { exists: true, valid: true, data, raw };
|
|
76
|
+
} catch (e) {
|
|
77
|
+
return {
|
|
78
|
+
exists: true,
|
|
79
|
+
valid: false,
|
|
80
|
+
data: {},
|
|
81
|
+
raw: null,
|
|
82
|
+
error: e.message,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Find our memex SessionStart hook entry in the parsed settings.
|
|
89
|
+
* Returns { found: bool, index: number, command: string|null }.
|
|
90
|
+
*
|
|
91
|
+
* Claude Code's hooks schema (as of 2026):
|
|
92
|
+
* settings.hooks.SessionStart = [
|
|
93
|
+
* { matcher: "...", hooks: [{ type: "command", command: "..." }] }
|
|
94
|
+
* ]
|
|
95
|
+
*
|
|
96
|
+
* We treat an outer entry as "ours" if any of its inner hooks has a
|
|
97
|
+
* command string containing MEMEX_COMMAND_MARKER.
|
|
98
|
+
*/
|
|
99
|
+
export function findMemexHookEntry(settings) {
|
|
100
|
+
const sessionStart = settings?.hooks?.SessionStart;
|
|
101
|
+
if (!Array.isArray(sessionStart)) {
|
|
102
|
+
return { found: false, index: -1, command: null };
|
|
103
|
+
}
|
|
104
|
+
for (let i = 0; i < sessionStart.length; i++) {
|
|
105
|
+
const entry = sessionStart[i];
|
|
106
|
+
const inner = entry?.hooks;
|
|
107
|
+
if (!Array.isArray(inner)) continue;
|
|
108
|
+
for (const h of inner) {
|
|
109
|
+
if (typeof h?.command === 'string' && h.command.includes(MEMEX_COMMAND_MARKER)) {
|
|
110
|
+
return { found: true, index: i, command: h.command };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { found: false, index: -1, command: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Add the memex SessionStart hook entry to ~/.claude/settings.json.
|
|
119
|
+
*
|
|
120
|
+
* Idempotent: if a memex entry already exists, no-op (returns
|
|
121
|
+
* alreadyPresent: true). If the user has OTHER SessionStart hooks (e.g.
|
|
122
|
+
* from gstack), they are preserved untouched — we only append our entry
|
|
123
|
+
* to the array.
|
|
124
|
+
*
|
|
125
|
+
* Returns:
|
|
126
|
+
* { installed: bool, alreadyPresent: bool, settingsPath: str,
|
|
127
|
+
* command: str, error: str|null }
|
|
128
|
+
*/
|
|
129
|
+
export function installHook(opts = {}) {
|
|
130
|
+
const binPath = opts.binPath || resolveMemexBinPath();
|
|
131
|
+
const command = `${binPath} context`;
|
|
132
|
+
|
|
133
|
+
const settings = readSettings();
|
|
134
|
+
if (settings.exists && !settings.valid) {
|
|
135
|
+
return {
|
|
136
|
+
installed: false,
|
|
137
|
+
alreadyPresent: false,
|
|
138
|
+
settingsPath: SETTINGS_PATH,
|
|
139
|
+
command,
|
|
140
|
+
error: `Could not parse ${SETTINGS_PATH}: ${settings.error}. Fix the file manually first.`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const data = settings.data || {};
|
|
145
|
+
const existing = findMemexHookEntry(data);
|
|
146
|
+
if (existing.found) {
|
|
147
|
+
return {
|
|
148
|
+
installed: false,
|
|
149
|
+
alreadyPresent: true,
|
|
150
|
+
settingsPath: SETTINGS_PATH,
|
|
151
|
+
command: existing.command,
|
|
152
|
+
error: null,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Build our entry. Use ".*" matcher (match any session) and the standard
|
|
157
|
+
// {type: "command", command: ...} inner hook shape.
|
|
158
|
+
const memexEntry = {
|
|
159
|
+
matcher: '.*',
|
|
160
|
+
hooks: [{ type: 'command', command }],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Defensive nested-set: never clobber adjacent keys
|
|
164
|
+
if (!data.hooks) data.hooks = {};
|
|
165
|
+
if (!Array.isArray(data.hooks.SessionStart)) data.hooks.SessionStart = [];
|
|
166
|
+
data.hooks.SessionStart.push(memexEntry);
|
|
167
|
+
|
|
168
|
+
// Atomic write — temp file + rename
|
|
169
|
+
try {
|
|
170
|
+
mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
171
|
+
const tmpPath = SETTINGS_PATH + '.tmp';
|
|
172
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
173
|
+
renameSync(tmpPath, SETTINGS_PATH);
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return {
|
|
176
|
+
installed: false,
|
|
177
|
+
alreadyPresent: false,
|
|
178
|
+
settingsPath: SETTINGS_PATH,
|
|
179
|
+
command,
|
|
180
|
+
error: `Failed to write ${SETTINGS_PATH}: ${e.message}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
installed: true,
|
|
186
|
+
alreadyPresent: false,
|
|
187
|
+
settingsPath: SETTINGS_PATH,
|
|
188
|
+
command,
|
|
189
|
+
error: null,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Remove the memex SessionStart hook entry. Preserves all other hooks.
|
|
195
|
+
*
|
|
196
|
+
* Returns: { removed: bool, wasPresent: bool, error: str|null }
|
|
197
|
+
*/
|
|
198
|
+
export function uninstallHook() {
|
|
199
|
+
const settings = readSettings();
|
|
200
|
+
if (!settings.exists) {
|
|
201
|
+
return { removed: false, wasPresent: false, error: null };
|
|
202
|
+
}
|
|
203
|
+
if (!settings.valid) {
|
|
204
|
+
return {
|
|
205
|
+
removed: false,
|
|
206
|
+
wasPresent: false,
|
|
207
|
+
error: `Could not parse ${SETTINGS_PATH}: ${settings.error}`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const data = settings.data;
|
|
212
|
+
const existing = findMemexHookEntry(data);
|
|
213
|
+
if (!existing.found) {
|
|
214
|
+
return { removed: false, wasPresent: false, error: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Remove our entry from the SessionStart array
|
|
218
|
+
data.hooks.SessionStart.splice(existing.index, 1);
|
|
219
|
+
|
|
220
|
+
// Cleanup empty containers — don't leave behind `hooks: {SessionStart: []}`
|
|
221
|
+
// detritus if memex was the only hook.
|
|
222
|
+
if (data.hooks.SessionStart.length === 0) delete data.hooks.SessionStart;
|
|
223
|
+
if (data.hooks && Object.keys(data.hooks).length === 0) delete data.hooks;
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const tmpPath = SETTINGS_PATH + '.tmp';
|
|
227
|
+
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
228
|
+
renameSync(tmpPath, SETTINGS_PATH);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
return {
|
|
231
|
+
removed: false,
|
|
232
|
+
wasPresent: true,
|
|
233
|
+
error: `Failed to write ${SETTINGS_PATH}: ${e.message}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { removed: true, wasPresent: true, error: null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Inspect current hook status. Returns:
|
|
242
|
+
* { installed: bool, settingsPath: str, command: str|null,
|
|
243
|
+
* otherSessionStartHooks: number, settingsExists: bool,
|
|
244
|
+
* settingsValid: bool }
|
|
245
|
+
*/
|
|
246
|
+
export function getHookStatus() {
|
|
247
|
+
const settings = readSettings();
|
|
248
|
+
const result = {
|
|
249
|
+
installed: false,
|
|
250
|
+
settingsPath: SETTINGS_PATH,
|
|
251
|
+
command: null,
|
|
252
|
+
otherSessionStartHooks: 0,
|
|
253
|
+
settingsExists: settings.exists,
|
|
254
|
+
settingsValid: settings.valid,
|
|
255
|
+
};
|
|
256
|
+
if (!settings.exists || !settings.valid) return result;
|
|
257
|
+
|
|
258
|
+
const sessionStart = settings.data?.hooks?.SessionStart || [];
|
|
259
|
+
const found = findMemexHookEntry(settings.data);
|
|
260
|
+
result.installed = found.found;
|
|
261
|
+
result.command = found.command;
|
|
262
|
+
result.otherSessionStartHooks = found.found
|
|
263
|
+
? sessionStart.length - 1
|
|
264
|
+
: sessionStart.length;
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export { MEMEX_COMMAND_MARKER, SETTINGS_PATH };
|