memex-mvp 0.7.0 → 0.8.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/HELP.md +34 -1
- package/README.md +25 -1
- package/README.ru.md +23 -1
- package/ingest.js +112 -2
- package/lib/cli/index.js +489 -2
- package/lib/hook/install.js +268 -0
- package/package.json +2 -2
- package/skills/install-memex/SKILL.md +22 -2
package/HELP.md
CHANGED
|
@@ -301,6 +301,10 @@ Memex по дефолту сортирует по **релевантности**
|
|
|
301
301
|
memex search "Postgres миграция" # FTS5 поиск
|
|
302
302
|
memex search "Q2 deck" --chat "Memex Bot" # фильтр по title чата
|
|
303
303
|
memex search "auth" --source claude-code --limit 5 --sort date_desc
|
|
304
|
+
memex search "JWT" --as-of 2026-05-01 # time-travel (v0.8.1+) — только до даты
|
|
305
|
+
|
|
306
|
+
memex when "Brian Chesky" # «когда мы об этом говорили»
|
|
307
|
+
memex when "JWT decision" --limit 5 # → chronological list of chats, без snippets
|
|
304
308
|
|
|
305
309
|
memex recent --limit 5 # последние сообщения
|
|
306
310
|
memex recent --source telegram
|
|
@@ -310,7 +314,7 @@ memex list --source web # только сохранё
|
|
|
310
314
|
|
|
311
315
|
memex get web-1582ab51a7b7 # полный контент conversation
|
|
312
316
|
|
|
313
|
-
memex overview # snapshot корпуса
|
|
317
|
+
memex overview # snapshot корпуса (+ capture streak v0.8.1+)
|
|
314
318
|
memex projects # уникальные project_paths
|
|
315
319
|
memex help # эта инструкция в терминале
|
|
316
320
|
memex --help # справка по командам
|
|
@@ -338,6 +342,35 @@ memex get web-1582ab51a7b7 --json > backup.json
|
|
|
338
342
|
|
|
339
343
|
---
|
|
340
344
|
|
|
345
|
+
## 🪄 Auto-context (v0.8+) — Brian Chesky moment
|
|
346
|
+
|
|
347
|
+
Magic-фича. Когда ты открываешь Claude Code в проекте, Claude **сам** инжектит 500-1500 токенов контекста про этот проект — что ты делал недавно, какие conversations касались темы. Ты ещё ничего не спросил, а AI **уже знает**.
|
|
348
|
+
|
|
349
|
+
**Технически:** SessionStart hook в `~/.claude/settings.json`. При старте каждой Claude Code сессии хук вызывает `memex context` → memex выдаёт markdown summary → Claude получает его как system message _до_ твоего первого вопроса.
|
|
350
|
+
|
|
351
|
+
**Установка:** во время `memex-sync install` будет промпт `[Y/n]` — соглашайся (Y по default'у). Или установи позже:
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
memex hook install # добавить хук
|
|
355
|
+
memex hook uninstall # удалить (только memex-запись, другие хуки сохраняются)
|
|
356
|
+
memex hook status # узнать текущее состояние
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Посмотреть что будет инжектиться** (dry-run в текущей директории):
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
memex context # markdown как для хука
|
|
363
|
+
memex context --json # структурированный
|
|
364
|
+
memex context --no-source telegram # исключить telegram (privacy)
|
|
365
|
+
memex context --freshness-days 30 # только последние 30 дней
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
**Privacy:** хук ничего не отправляет наружу — это локальная инъекция в локальную Claude-сессию. Но в context могут попасть фрагменты из любых indexed sources (включая Telegram). Чтобы исключить — добавь `--no-source telegram` в команду хука (правится в `~/.claude/settings.json`).
|
|
369
|
+
|
|
370
|
+
**Где это пока не работает:** Cursor, Cline, Continue, Zed — у них нет native SessionStart hook'а. Fallback через MCP-tool — в roadmap v0.9.0. Сейчас auto-context работает только в **Claude Code и OpenClaw**.
|
|
371
|
+
|
|
372
|
+
---
|
|
373
|
+
|
|
341
374
|
## Если что-то не работает
|
|
342
375
|
|
|
343
376
|
### Поиск пустой
|
package/README.md
CHANGED
|
@@ -107,10 +107,12 @@ The same `memex` binary that runs as an MCP server also has a terminal mode for
|
|
|
107
107
|
```sh
|
|
108
108
|
memex search "Postgres migration" # full-text search
|
|
109
109
|
memex search "Q2 deck" --chat "Memex Bot" # scope to one conversation by title
|
|
110
|
+
memex search "JWT" --as-of 2026-05-01 # v0.8.1: time-travel — only msgs before date
|
|
111
|
+
memex when "Brian Chesky" # v0.8.1: "when did we talk about X" — dates + chats
|
|
110
112
|
memex recent --limit 5 # last 5 messages across all sources
|
|
111
113
|
memex list --source web # all saved URLs
|
|
112
114
|
memex get web-1582ab51a7b7 # full content of one conversation
|
|
113
|
-
memex overview # snapshot of corpus
|
|
115
|
+
memex overview # snapshot of corpus + v0.8.1: capture streak
|
|
114
116
|
memex projects # distinct project_paths captured
|
|
115
117
|
memex help # full user guide (HELP.md)
|
|
116
118
|
memex --help # command reference
|
|
@@ -122,6 +124,28 @@ When called **without arguments** (`memex`), the binary still runs as an MCP std
|
|
|
122
124
|
|
|
123
125
|
---
|
|
124
126
|
|
|
127
|
+
## Auto-context (v0.8+) — Claude already knows what you were doing
|
|
128
|
+
|
|
129
|
+
After `memex-sync install`, you're prompted to enable **auto-context**. When yes, memex adds a SessionStart hook to `~/.claude/settings.json` so that **every time you open Claude Code in a project**, Claude gets injected with ~500-1500 tokens of relevant context — what you did recently in this project, which conversations touched it, which related topics came up. No prompts. No tool calls. Just memory.
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
# Adding/removing the hook outside the install flow:
|
|
133
|
+
memex hook install # add SessionStart hook (idempotent)
|
|
134
|
+
memex hook uninstall # remove only the memex entry, preserves other hooks
|
|
135
|
+
memex hook status # show current state
|
|
136
|
+
|
|
137
|
+
# Inspecting what gets injected:
|
|
138
|
+
memex context # dry-run the hook output for the current dir
|
|
139
|
+
memex context --pwd /path # for a different project
|
|
140
|
+
memex context --no-source telegram # exclude a source
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The hook respects existing hooks (e.g. `gstack`, custom user hooks) — they're preserved untouched.
|
|
144
|
+
|
|
145
|
+
**Currently only Claude Code has native SessionStart hooks.** For Cursor / Cline / Continue / Zed, MCP-tool-based fallback is on the v0.9.0 roadmap.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
125
149
|
## Save URLs into memex (v0.6+)
|
|
126
150
|
|
|
127
151
|
Once memex is installed, any MCP-aware agent can also save **web pages, AI chat shares, and pasted text** into your memex memory — searchable from any other AI chat later. In Claude Code, Cursor, Cline, …:
|
package/README.ru.md
CHANGED
|
@@ -143,10 +143,12 @@ curl -fsSL https://raw.githubusercontent.com/parallelclaw/memex-mvp/main/skills/
|
|
|
143
143
|
```bash
|
|
144
144
|
memex search "Postgres миграция" # полнотекстовый поиск
|
|
145
145
|
memex search "Q2 deck" --chat "Memex Bot" # сузить до конкретного чата по title
|
|
146
|
+
memex search "JWT" --as-of 2026-05-01 # v0.8.1: time-travel — только до даты
|
|
147
|
+
memex when "Brian Chesky" # v0.8.1: «когда мы это обсуждали» — даты + чаты
|
|
146
148
|
memex recent --limit 5 # последние 5 сообщений из всех источников
|
|
147
149
|
memex list --source web # все сохранённые URL'ы
|
|
148
150
|
memex get web-1582ab51a7b7 # полный контент одной conversation
|
|
149
|
-
memex overview # snapshot корпуса
|
|
151
|
+
memex overview # snapshot корпуса + v0.8.1: capture streak
|
|
150
152
|
memex projects # уникальные project_paths
|
|
151
153
|
memex help # полное руководство (HELP.md)
|
|
152
154
|
memex --help # справка по командам
|
|
@@ -162,6 +164,26 @@ memex --help # справка по команд
|
|
|
162
164
|
- Хочешь пайпить результаты: `memex search foo --json | jq ...`
|
|
163
165
|
- Хочешь сдампить полный transcript в stdout для context'a
|
|
164
166
|
|
|
167
|
+
### Auto-context (v0.8+) — Claude уже знает что ты делал
|
|
168
|
+
|
|
169
|
+
После `memex-sync install` появляется промпт про **auto-context**. Если согласишься — memex добавит SessionStart хук в `~/.claude/settings.json`. Когда ты потом открываешь Claude Code в каком-то проекте, Claude **сам подгружает 500-1500 токенов контекста** про этот проект — что ты делал недавно, какие conversations его касались, какие связанные темы всплывали. Никаких вопросов, никаких tool-call'ов, просто Claude **знает**.
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# Добавить/удалить хук вне install-flow:
|
|
173
|
+
memex hook install # добавить SessionStart хук (idempotent)
|
|
174
|
+
memex hook uninstall # удалить только memex-запись, остальные хуки не трогает
|
|
175
|
+
memex hook status # показать текущее состояние
|
|
176
|
+
|
|
177
|
+
# Посмотреть что будет инжектиться:
|
|
178
|
+
memex context # dry-run для текущей директории
|
|
179
|
+
memex context --pwd /path # для другого проекта
|
|
180
|
+
memex context --no-source telegram # исключить источник
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Хук **сохраняет существующие хуки** (gstack, твои кастомные) — добавляет только свою запись.
|
|
184
|
+
|
|
185
|
+
**Сейчас native SessionStart есть только в Claude Code.** Для Cursor / Cline / Continue / Zed fallback через MCP-tool — в roadmap v0.9.0.
|
|
186
|
+
|
|
165
187
|
### Подключение к Claude Code
|
|
166
188
|
|
|
167
189
|
Сначала возьми **два абсолютных пути** в терминале:
|
package/ingest.js
CHANGED
|
@@ -61,6 +61,8 @@ import {
|
|
|
61
61
|
vaultSlug,
|
|
62
62
|
shouldSkipPath,
|
|
63
63
|
} from './lib/parse-obsidian.js';
|
|
64
|
+
import { installHook as installSessionStartHook } from './lib/hook/install.js';
|
|
65
|
+
import { createInterface } from 'node:readline';
|
|
64
66
|
import {
|
|
65
67
|
CONFIG_PATH,
|
|
66
68
|
KNOWN_SOURCES,
|
|
@@ -137,7 +139,12 @@ if (subcommand && subcommand !== '--help' && subcommand.startsWith('-') === fals
|
|
|
137
139
|
console.error(`usage: memex-sync [install|uninstall|status|logs|serve]`);
|
|
138
140
|
process.exit(2);
|
|
139
141
|
}
|
|
140
|
-
|
|
142
|
+
// Handlers may be sync (most) or async (cmdInstall after v0.8 — needs readline
|
|
143
|
+
// for the auto-context prompt). Promise.resolve() normalises both.
|
|
144
|
+
Promise.resolve(handler()).catch((e) => {
|
|
145
|
+
console.error(`error in ${subcommand}: ${e.stack || e.message}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
});
|
|
141
148
|
// CLI handlers either exit themselves or fall through to daemon mode (cmdServe)
|
|
142
149
|
} else if (subcommand === '--help' || subcommand === '-h') {
|
|
143
150
|
console.log(`memex-sync — auto-capture daemon for memex memory
|
|
@@ -185,7 +192,7 @@ paths:
|
|
|
185
192
|
|
|
186
193
|
// -------------------- CLI command handlers --------------------
|
|
187
194
|
|
|
188
|
-
function cmdInstall() {
|
|
195
|
+
async function cmdInstall() {
|
|
189
196
|
if (platform() !== 'darwin') {
|
|
190
197
|
console.error('install: macOS-only for now (LaunchAgent). Linux systemd-user support pending.');
|
|
191
198
|
console.error('on Linux you can run: nohup memex-sync &');
|
|
@@ -243,6 +250,18 @@ function cmdInstall() {
|
|
|
243
250
|
console.log(` log: ${LOG_PATH}`);
|
|
244
251
|
console.log('');
|
|
245
252
|
|
|
253
|
+
// ── Auto-context prompt (v0.8+) ─────────────────────────────────────
|
|
254
|
+
// Bundle Claude Code SessionStart hook install into the same flow
|
|
255
|
+
// the user is already running. Single [Y/n] beats a separate command
|
|
256
|
+
// they'd never remember.
|
|
257
|
+
//
|
|
258
|
+
// Honor non-interactive flags / env for CI:
|
|
259
|
+
// --auto-context yes explicit opt-in
|
|
260
|
+
// --auto-context no explicit opt-out
|
|
261
|
+
// --yes / -y accept all defaults (yes)
|
|
262
|
+
// $MEMEX_AUTO_CONTEXT=yes|no env override
|
|
263
|
+
await maybeInstallAutoContextHook();
|
|
264
|
+
|
|
246
265
|
// Show what daemon will actually capture, based on current config.
|
|
247
266
|
const cfg = loadConfig();
|
|
248
267
|
console.log('memex-sync will capture from these sources:');
|
|
@@ -277,6 +296,97 @@ function cmdInstall() {
|
|
|
277
296
|
process.exit(0);
|
|
278
297
|
}
|
|
279
298
|
|
|
299
|
+
// ──────────────────────────────────────────────────────────────────
|
|
300
|
+
// Auto-context hook prompt — bundled into `memex-sync install` so the
|
|
301
|
+
// user doesn't need to remember an extra `memex hook install` step.
|
|
302
|
+
//
|
|
303
|
+
// Decision priority:
|
|
304
|
+
// 1. CLI flag --auto-context=yes|no (explicit)
|
|
305
|
+
// 2. CLI flag --yes / -y (accept all defaults: yes)
|
|
306
|
+
// 3. env MEMEX_AUTO_CONTEXT=yes|no (for CI / scripts)
|
|
307
|
+
// 4. Interactive [Y/n] prompt (TTY only)
|
|
308
|
+
// 5. Default: SKIP if no TTY (don't hang on stdin in non-TTY contexts)
|
|
309
|
+
async function maybeInstallAutoContextHook() {
|
|
310
|
+
const argv = process.argv.slice(3); // drop ["node", "ingest.js", "install"]
|
|
311
|
+
|
|
312
|
+
// Parse flags
|
|
313
|
+
let explicit = null; // 'yes' | 'no' | null
|
|
314
|
+
for (let i = 0; i < argv.length; i++) {
|
|
315
|
+
const a = argv[i];
|
|
316
|
+
if (a === '--auto-context') {
|
|
317
|
+
const v = (argv[++i] || '').toLowerCase();
|
|
318
|
+
if (v === 'yes' || v === 'y' || v === 'true') explicit = 'yes';
|
|
319
|
+
else if (v === 'no' || v === 'n' || v === 'false') explicit = 'no';
|
|
320
|
+
} else if (a === '--auto-context=yes') explicit = 'yes';
|
|
321
|
+
else if (a === '--auto-context=no') explicit = 'no';
|
|
322
|
+
else if (a === '--yes' || a === '-y') explicit = 'yes';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Env fallback
|
|
326
|
+
if (explicit === null) {
|
|
327
|
+
const env = (process.env.MEMEX_AUTO_CONTEXT || '').toLowerCase();
|
|
328
|
+
if (env === 'yes' || env === 'y' || env === 'true' || env === '1') explicit = 'yes';
|
|
329
|
+
else if (env === 'no' || env === 'n' || env === 'false' || env === '0') explicit = 'no';
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Interactive prompt as last resort
|
|
333
|
+
if (explicit === null) {
|
|
334
|
+
if (!process.stdin.isTTY) {
|
|
335
|
+
// Non-interactive (CI, scripts, install-skill flows that don't pipe stdin):
|
|
336
|
+
// skip silently. User can run `memex hook install` later.
|
|
337
|
+
console.log('Auto-context hook: skipped (non-interactive). Enable with: memex hook install');
|
|
338
|
+
console.log('');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
explicit = await promptYesNo(
|
|
342
|
+
`Auto-context (Brian Chesky mode):\n` +
|
|
343
|
+
` When you open Claude Code in a project, memex can inject 500-1500 tokens\n` +
|
|
344
|
+
` of relevant context so Claude knows what you were doing — without you\n` +
|
|
345
|
+
` having to ask. Adds a SessionStart hook to ~/.claude/settings.json.\n` +
|
|
346
|
+
` Other hooks (e.g. gstack) are preserved.\n\n` +
|
|
347
|
+
` Enable?`,
|
|
348
|
+
'yes' // default Y
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (explicit !== 'yes') {
|
|
353
|
+
console.log('Auto-context hook: skipped. Enable later with: memex hook install');
|
|
354
|
+
console.log('');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const r = installSessionStartHook();
|
|
359
|
+
if (r.error) {
|
|
360
|
+
console.log(`Auto-context hook: ✗ ${r.error}`);
|
|
361
|
+
console.log(' (memex-sync daemon still works — only the auto-context hook failed)');
|
|
362
|
+
console.log('');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (r.alreadyPresent) {
|
|
366
|
+
console.log('Auto-context hook: already installed (no-op).');
|
|
367
|
+
} else {
|
|
368
|
+
console.log('Auto-context hook: ✓ installed.');
|
|
369
|
+
console.log(` settings: ${r.settingsPath}`);
|
|
370
|
+
console.log(` command: ${r.command}`);
|
|
371
|
+
console.log(' Restart Claude Code (Cmd+Q + reopen) to activate.');
|
|
372
|
+
}
|
|
373
|
+
console.log('');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function promptYesNo(question, defaultAnswer) {
|
|
377
|
+
return new Promise((resolve) => {
|
|
378
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
379
|
+
const suffix = defaultAnswer === 'yes' ? ' [Y/n] ' : ' [y/N] ';
|
|
380
|
+
rl.question(question + suffix, (answer) => {
|
|
381
|
+
rl.close();
|
|
382
|
+
const v = (answer || '').trim().toLowerCase();
|
|
383
|
+
if (v === 'y' || v === 'yes') resolve('yes');
|
|
384
|
+
else if (v === 'n' || v === 'no') resolve('no');
|
|
385
|
+
else resolve(defaultAnswer); // empty enter → default
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
280
390
|
function cmdUninstall() {
|
|
281
391
|
if (platform() !== 'darwin') {
|
|
282
392
|
console.error('uninstall: macOS-only for now.');
|
package/lib/cli/index.js
CHANGED
|
@@ -24,15 +24,22 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import Database from 'better-sqlite3';
|
|
27
|
-
import { join } from 'node:path';
|
|
27
|
+
import { join, basename } from 'node:path';
|
|
28
28
|
import { homedir } from 'node:os';
|
|
29
29
|
import { existsSync, readFileSync } from 'node:fs';
|
|
30
30
|
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import {
|
|
32
|
+
installHook,
|
|
33
|
+
uninstallHook,
|
|
34
|
+
getHookStatus,
|
|
35
|
+
resolveMemexBinPath,
|
|
36
|
+
} from '../hook/install.js';
|
|
31
37
|
|
|
32
38
|
// ---------- Subcommand registry ----------
|
|
33
39
|
export const CLI_SUBCOMMAND_NAMES = [
|
|
34
40
|
'search', 'recent', 'list', 'get', 'overview',
|
|
35
|
-
'projects', '
|
|
41
|
+
'projects', 'context', 'hook', 'when',
|
|
42
|
+
'help', '-h', '--help', '-v', '--version',
|
|
36
43
|
];
|
|
37
44
|
|
|
38
45
|
// ---------- Path helpers ----------
|
|
@@ -71,6 +78,15 @@ function parseArgs(argv) {
|
|
|
71
78
|
else if (a === '--project') opts.project = argv[++i];
|
|
72
79
|
else if (a === '--sort') opts.sort = argv[++i];
|
|
73
80
|
else if (a === '--include-archived') opts.includeArchived = true;
|
|
81
|
+
else if (a === '--pwd') opts.pwd = argv[++i];
|
|
82
|
+
else if (a === '--budget' || a === '--budget-tokens') opts.budget = parseInt(argv[++i], 10);
|
|
83
|
+
else if (a === '--freshness-days') opts.freshnessDays = parseInt(argv[++i], 10);
|
|
84
|
+
else if (a === '--no-source') {
|
|
85
|
+
// Allow repeated --no-source telegram --no-source obsidian
|
|
86
|
+
if (!Array.isArray(opts.noSource)) opts.noSource = [];
|
|
87
|
+
opts.noSource.push(argv[++i]);
|
|
88
|
+
}
|
|
89
|
+
else if (a === '--as-of') opts.asOf = argv[++i];
|
|
74
90
|
else if (a === '--help' || a === '-h') opts.help = true;
|
|
75
91
|
else if (a.startsWith('--')) { /* ignore unknown flag for forward-compat */ }
|
|
76
92
|
else positionals.push(a);
|
|
@@ -98,6 +114,20 @@ function fmtDateTime(ts) {
|
|
|
98
114
|
return new Date(ts * 1000).toISOString().slice(0, 16).replace('T', ' ');
|
|
99
115
|
}
|
|
100
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Parse YYYY-MM-DD into unix timestamp at start-of-day (00:00 UTC).
|
|
119
|
+
* Returns null on invalid input.
|
|
120
|
+
*/
|
|
121
|
+
function parseAsOf(s) {
|
|
122
|
+
if (typeof s !== 'string') return null;
|
|
123
|
+
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
124
|
+
if (!m) return null;
|
|
125
|
+
const [, y, mo, d] = m;
|
|
126
|
+
const date = new Date(`${y}-${mo}-${d}T00:00:00Z`);
|
|
127
|
+
if (isNaN(date.getTime())) return null;
|
|
128
|
+
return Math.floor(date.getTime() / 1000);
|
|
129
|
+
}
|
|
130
|
+
|
|
101
131
|
// FTS5 expects sanitized tokens — strip what would be operators
|
|
102
132
|
function sanitizeFtsQuery(q) {
|
|
103
133
|
return String(q || '')
|
|
@@ -142,6 +172,18 @@ async function cmdSearch(args) {
|
|
|
142
172
|
filters.push('LOWER(c.title) LIKE LOWER(?)');
|
|
143
173
|
params.push(`%${opts.chat}%`);
|
|
144
174
|
}
|
|
175
|
+
// Time-travel: --as-of YYYY-MM-DD returns only messages with ts strictly
|
|
176
|
+
// before that calendar date (start-of-day). Useful for retrospectives:
|
|
177
|
+
// "what did I know about X two weeks ago?"
|
|
178
|
+
if (opts.asOf) {
|
|
179
|
+
const cutoff = parseAsOf(opts.asOf);
|
|
180
|
+
if (cutoff === null) {
|
|
181
|
+
console.error(`Invalid --as-of date: "${opts.asOf}". Expected YYYY-MM-DD.`);
|
|
182
|
+
process.exit(2);
|
|
183
|
+
}
|
|
184
|
+
filters.push('m.ts > 0 AND m.ts < ?');
|
|
185
|
+
params.push(cutoff);
|
|
186
|
+
}
|
|
145
187
|
|
|
146
188
|
let orderBy;
|
|
147
189
|
if (opts.sort === 'date_asc') {
|
|
@@ -345,6 +387,10 @@ async function cmdOverview(args) {
|
|
|
345
387
|
ORDER BY last_ts DESC
|
|
346
388
|
LIMIT 10
|
|
347
389
|
`).all();
|
|
390
|
+
|
|
391
|
+
// Streak + today's capture count (D6 — GitHub-style daily habit signal)
|
|
392
|
+
const streak = computeStreak(db);
|
|
393
|
+
|
|
348
394
|
db.close();
|
|
349
395
|
|
|
350
396
|
if (opts.json) {
|
|
@@ -353,11 +399,25 @@ async function cmdOverview(args) {
|
|
|
353
399
|
total_conversations: totalConvs,
|
|
354
400
|
sources,
|
|
355
401
|
recent_conversations: recentConvs,
|
|
402
|
+
streak: streak,
|
|
356
403
|
}, null, 2));
|
|
357
404
|
return;
|
|
358
405
|
}
|
|
359
406
|
console.log(c.bold('memex corpus snapshot') + '\n');
|
|
360
407
|
console.log(`Total: ${c.green(totalMsgs + ' messages')} in ${c.green(totalConvs + ' conversations')}\n`);
|
|
408
|
+
|
|
409
|
+
// Streak block — only show if there's at least one captured day
|
|
410
|
+
if (streak.streakDays > 0) {
|
|
411
|
+
const today = streak.todayMessages;
|
|
412
|
+
const todayLine = today > 0
|
|
413
|
+
? `Today: ${c.green(today + ' messages')} across ${streak.todayConversations} conversation(s).`
|
|
414
|
+
: `${c.dim('No captures yet today.')}`;
|
|
415
|
+
const streakLine = streak.streakDays >= 2
|
|
416
|
+
? `${c.green('✓ ' + streak.streakDays + '-day capture streak')} (since ${fmtDate(streak.streakStartTs)}). ${todayLine}`
|
|
417
|
+
: `${c.dim('Starting fresh — capture something today to begin a streak.')} ${todayLine}`;
|
|
418
|
+
console.log(streakLine + '\n');
|
|
419
|
+
}
|
|
420
|
+
|
|
361
421
|
console.log(c.bold('By source:'));
|
|
362
422
|
for (const s of sources) {
|
|
363
423
|
console.log(` ${s.source.padEnd(18)} ${String(s.msgs).padStart(7)} msgs · ${String(s.chats).padStart(5)} chats · ${fmtDate(s.first_ts)} → ${fmtDate(s.last_ts)}`);
|
|
@@ -369,6 +429,66 @@ async function cmdOverview(args) {
|
|
|
369
429
|
}
|
|
370
430
|
}
|
|
371
431
|
|
|
432
|
+
/**
|
|
433
|
+
* Compute current capture streak: consecutive days (working backward from
|
|
434
|
+
* today) with at least one message captured.
|
|
435
|
+
*
|
|
436
|
+
* Returns:
|
|
437
|
+
* {
|
|
438
|
+
* streakDays: number, // 0 if today has 0 captures
|
|
439
|
+
* streakStartTs: number, // ts of the earliest day in the streak
|
|
440
|
+
* todayMessages: number, // count of messages captured today
|
|
441
|
+
* todayConversations: number,
|
|
442
|
+
* }
|
|
443
|
+
*
|
|
444
|
+
* "Day" boundaries are UTC (matches how we store ts). A more user-friendly
|
|
445
|
+
* version would use local-day, but UTC is consistent and predictable —
|
|
446
|
+
* good enough for v0.8.1.
|
|
447
|
+
*/
|
|
448
|
+
function computeStreak(db) {
|
|
449
|
+
const now = Math.floor(Date.now() / 1000);
|
|
450
|
+
const todayStart = Math.floor(now / 86400) * 86400; // UTC midnight today
|
|
451
|
+
|
|
452
|
+
// Distinct days with captures, sorted desc — pull up to ~365 days to bound work
|
|
453
|
+
const days = db.prepare(`
|
|
454
|
+
SELECT DISTINCT (ts / 86400) AS day
|
|
455
|
+
FROM messages
|
|
456
|
+
WHERE ts >= ?
|
|
457
|
+
ORDER BY day DESC
|
|
458
|
+
`).all(todayStart - 365 * 86400);
|
|
459
|
+
|
|
460
|
+
let streakDays = 0;
|
|
461
|
+
let streakStartTs = 0;
|
|
462
|
+
if (days.length > 0) {
|
|
463
|
+
const todayDay = Math.floor(todayStart / 86400);
|
|
464
|
+
let cursor = todayDay;
|
|
465
|
+
for (const row of days) {
|
|
466
|
+
if (row.day === cursor) {
|
|
467
|
+
streakDays += 1;
|
|
468
|
+
streakStartTs = row.day * 86400;
|
|
469
|
+
cursor -= 1;
|
|
470
|
+
} else if (row.day < cursor) {
|
|
471
|
+
// Streak broken — stop
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
// row.day > cursor shouldn't happen with DESC order; if it does, skip
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const todayRow = db.prepare(`
|
|
479
|
+
SELECT COUNT(*) AS msgs, COUNT(DISTINCT conversation_id) AS convs
|
|
480
|
+
FROM messages
|
|
481
|
+
WHERE ts >= ?
|
|
482
|
+
`).get(todayStart);
|
|
483
|
+
|
|
484
|
+
return {
|
|
485
|
+
streakDays,
|
|
486
|
+
streakStartTs,
|
|
487
|
+
todayMessages: todayRow.msgs,
|
|
488
|
+
todayConversations: todayRow.convs,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
372
492
|
// =============================================================
|
|
373
493
|
// PROJECTS
|
|
374
494
|
// =============================================================
|
|
@@ -400,6 +520,348 @@ async function cmdProjects(args) {
|
|
|
400
520
|
}
|
|
401
521
|
}
|
|
402
522
|
|
|
523
|
+
// =============================================================
|
|
524
|
+
// WHEN — chronological "when did we talk about X" CLI shortcut
|
|
525
|
+
// =============================================================
|
|
526
|
+
//
|
|
527
|
+
// `memex when "JWT decision"` answers the single most common memex query
|
|
528
|
+
// in 1 second: dates, sources, conversation titles. No snippets, no
|
|
529
|
+
// re-ranking — just chronological recall.
|
|
530
|
+
//
|
|
531
|
+
// Returns one row per matching conversation, sorted by latest message in
|
|
532
|
+
// that conversation (date_desc). Useful when the user remembers a topic
|
|
533
|
+
// but can't recall WHICH session it came from or WHEN.
|
|
534
|
+
async function cmdWhen(args) {
|
|
535
|
+
const { opts, positionals } = parseArgs(args);
|
|
536
|
+
const query = positionals.join(' ').trim();
|
|
537
|
+
|
|
538
|
+
if (!query || opts.help) {
|
|
539
|
+
console.error('Usage: memex when "<query>" [--source X] [--limit N] [--json]');
|
|
540
|
+
console.error('');
|
|
541
|
+
console.error('Returns a chronological "when did we talk about X" list — date + source +');
|
|
542
|
+
console.error('conversation title, no snippets. Sorted newest first.');
|
|
543
|
+
process.exit(query ? 0 : 2);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const limit = Math.min(50, Math.max(1, opts.limit || 15));
|
|
547
|
+
const sanitized = sanitizeFtsQuery(query);
|
|
548
|
+
if (!sanitized) {
|
|
549
|
+
console.error('Query became empty after sanitization — try simpler keywords.');
|
|
550
|
+
process.exit(2);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const filters = ['messages_fts MATCH ?'];
|
|
554
|
+
const params = [sanitized];
|
|
555
|
+
if (opts.source) {
|
|
556
|
+
filters.push('m.source = ?');
|
|
557
|
+
params.push(opts.source);
|
|
558
|
+
}
|
|
559
|
+
if (!opts.includeArchived) {
|
|
560
|
+
filters.push('(c.archived_at IS NULL OR c.archived_at = 0)');
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Aggregate by conversation: one row per chat, latest hit's date, match count
|
|
564
|
+
const sql = `
|
|
565
|
+
SELECT m.conversation_id,
|
|
566
|
+
m.source,
|
|
567
|
+
MAX(m.ts) AS latest_ts,
|
|
568
|
+
MIN(m.ts) AS earliest_ts,
|
|
569
|
+
COUNT(*) AS match_count,
|
|
570
|
+
c.title AS conversation_title
|
|
571
|
+
FROM messages_fts
|
|
572
|
+
JOIN messages m ON m.id = messages_fts.rowid
|
|
573
|
+
LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
|
|
574
|
+
WHERE ${filters.join(' AND ')}
|
|
575
|
+
GROUP BY m.conversation_id
|
|
576
|
+
ORDER BY latest_ts DESC
|
|
577
|
+
LIMIT ?
|
|
578
|
+
`;
|
|
579
|
+
const db = openDb();
|
|
580
|
+
const rows = db.prepare(sql).all(...params, limit);
|
|
581
|
+
db.close();
|
|
582
|
+
|
|
583
|
+
if (opts.json) {
|
|
584
|
+
console.log(JSON.stringify({ query, count: rows.length, results: rows }, null, 2));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (rows.length === 0) {
|
|
588
|
+
console.log(`No mentions of ${c.bold('"' + query + '"')} found.`);
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
console.log(`${c.bold('"' + query + '"')} mentioned in ${c.bold(rows.length)} conversation(s):\n`);
|
|
592
|
+
for (const r of rows) {
|
|
593
|
+
const date = fmtDate(r.latest_ts);
|
|
594
|
+
const range = r.earliest_ts && r.earliest_ts !== r.latest_ts
|
|
595
|
+
? ` (also ${fmtDate(r.earliest_ts)})`
|
|
596
|
+
: '';
|
|
597
|
+
const count = r.match_count > 1 ? ` · ${r.match_count} matches` : '';
|
|
598
|
+
console.log(` ${c.green(date)}${c.dim(range)} ${c.dim(r.source.padEnd(14))} ${c.cyan((r.conversation_title || r.conversation_id).slice(0, 60))}${c.dim(count)}`);
|
|
599
|
+
}
|
|
600
|
+
console.log('');
|
|
601
|
+
console.log(c.dim(`To read one: memex get <conversation_id> | to search content: memex search "${query}"`));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// =============================================================
|
|
605
|
+
// CONTEXT — output relevant memex context for current pwd
|
|
606
|
+
// =============================================================
|
|
607
|
+
//
|
|
608
|
+
// Designed to be called by Claude Code SessionStart hook (or equivalent).
|
|
609
|
+
// stdout markdown becomes a system message injected into Claude's context
|
|
610
|
+
// BEFORE the user sends their first prompt. So Claude "knows" what the
|
|
611
|
+
// user has been doing in this project without being asked.
|
|
612
|
+
//
|
|
613
|
+
// Smart selection:
|
|
614
|
+
// 1. Direct project_path match — conversations where this exact path was
|
|
615
|
+
// captured (Claude Code/Cowork cwd, Obsidian vault, etc.)
|
|
616
|
+
// 2. Project-name fuzzy match — conversations whose title mentions the
|
|
617
|
+
// basename of pwd (catches discussions of the project across sources
|
|
618
|
+
// like Telegram where there's no project_path).
|
|
619
|
+
//
|
|
620
|
+
// Default budget: 1500 tokens (≈6000 chars markdown). Truncated cleanly
|
|
621
|
+
// if needed — never spill into Claude's context window unboundedly.
|
|
622
|
+
//
|
|
623
|
+
// Privacy: telegram source is included by default (users discussed feature
|
|
624
|
+
// idea here) but can be excluded via --no-source telegram. Future:
|
|
625
|
+
// per-source sensitivity flags in ~/.memex/config.json.
|
|
626
|
+
//
|
|
627
|
+
// Output is markdown. --json gives the structured underlying data.
|
|
628
|
+
async function cmdContext(args) {
|
|
629
|
+
const { opts } = parseArgs(args);
|
|
630
|
+
|
|
631
|
+
if (opts.help) {
|
|
632
|
+
console.error('Usage: memex context [--pwd PATH] [--limit N] [--budget-tokens N] [--freshness-days N] [--no-source NAME] [--json]');
|
|
633
|
+
console.error('');
|
|
634
|
+
console.error('Outputs markdown summarizing recent memex activity relevant to the current pwd.');
|
|
635
|
+
console.error('Designed for use as a Claude Code SessionStart hook.');
|
|
636
|
+
process.exit(0);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const pwd = opts.pwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
640
|
+
const limit = Math.min(20, Math.max(1, opts.limit || 5));
|
|
641
|
+
const tokenBudget = Math.min(8000, Math.max(50, opts.budget || 1500));
|
|
642
|
+
const freshnessDays = Math.min(365, Math.max(1, opts.freshnessDays || 90));
|
|
643
|
+
const excludeSources = Array.isArray(opts.noSource) ? opts.noSource : (opts.noSource ? [opts.noSource] : []);
|
|
644
|
+
|
|
645
|
+
const project = basename(pwd);
|
|
646
|
+
const sinceTs = Math.floor(Date.now() / 1000) - freshnessDays * 86400;
|
|
647
|
+
|
|
648
|
+
const db = openDb();
|
|
649
|
+
|
|
650
|
+
// 1. Direct project_path matches — highest signal.
|
|
651
|
+
const directFilters = [
|
|
652
|
+
'project_path LIKE ?',
|
|
653
|
+
'last_ts >= ?',
|
|
654
|
+
'(archived_at IS NULL OR archived_at = 0)',
|
|
655
|
+
];
|
|
656
|
+
const directParams = [`%${pwd}%`, sinceTs];
|
|
657
|
+
if (excludeSources.length) {
|
|
658
|
+
const placeholders = excludeSources.map(() => '?').join(',');
|
|
659
|
+
directFilters.push(`source NOT IN (${placeholders})`);
|
|
660
|
+
directParams.push(...excludeSources);
|
|
661
|
+
}
|
|
662
|
+
const directConvs = db.prepare(`
|
|
663
|
+
SELECT conversation_id, source, title, first_ts, last_ts, message_count
|
|
664
|
+
FROM conversations
|
|
665
|
+
WHERE ${directFilters.join(' AND ')}
|
|
666
|
+
ORDER BY last_ts DESC
|
|
667
|
+
LIMIT ?
|
|
668
|
+
`).all(...directParams, limit);
|
|
669
|
+
|
|
670
|
+
// 2. Fuzzy project-name matches in title (catches Telegram / web discussion of project).
|
|
671
|
+
// Skip duplicates we already got from direct match.
|
|
672
|
+
const seenIds = new Set(directConvs.map((c) => c.conversation_id));
|
|
673
|
+
const fuzzyFilters = [
|
|
674
|
+
'LOWER(title) LIKE LOWER(?)',
|
|
675
|
+
'last_ts >= ?',
|
|
676
|
+
'(archived_at IS NULL OR archived_at = 0)',
|
|
677
|
+
];
|
|
678
|
+
const fuzzyParams = [`%${project}%`, sinceTs];
|
|
679
|
+
if (excludeSources.length) {
|
|
680
|
+
const placeholders = excludeSources.map(() => '?').join(',');
|
|
681
|
+
fuzzyFilters.push(`source NOT IN (${placeholders})`);
|
|
682
|
+
fuzzyParams.push(...excludeSources);
|
|
683
|
+
}
|
|
684
|
+
const fuzzyConvs = db.prepare(`
|
|
685
|
+
SELECT conversation_id, source, title, first_ts, last_ts, message_count
|
|
686
|
+
FROM conversations
|
|
687
|
+
WHERE ${fuzzyFilters.join(' AND ')}
|
|
688
|
+
ORDER BY last_ts DESC
|
|
689
|
+
LIMIT ?
|
|
690
|
+
`).all(...fuzzyParams, limit * 2);
|
|
691
|
+
const filteredFuzzy = fuzzyConvs.filter((r) => !seenIds.has(r.conversation_id)).slice(0, Math.max(1, limit - directConvs.length));
|
|
692
|
+
|
|
693
|
+
db.close();
|
|
694
|
+
|
|
695
|
+
const all = [...directConvs, ...filteredFuzzy];
|
|
696
|
+
|
|
697
|
+
if (opts.json) {
|
|
698
|
+
console.log(JSON.stringify({
|
|
699
|
+
pwd, project, freshness_days: freshnessDays,
|
|
700
|
+
direct_matches: directConvs.length,
|
|
701
|
+
fuzzy_matches: filteredFuzzy.length,
|
|
702
|
+
conversations: all,
|
|
703
|
+
token_budget: tokenBudget,
|
|
704
|
+
}, null, 2));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Markdown output (TTY-color stripped — this is consumed by Claude, not the user)
|
|
709
|
+
const lines = [];
|
|
710
|
+
lines.push(`## memex auto-context for ${project}`);
|
|
711
|
+
lines.push('');
|
|
712
|
+
|
|
713
|
+
if (all.length === 0) {
|
|
714
|
+
lines.push(`_No recent activity in memex for this project (last ${freshnessDays} days)._`);
|
|
715
|
+
lines.push('');
|
|
716
|
+
lines.push(`Path searched: \`${pwd}\``);
|
|
717
|
+
lines.push('');
|
|
718
|
+
lines.push('---');
|
|
719
|
+
lines.push(`_memex auto-context · empty · v0.8+_`);
|
|
720
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (directConvs.length > 0) {
|
|
725
|
+
lines.push(`### Recent conversations in this project (last ${freshnessDays} days)`);
|
|
726
|
+
lines.push('');
|
|
727
|
+
for (const conv of directConvs) {
|
|
728
|
+
const date = fmtDate(conv.last_ts);
|
|
729
|
+
const title = (conv.title || conv.conversation_id).slice(0, 100);
|
|
730
|
+
lines.push(`- **${date}** · _${conv.source}_ · ${title} (${conv.message_count} msgs)`);
|
|
731
|
+
}
|
|
732
|
+
lines.push('');
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (filteredFuzzy.length > 0) {
|
|
736
|
+
lines.push(`### Related discussions mentioning "${project}"`);
|
|
737
|
+
lines.push('');
|
|
738
|
+
for (const conv of filteredFuzzy) {
|
|
739
|
+
const date = fmtDate(conv.last_ts);
|
|
740
|
+
const title = (conv.title || conv.conversation_id).slice(0, 100);
|
|
741
|
+
lines.push(`- **${date}** · _${conv.source}_ · ${title}`);
|
|
742
|
+
}
|
|
743
|
+
lines.push('');
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
lines.push('---');
|
|
747
|
+
lines.push(`_memex auto-context · ${all.length} sources · v0.8+_`);
|
|
748
|
+
lines.push(`_To search deeper, ask memex (via MCP tool or terminal: \`memex search "..."\`)._`);
|
|
749
|
+
|
|
750
|
+
let out = lines.join('\n') + '\n';
|
|
751
|
+
|
|
752
|
+
// Token budget enforcement — rough estimate ≈ 4 chars/token. Truncate
|
|
753
|
+
// cleanly at a line boundary to keep markdown valid.
|
|
754
|
+
const maxChars = tokenBudget * 4;
|
|
755
|
+
if (out.length > maxChars) {
|
|
756
|
+
out = out.slice(0, maxChars);
|
|
757
|
+
const lastNewline = out.lastIndexOf('\n');
|
|
758
|
+
if (lastNewline > 0) out = out.slice(0, lastNewline);
|
|
759
|
+
out += '\n\n_[context truncated — exceeds token budget]_\n';
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
process.stdout.write(out);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// =============================================================
|
|
766
|
+
// HOOK — install/uninstall/status for Claude Code SessionStart
|
|
767
|
+
// =============================================================
|
|
768
|
+
async function cmdHook(args) {
|
|
769
|
+
const sub = args[0];
|
|
770
|
+
const rest = args.slice(1);
|
|
771
|
+
const { opts } = parseArgs(rest);
|
|
772
|
+
|
|
773
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
774
|
+
console.error('Usage: memex hook <install|uninstall|status>');
|
|
775
|
+
console.error('');
|
|
776
|
+
console.error(' install Add memex SessionStart hook to ~/.claude/settings.json');
|
|
777
|
+
console.error(' Idempotent — re-runs are no-ops.');
|
|
778
|
+
console.error(' Claude Code will inject memex context on every new session.');
|
|
779
|
+
console.error('');
|
|
780
|
+
console.error(' uninstall Remove the memex hook entry. Preserves all other hooks.');
|
|
781
|
+
console.error('');
|
|
782
|
+
console.error(' status Show whether the hook is currently installed.');
|
|
783
|
+
process.exit(sub ? 0 : 2);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (sub === 'install') {
|
|
787
|
+
const r = installHook();
|
|
788
|
+
if (opts.json) {
|
|
789
|
+
console.log(JSON.stringify(r, null, 2));
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
if (r.error) {
|
|
793
|
+
console.error(`✗ ${r.error}`);
|
|
794
|
+
process.exit(1);
|
|
795
|
+
}
|
|
796
|
+
if (r.alreadyPresent) {
|
|
797
|
+
console.log(`✓ memex hook already installed in ${r.settingsPath}`);
|
|
798
|
+
console.log(` command: ${r.command}`);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
console.log(`✓ memex SessionStart hook installed`);
|
|
802
|
+
console.log(` settings: ${r.settingsPath}`);
|
|
803
|
+
console.log(` command: ${r.command}`);
|
|
804
|
+
console.log('');
|
|
805
|
+
console.log('Restart Claude Code (Cmd+Q + reopen) for the hook to activate.');
|
|
806
|
+
console.log('After restart, Claude will see memex context on every new session.');
|
|
807
|
+
console.log('');
|
|
808
|
+
console.log('Disable later: memex hook uninstall');
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (sub === 'uninstall') {
|
|
813
|
+
const r = uninstallHook();
|
|
814
|
+
if (opts.json) {
|
|
815
|
+
console.log(JSON.stringify(r, null, 2));
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (r.error) {
|
|
819
|
+
console.error(`✗ ${r.error}`);
|
|
820
|
+
process.exit(1);
|
|
821
|
+
}
|
|
822
|
+
if (!r.wasPresent) {
|
|
823
|
+
console.log('memex hook was not installed (nothing to remove).');
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
console.log('✓ memex SessionStart hook removed');
|
|
827
|
+
console.log(' Other hooks in ~/.claude/settings.json preserved.');
|
|
828
|
+
console.log('');
|
|
829
|
+
console.log('Restart Claude Code (Cmd+Q + reopen) for the change to take effect.');
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (sub === 'status') {
|
|
834
|
+
const r = getHookStatus();
|
|
835
|
+
if (opts.json) {
|
|
836
|
+
console.log(JSON.stringify(r, null, 2));
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
console.log(`settings file: ${r.settingsPath}`);
|
|
840
|
+
if (!r.settingsExists) {
|
|
841
|
+
console.log(' status: file does not exist');
|
|
842
|
+
console.log(' hook: NOT installed');
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (!r.settingsValid) {
|
|
846
|
+
console.log(' status: file exists but is not valid JSON');
|
|
847
|
+
console.log(' hook: could not determine (fix settings file first)');
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
console.log(` hook: ${r.installed ? 'INSTALLED' : 'NOT installed'}`);
|
|
851
|
+
if (r.installed) console.log(` command: ${r.command}`);
|
|
852
|
+
console.log(` other SessionStart hooks: ${r.otherSessionStartHooks}`);
|
|
853
|
+
if (!r.installed) {
|
|
854
|
+
console.log('');
|
|
855
|
+
console.log('Install with: memex hook install');
|
|
856
|
+
}
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
console.error(`Unknown hook subcommand: ${sub}`);
|
|
861
|
+
console.error('Run "memex hook --help" for usage.');
|
|
862
|
+
process.exit(2);
|
|
863
|
+
}
|
|
864
|
+
|
|
403
865
|
// =============================================================
|
|
404
866
|
// HELP — print HELP.md content
|
|
405
867
|
// =============================================================
|
|
@@ -430,9 +892,16 @@ COMMANDS
|
|
|
430
892
|
--chat "<title>" filter by conversation title (substring)
|
|
431
893
|
--project <path> filter by project_path (substring)
|
|
432
894
|
--sort <mode> relevance | date_asc | date_desc
|
|
895
|
+
--as-of YYYY-MM-DD time-travel: only messages before this date
|
|
433
896
|
--limit N max results (default 10, max 50)
|
|
434
897
|
--json output JSON instead of markdown
|
|
435
898
|
|
|
899
|
+
when "<query>" chronological "when did we talk about X" —
|
|
900
|
+
one row per conversation, date + title, no snippets
|
|
901
|
+
--source <name> filter by source
|
|
902
|
+
--limit N default 15, max 50
|
|
903
|
+
--json
|
|
904
|
+
|
|
436
905
|
recent most recent messages across all sources
|
|
437
906
|
--limit N default 20, max 100
|
|
438
907
|
--source <name> filter by source
|
|
@@ -454,6 +923,21 @@ COMMANDS
|
|
|
454
923
|
--limit N default 50, max 500
|
|
455
924
|
--json
|
|
456
925
|
|
|
926
|
+
context output markdown summary of recent activity
|
|
927
|
+
in current pwd (for Claude Code SessionStart
|
|
928
|
+
hook — auto-injects context into new sessions)
|
|
929
|
+
--pwd PATH override (default: $CLAUDE_PROJECT_DIR or cwd)
|
|
930
|
+
--limit N max conversations to include (default 5)
|
|
931
|
+
--budget-tokens N cap output size (default 1500)
|
|
932
|
+
--freshness-days N only conversations newer than (default 90)
|
|
933
|
+
--no-source NAME exclude a source (repeatable; e.g. telegram)
|
|
934
|
+
--json
|
|
935
|
+
|
|
936
|
+
hook install install SessionStart hook in
|
|
937
|
+
~/.claude/settings.json (idempotent)
|
|
938
|
+
hook uninstall remove only the memex hook entry
|
|
939
|
+
hook status show whether the hook is installed
|
|
940
|
+
|
|
457
941
|
help print the user guide (HELP.md)
|
|
458
942
|
--help, -h this command reference
|
|
459
943
|
--version, -v print package version
|
|
@@ -500,6 +984,9 @@ export async function runCli(sub, args) {
|
|
|
500
984
|
case 'get': return cmdGet(args);
|
|
501
985
|
case 'overview': return cmdOverview(args);
|
|
502
986
|
case 'projects': return cmdProjects(args);
|
|
987
|
+
case 'when': return cmdWhen(args);
|
|
988
|
+
case 'context': return cmdContext(args);
|
|
989
|
+
case 'hook': return cmdHook(args);
|
|
503
990
|
case 'help': return cmdHelp();
|
|
504
991
|
case '--help':
|
|
505
992
|
case '-h': return cmdUsage();
|
|
@@ -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/Cline/Continue/Zed, 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 };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memex-mvp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Local-first MCP server for cross-agent AI memory. One SQLite + FTS5 corpus across Claude Code, Cowork, Cursor, Continue, Zed, Obsidian, and Telegram — passively captured, verbatim, searchable from any MCP-compatible client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"sync": "node ingest.js",
|
|
27
27
|
"ingest": "node ingest.js",
|
|
28
28
|
"bot": "node bot/index.js",
|
|
29
|
-
"test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.test.js && node test/store-document.test.js && node test/cli.test.js",
|
|
29
|
+
"test": "node test/parser.test.js && node test/bot-inbox.test.js && node test/search-sort.test.js && node test/store-document.test.js && node test/cli.test.js && node test/hook.test.js",
|
|
30
30
|
"prepublishOnly": "npm test"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
|
@@ -122,16 +122,35 @@ If the config file doesn't exist, create the parent directory and write a minima
|
|
|
122
122
|
|
|
123
123
|
**Verify:** re-read the file after save; confirm `memex` entry is present and `command` is an absolute path.
|
|
124
124
|
|
|
125
|
-
### 3. Turn on live auto-capture
|
|
125
|
+
### 3. Turn on live auto-capture + auto-context (v0.8+)
|
|
126
126
|
|
|
127
127
|
```sh
|
|
128
128
|
memex-sync install
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
During `memex-sync install`, you'll see a prompt:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
Auto-context (Brian Chesky mode):
|
|
135
|
+
When you open Claude Code in a project, memex can inject 500-1500 tokens
|
|
136
|
+
of relevant context so Claude knows what you were doing — without you
|
|
137
|
+
having to ask. Adds a SessionStart hook to ~/.claude/settings.json.
|
|
138
|
+
Other hooks (e.g. gstack) are preserved.
|
|
139
|
+
|
|
140
|
+
Enable? [Y/n]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Answer Y unless the user has stated privacy concerns** about Claude seeing context from their other AI conversations. Auto-context is the "10/10 magic moment" of memex — without it the install ships at base capability. Other hooks (gstack, custom) are preserved untouched.
|
|
144
|
+
|
|
145
|
+
For non-interactive flows (CI / scripts): pass `--auto-context yes` or `--yes`.
|
|
146
|
+
|
|
147
|
+
```sh
|
|
129
148
|
memex-sync status
|
|
130
149
|
```
|
|
131
150
|
|
|
132
151
|
`status` should print "daemon installed", "running (PID …)", "watching N sessions".
|
|
133
152
|
|
|
134
|
-
**Verify:** status output shows a non-zero PID.
|
|
153
|
+
**Verify:** status output shows a non-zero PID. Also run `memex hook status` — should show `INSTALLED` if auto-context was accepted.
|
|
135
154
|
|
|
136
155
|
### 4. Backfill existing history
|
|
137
156
|
|
|
@@ -161,6 +180,7 @@ After restart, suggest they try any of:
|
|
|
161
180
|
- "what projects has memex captured" → triggers `memex_list_projects`
|
|
162
181
|
- "search memex for [recent topic]" → triggers `memex_search`
|
|
163
182
|
- "save https://en.wikipedia.org/wiki/As_We_May_Think to memex" → triggers `memex_store_document` and teaches the user that URL-saving exists (v0.6+)
|
|
183
|
+
- **Open Claude Code in any project the user worked on recently** — the SessionStart auto-context (v0.8+) should kick in and Claude will mention prior work _before_ the user types anything. This is the "Brian Chesky moment" — the magical-first-impression of memex.
|
|
164
184
|
|
|
165
185
|
These confirm everything works end-to-end.
|
|
166
186
|
|