nlm-memory 0.5.1 → 0.5.3

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/README.md CHANGED
@@ -1,27 +1,39 @@
1
- # nlm-memory
1
+ <p align="center">
2
+ <strong>nlm-memory</strong><br/>
3
+ Local-first non-linear memory OS for AI operators
4
+ </p>
5
+
6
+ <p align="center">
7
+ <a href="https://www.npmjs.com/package/nlm-memory"><img src="https://img.shields.io/npm/v/nlm-memory?color=CB3837&label=npm&logo=npm" alt="npm version" /></a>
8
+ <a href="https://github.com/pbmagnet4/nlm-memory-ts/blob/main/LICENSE"><img src="https://img.shields.io/github/license/pbmagnet4/nlm-memory-ts?color=blue" alt="License: Apache 2.0" /></a>
9
+ <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/nlm-memory?color=brightgreen" alt="Node 20+" /></a>
10
+ <img src="https://img.shields.io/badge/tests-612%20passing-success" alt="612 tests passing" />
11
+ <img src="https://img.shields.io/badge/runtimes-9-8A2BE2" alt="9 runtimes supported" />
12
+ <img src="https://img.shields.io/badge/telemetry-none-informational" alt="Zero telemetry" />
13
+ </p>
14
+
15
+ <p align="center">
16
+ <a href="#install">Install</a> &middot;
17
+ <a href="#quick-start">Quick Start</a> &middot;
18
+ <a href="#runtimes">Runtimes</a> &middot;
19
+ <a href="#how-recall-works">How recall works</a> &middot;
20
+ <a href="#mcp-tools">MCP</a> &middot;
21
+ <a href="#rest-api">REST API</a> &middot;
22
+ <a href="#daily-digest">Digest</a> &middot;
23
+ <a href="#configuration">Config</a> &middot;
24
+ <a href="#security">Security</a> &middot;
25
+ <a href="#vs-alternatives">vs Alternatives</a>
26
+ </p>
2
27
 
3
- > Local-first memory OS for AI operators — one corpus across every runtime you use.
28
+ ---
4
29
 
5
- `nlm-memory` indexes every session from Claude Code, Codex, OpenCode, Cursor, Windsurf, Hermes, Aider, and pi into a single searchable store on your machine. Three properties no competitor ships together:
30
+ `nlm-memory` indexes every session from Claude Code, Codex, OpenCode, Cursor, Windsurf, Hermes, Aider, and pi into a single searchable store on your machine. Three properties no other memory layer ships together:
6
31
 
7
- 1. **Cross-runtime reach.** One index spans every adapter — not one per runtime.
8
- 2. **Editable timeline.** Sessions can be superseded, retired, or marked aborted. Memory is non-linear: patch history retroactively. No other memory layer lets you do this.
32
+ 1. **Cross-runtime reach.** One index, every adapter.
33
+ 2. **Editable timeline.** Sessions can be superseded, retired, or marked aborted. Patch history retroactively no other tool lets you do this.
9
34
  3. **97.2% R@5 baseline.** On a 14-month corpus, keyword recall surfaces the right session in the top 5 on 97.2% of evaluator queries. No fine-tuning, no cloud, no account.
10
35
 
11
- Everything stays on your machine. No telemetry, no account required beyond your classifier of choice.
12
-
13
- ---
14
-
15
- ## Requirements
16
-
17
- - **Node 20+**
18
- - **[Ollama](https://ollama.com)** running locally with `nomic-embed-text` pulled for semantic search:
19
- ```sh
20
- ollama pull nomic-embed-text
21
- ```
22
- - **A classifier** — pick during setup:
23
- - **DeepSeek cloud** (recommended for speed) — fast, cheap (~$0.002/session). Sends up to 30K chars of each session transcript to `api.deepseek.com`.
24
- - **Ollama local** — fully offline. Slower; uses whichever chat model you select from your local pull list.
36
+ Everything stays on your machine. No telemetry, no account beyond your classifier of choice.
25
37
 
26
38
  ---
27
39
 
@@ -32,49 +44,129 @@ npm install -g nlm-memory
32
44
  nlm setup
33
45
  ```
34
46
 
35
- `nlm setup` is the interactive first-run wizard. It asks you to pick your classifier, model, and which runtimes to connect (Claude Code, Codex, Hermes), then installs the daemon. After it finishes, open **http://localhost:3940/ui** — done.
36
-
37
- ### Platform support
47
+ `nlm setup` is the interactive first-run wizard. It picks your classifier + model, wires the runtimes you actually use, generates an `NLM_MCP_TOKEN`, hardens permissions on `~/.nlm/`, and installs the daemon supervisor for your platform.
38
48
 
39
49
  | Platform | Daemon | Notes |
40
50
  |---|---|---|
41
51
  | **macOS** | LaunchAgent at `~/Library/LaunchAgents/com.github.pbmagnet4.nlm-memory.plist` | Auto-starts on login |
42
- | **Linux** | systemd user unit at `~/.config/systemd/user/nlm.service` | Run `loginctl enable-linger $USER` on headless servers so the daemon survives logout |
43
- | **Windows** | Manual `nlm start` for now | Hook + MCP install paths are platform-aware; daemon supervisor lands in the next release |
52
+ | **Linux** | systemd user unit at `~/.config/systemd/user/nlm.service` | Headless servers: `loginctl enable-linger $USER` so the daemon survives logout |
53
+ | **Windows** | Manual `nlm start` for now | Hook + MCP install paths are platform-aware; supervisor lands next release |
54
+
55
+ Stop or remove: `nlm uninstall`.
56
+
57
+ ---
58
+
59
+ ## Quick Start
60
+
61
+ After `nlm setup` finishes, open **http://localhost:3940/ui** — the daemon is running. A 30-second sanity check:
44
62
 
45
- To stop or remove:
46
63
  ```sh
47
- nlm uninstall # remove the daemon supervisor on your platform
64
+ nlm recall "what was that pgvector decision" # one-shot search from the shell
65
+ nlm digest # yesterday's activity at a glance
66
+ nlm --version
48
67
  ```
49
68
 
50
69
  ---
51
70
 
71
+ ## Runtimes
72
+
73
+ One corpus across every adapter. `nlm connect` wires hooks + MCP for each runtime:
74
+
75
+ | Runtime | Connect | Sessions read from | Hooks |
76
+ |---|---|---|---|
77
+ | **Claude Code** | `nlm connect claude-code` | `~/.claude/projects/**/*.jsonl` | 5 (UserPromptSubmit, SessionStart, Stop, PreCompact, SubagentStart) |
78
+ | **Codex CLI** | `nlm connect codex` | `~/.codex/sessions/` | Marketplace plugin |
79
+ | **Hermes** | `nlm connect hermes` | Hermes session DB | MCP only |
80
+ | **Hermes Agent** | `nlm connect hermes-agent` | Hermes plugin path | pre-turn, post-turn, lifecycle |
81
+ | **Cursor** | `nlm connect cursor` | Cursor IDE chat DB | MCP only |
82
+ | **Windsurf** | `nlm connect windsurf` | Windsurf user dir | MCP only |
83
+ | **OpenCode** | adapter active | `~/.local/share/opencode/` | MCP only |
84
+ | **Aider** | adapter active | `AIDER_CHAT_HISTORY_FILE` | MCP only |
85
+ | **pi.dev** | adapter active | `~/.pi/sessions/` | MCP only |
86
+
87
+ `nlm disconnect <runtime>` reverses any of the above.
88
+
89
+ ---
90
+
52
91
  ## How recall works
53
92
 
54
- Once installed, NLM runs as a quiet background daemon. Two ways your AI agents get to it:
93
+ Two delivery paths. They share the same index.
55
94
 
56
95
  ### 1. Hooks (Claude Code) — automatic context injection
57
96
 
58
- `nlm connect claude-code` installs five hooks into `~/.claude/settings.json`:
97
+ Five hooks installed into `~/.claude/settings.json`:
59
98
 
60
- - **UserPromptSubmit / SessionStart** — before each turn, NLM scores the prompt against your past sessions and silently prepends a pointer block listing the 0–3 most likely-relevant prior sessions. The model sees them as conversational context.
61
- - **Stop** — after the model responds, NLM scans the response for citations of surfaced session IDs to measure useful-hit rate.
62
- - **PreCompact / SubagentStart** link conversations across compactions and subagent dispatches so threads stay coherent.
99
+ | Event | What NLM does | Mode |
100
+ |---|---|---|
101
+ | **UserPromptSubmit** | Score the prompt, silently prepend pointer block listing 0–3 most likely-relevant prior sessions | live by default |
102
+ | **SessionStart** | Cold-start agents (cron, background) hit this; same pointer-block delivery without a user prompt | live by default |
103
+ | **Stop** | Scan the model's response for citations of surfaced session IDs → updates `useful_hit_rate` and builds the reranker training substrate | always on |
104
+ | **PreCompact** | Flush the per-conversation surfaced-IDs memo so post-compaction recalls aren't gated | always on |
105
+ | **SubagentStart** | Record parent→subagent links so threads stay coherent across dispatches | always on |
63
106
 
64
- Default mode is **live** (recall injected into prompts). Switch to **shadow** (log-only, no injection) by setting `NLM_HOOK_MODE=shadow` in your hook commands.
107
+ Switch to **shadow** mode (log-only, no injection) anytime with `NLM_HOOK_MODE=shadow` on the hook command. Toggle for an existing install: re-run `nlm hook install` after changing the env var. The hook fails open — any error yields a clean exit and never blocks the model.
65
108
 
66
109
  ### 2. MCP — explicit tools any agent can call
67
110
 
111
+ Container-hosted agents (Hermes WebUI, Codex CLI, etc.) hit the Streamable-HTTP `POST /mcp` endpoint with `Authorization: Bearer ${NLM_MCP_TOKEN}`. Stdio MCP is also supported for Claude Code via `~/.mcp.json`.
112
+
113
+ ---
114
+
115
+ ## MCP Tools
116
+
117
+ | Tool | What it does |
118
+ |---|---|
119
+ | `recall_sessions` | Hybrid keyword+semantic search across the full session corpus. Returns label, started_at, snippet, match score. |
120
+ | `get_session` | Full body of one session by ID. Includes enriched `supersedes` / `supersededBy` links (id + label + summary) so chasing corrected facts doesn't need a second round-trip. |
121
+ | `recall_facts` | Search structured facts: decisions, open questions, project state. Filterable by entity and kind. |
122
+ | `get_fact_history` | Full version history of one fact — how a decision evolved over time. |
123
+ | `cite_session` | Mark a session as explicitly referenced. Drives the `useful_hit_rate` metric and the future learned reranker. |
124
+
125
+ ---
126
+
127
+ ## REST API
128
+
129
+ Daemon binds `127.0.0.1:3940` (override with `NLM_PORT`). Selected endpoints:
130
+
131
+ | Method | Path | Auth | Purpose |
132
+ |---|---|---|---|
133
+ | GET | `/api/health` | Host-only | Liveness probe; returns `{version, status, service}` |
134
+ | GET | `/api/recall` | Bearer/Origin | Hybrid recall — `?q=`, `?mode=keyword\|semantic\|hybrid`, `?limit=` |
135
+ | GET | `/api/recall/stats` | Bearer/Origin | 7-day stats: total, hit_rate, useful_hit_rate, top queries |
136
+ | GET | `/api/recall/recent` | Bearer/Origin | Last N recall events for live tail/telemetry |
137
+ | GET | `/api/recall/cite-stats` | Bearer/Origin | Citation rate over `?days=` |
138
+ | GET | `/api/session/:id` | Bearer/Origin | Full session body + supersedence links |
139
+ | GET | `/api/recall/facts` | Bearer/Origin | Structured fact search |
140
+ | GET | `/api/facts/history` | Bearer/Origin | Version chain for one fact |
141
+ | GET | `/api/dataset` | Bearer/Origin | Full session list for the UI dataset view |
142
+ | GET | `/api/live/recent-writes` | Bearer/Origin | Live tail of ingested sessions |
143
+ | GET | `/api/data/backup` | Bearer/Origin | Streaming SQLite snapshot download |
144
+ | POST | `/api/data/restore` | Bearer/Origin | Stage a snapshot for apply-on-restart |
145
+ | POST | `/api/hook/pre-compact` | Bearer/Origin | Hook endpoint; flushes the surfaced-IDs memo |
146
+ | ALL | `/mcp` | Bearer required | Streamable-HTTP MCP transport for container agents |
147
+
148
+ `/api/*` is gated by three layers: 127.0.0.1 Host check (defeats DNS rebinding), Origin check when the browser sends one (defeats cross-origin drive-by), Bearer fallback when Origin is absent (server-to-server clients).
149
+
150
+ ---
151
+
152
+ ## Daily digest
153
+
154
+ Once-a-day summary of yesterday's activity:
155
+
68
156
  ```sh
69
- nlm connect claude-code # writes ~/.mcp.json + installs hooks
70
- nlm connect codex # installs as a Codex marketplace plugin
71
- nlm connect hermes # writes ~/.hermes/config.yaml (MCP)
72
- nlm connect hermes-agent # installs as a NousResearch Hermes plugin (hooks + MCP)
157
+ nlm digest # print to stdout
158
+ nlm digest --telegram # post to Telegram (TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID)
73
159
  ```
74
160
 
75
- Once wired, agents can call `recall_sessions` (search past conversations), `recall_facts` (decisions/open questions/project state), `get_session` (pull a full session), `get_fact_history` (how a decision evolved), and `cite_session` (explicitly mark a session as referenced).
161
+ Reports 24h real-traffic (probes filtered), 7d hit_rate + useful_hit_rate, top 5 queries, and a **`WARN hook silent`** alert when Claude Code ran yesterday but no live hook fires were logged. That alert is the canary for post-install drift — node upgrades, `settings.json` hand-edits, and `dist/` moves silently break the hook while Claude Code keeps working. Setup-time smoke tests can't catch this; only the daily correlation can.
162
+
163
+ Wire to cron for a morning push:
164
+
165
+ ```cron
166
+ 0 7 * * * nlm digest --telegram >> ~/.nlm/logs/digest.log 2>&1
167
+ ```
76
168
 
77
- For container-hosted agents that can't use stdio MCP, the daemon also exposes Streamable-HTTP MCP at `POST /mcp`. Use the auto-generated `NLM_MCP_TOKEN` from `~/.nlm/.env` as a bearer.
169
+ When the daemon is unreachable, `--telegram` still fires posts a "daemon unreachable" alert instead of failing silently.
78
170
 
79
171
  ---
80
172
 
@@ -84,30 +176,88 @@ Open `http://localhost:3940/ui` after the daemon starts.
84
176
 
85
177
  | Page | What it shows |
86
178
  |---|---|
87
- | **Live** | Sessions being written in real time, recent reads and decisions |
179
+ | **Live** | Sessions being written in real time, recent reads, recent decisions |
88
180
  | **Pulse** | System health — coherence, runtimes, stale entities, recent sessions |
89
- | **River** | Full session timeline with density controls and supersedence visualization |
90
- | **Thread** | Per-entity conversation history with runtime filters |
91
- | **Search** | Keyword, semantic, or hybrid recall with match snippets |
92
- | **Recall** | Adoption telemetry — is the memory system actually being used? |
181
+ | **River** | Full session timeline with density controls + superseded-lane visualization |
182
+ | **Thread** | Per-entity conversation history with runtime filters and ←/→ navigation |
183
+ | **Search** | Keyword, semantic, or hybrid recall with match snippets and field-origin tags |
184
+ | **Recall** | Adoption telemetry — useful_hit_rate, source breakdown, query log |
93
185
  | **Settings** | Sources, providers, classifier, data backup/restore |
94
186
 
95
187
  ---
96
188
 
189
+ ## Pipeline
190
+
191
+ What happens when an AI runtime writes a session and you later recall it:
192
+
193
+ ```
194
+ ingest: runtime transcript (jsonl/sqlite)
195
+ -> adapter parses runtime-specific format
196
+ -> classifier (DeepSeek cloud or Ollama local) extracts label + entities + decisions + open questions
197
+ -> embedder (nomic-embed-text via Ollama) computes 768-dim vector
198
+ -> SQLite canonical store + FTS5 keyword index + sqlite-vec ANN index
199
+
200
+ recall: prompt / query
201
+ -> tokenize + match scoring (label x3, entity-exact x4, decision x2, summary x1, phrase-bonus +5)
202
+ -> hybrid: BM25-style keyword + vector cosine, fused by score
203
+ -> select-top-N gate (per-fire cap 3, per-conversation cap 10)
204
+ -> pointer block prepended to model context (hooks) or returned as tool result (MCP)
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Configuration
210
+
211
+ ### Environment variables
212
+
213
+ | Var | Default | What |
214
+ |---|---|---|
215
+ | `NLM_PORT` | `3940` | Daemon bind port (loopback only) |
216
+ | `NLM_DB_PATH` | `~/.nlm/canonical.sqlite` | SQLite canonical store location |
217
+ | `NLM_HOOK_MODE` | `live` | `live` injects pointer block; `shadow` logs without injecting |
218
+ | `NLM_HOOK_LOG` | `~/.nlm/hook-log.jsonl` | Hook fire log; powers digest's liveness alert |
219
+ | `NLM_USEFUL_HIT_LOG` | `~/.nlm/useful-hit-log.jsonl` | Citation/useful-hit ledger |
220
+ | `NLM_QUERY_LOG` | `~/.nlm/query-log.jsonl` | Recall query telemetry |
221
+ | `NLM_CITATION_LOG` | `~/.nlm/citation-log.jsonl` | Stop-hook citation events |
222
+ | `NLM_MCP_TOKEN` | auto-generated | 256-bit bearer for `/api/*` (non-browser) and `/mcp` |
223
+ | `NLM_MCP_CONFIG` | `~/.mcp.json` | Path the `connect`/`disconnect` commands modify |
224
+ | `NLM_CLASSIFIER` | `deepseek` | `deepseek` (cloud) or `ollama` (local) |
225
+ | `NLM_CLASSIFIER_MODEL` | `deepseek-v4-flash` | Model id for the chosen provider |
226
+ | `NLM_OLLAMA_URL` | `http://localhost:11434` | Override Ollama endpoint |
227
+ | `NLM_ADAPTERS` | all | Comma-separated allowlist of adapters to enable |
228
+ | `DEEPSEEK_API_KEY` | — | Required when classifier=deepseek |
229
+ | `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` | — | Required for `nlm digest --telegram` |
230
+
231
+ Adapter source paths can be overridden individually: `NLM_CLAUDE_PROJECTS_PATH`, `NLM_CODEX_CONFIG`, `NLM_CURSOR_DB_PATH`, `NLM_HERMES_SESSIONS_PATH`, `NLM_HERMES_AGENT_DB_PATH`, `NLM_WINDSURF_USER_DIR`, `OPENCODE_DB_PATH`, `PI_SESSIONS_PATH`, `AIDER_CHAT_HISTORY_FILE`.
232
+
233
+ ### Config file
234
+
235
+ `~/.nlm/.env` — autoloaded by every CLI command. Mode `0600`, owned by you, never readable by other users. The setup wizard writes the initial keys; you can edit it directly.
236
+
237
+ ### Ports
238
+
239
+ | Port | Process | Bind | Override |
240
+ |---|---|---|---|
241
+ | `3940` | Daemon HTTP API + MCP | `127.0.0.1` only | `NLM_PORT` |
242
+ | `11434` | Ollama (embedding + local classifier) | localhost | `NLM_OLLAMA_URL` |
243
+
244
+ ---
245
+
97
246
  ## Security
98
247
 
99
248
  NLM is local-first by design. The daemon:
100
249
 
101
- - Binds to `127.0.0.1` only — never `0.0.0.0`.
102
- - Enforces Host + Origin checks on `/api/*` to block DNS rebinding and cross-origin drive-by.
103
- - Generates a 256-bit `NLM_MCP_TOKEN` on first run and persists to `~/.nlm/.env` (mode `0600`). All non-browser API requests (hooks, MCP container clients) authenticate with `Authorization: Bearer ${NLM_MCP_TOKEN}`.
104
- - Recursively enforces `0700` on `~/.nlm/` and `0600` on its contents on every start.
250
+ - Binds to `127.0.0.1` only — never `0.0.0.0`
251
+ - Enforces Host + Origin checks on `/api/*` to defeat DNS rebinding and cross-origin drive-by
252
+ - Generates a 256-bit `NLM_MCP_TOKEN` on first run, persists to `~/.nlm/.env` (mode `0600`); non-browser clients authenticate with `Authorization: Bearer ${NLM_MCP_TOKEN}` compared with `timingSafeEqual`
253
+ - Recursively enforces `0700` on `~/.nlm/` and `0600` on its contents on every start
105
254
  - Sends nothing outbound except:
106
- - Ollama (`localhost:11434`) for embeddings + local classifier
107
- - DeepSeek API (`api.deepseek.com`) — only when classifier is set to DeepSeek
255
+ - Ollama (`localhost:11434`) for embeddings and the local classifier path
256
+ - DeepSeek API (`api.deepseek.com`) when classifier is set to DeepSeek
257
+ - Telegram API (`api.telegram.org`) when `nlm digest --telegram` is invoked
108
258
  - Your AI runtime transcript files (read-only)
109
259
 
110
- No telemetry. No vendor calls. No account.
260
+ No telemetry. No vendor pings. No account.
111
261
 
112
262
  Report vulnerabilities via [SECURITY.md](SECURITY.md).
113
263
 
@@ -123,13 +273,21 @@ Old installs have `NLM_HOOK_MODE=shadow` hardcoded in `~/.claude/settings.json`
123
273
 
124
274
  ---
125
275
 
126
- ## How it differs from mem0 and graphiti
276
+ ## vs Alternatives
277
+
278
+ | | **nlm-memory** | mem0 | Letta / MemGPT | Built-in (`CLAUDE.md`) |
279
+ |---|---|---|---|---|
280
+ | **Unit of memory** | Whole session + extracted markers | Atomic facts | Graph nodes + edges | Static file |
281
+ | **Cross-runtime** | 9 adapters, one corpus | Per-app SDK integration | Per-app SDK integration | Per-runtime config |
282
+ | **Editable timeline** | Sessions can be superseded, retired, aborted | Append-only fact log | Graph edits | Manual file edits |
283
+ | **R@5 baseline** | 97.2% on 14mo corpus | published varies | published varies | n/a |
284
+ | **External deps** | SQLite + Ollama (local) | Postgres or Qdrant | Postgres | none |
285
+ | **Hosted offering** | none — local only | yes | yes | n/a |
286
+ | **Account required** | none | yes (cloud tier) | yes | none |
287
+ | **Telemetry** | none | yes | yes | none |
288
+ | **License** | Apache 2.0 | Apache 2.0 | Apache 2.0 | — |
127
289
 
128
- - **Unit of memory:** whole sessions with extracted markers (decisions, open questions, entities), not individual facts or graph edges.
129
- - **Audience:** you querying your own past work, not an embedded SDK for app developers.
130
- - **Cross-runtime:** one corpus across Claude Code, Codex, OpenCode, Cursor, Windsurf, Hermes, and more. Competitors target one runtime.
131
- - **Editable timeline:** sessions can be superseded, retired, aborted. No other tool lets you retrofit memory — a record from 6 months ago can be corrected today.
132
- - **Local-only:** no hosted offering, no telemetry, no vendor dependency.
290
+ The defining property is the editable timeline. mem0 and Letta append; NLM lets you reach back and mark a session as superseded by a newer one, retire one as no-longer-relevant, or flag one as aborted-mid-flight. The next recall surfaces the corrected version, not the stale one. A claim from 6 months ago can be patched today.
133
291
 
134
292
  ---
135
293
 
@@ -138,17 +296,17 @@ Old installs have `NLM_HOOK_MODE=shadow` hardcoded in `~/.claude/settings.json`
138
296
  ```sh
139
297
  git clone https://github.com/pbmagnet4/nlm-memory-ts
140
298
  cd nlm-memory-ts
141
- npm install # install dependencies
142
- npm run build # compile dist/ — commit the result, it ships in the repo
143
- npm run dev # hot-reload daemon
144
- npm run ui:dev # hot-reload UI at localhost:5173 (proxies /api to :3940)
145
- npm test # 601 tests across 62 files
299
+ npm install
300
+ npm run build # compile dist/ — commit the result, it ships in the repo
301
+ npm run dev # hot-reload daemon
302
+ npm run ui:dev # hot-reload UI at localhost:5173 (proxies /api to :3940)
303
+ npm test # 612 tests across 64 files
146
304
  npm run typecheck
147
305
  ```
148
306
 
149
- `dist/` is committed to the repo so the global install works without a build step on the user's machine. Rebuild and commit `dist/` whenever you change `src/`.
307
+ Architecture: hexagonal. `src/core/` knows about ports (interfaces), not adapters. `src/cli/nlm.ts` is the composition root the only file that wires concrete implementations (`SqliteSessionStore`, `OllamaClient`, `Hono`, `StdioServerTransport`). Adapters in `src/core/adapters/` are one-way: they parse runtime-specific session formats into NLM's canonical shape; nothing in the runtime sees NLM.
150
308
 
151
- Database lives at `~/.nlm/canonical.sqlite`. Override with `NLM_DB_PATH`.
309
+ `dist/` is committed so `npm install -g` works without a build step. Rebuild + commit when you change `src/`.
152
310
 
153
311
  ---
154
312
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * `nlm digest` — compose a daily-activity digest from the running daemon and
3
+ * either print it to stdout (default) or POST it to Telegram.
4
+ *
5
+ * Talks to the daemon over HTTP so it works regardless of where the daemon is
6
+ * actually running. If the daemon is unreachable, the Telegram path posts a
7
+ * "daemon unreachable" alert instead of failing silently — the cron user is
8
+ * specifically watching for this telemetry, so silence is worse than noise.
9
+ */
10
+ export interface DigestOptions {
11
+ readonly port: number;
12
+ readonly telegram: boolean;
13
+ readonly timeoutMs?: number;
14
+ }
15
+ export interface DigestResult {
16
+ readonly text: string;
17
+ readonly delivered: "stdout" | "telegram" | "telegram-alert";
18
+ readonly daemonReachable: boolean;
19
+ }
20
+ export declare function runDigest(opts: DigestOptions): Promise<DigestResult>;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `nlm digest` — compose a daily-activity digest from the running daemon and
3
+ * either print it to stdout (default) or POST it to Telegram.
4
+ *
5
+ * Talks to the daemon over HTTP so it works regardless of where the daemon is
6
+ * actually running. If the daemon is unreachable, the Telegram path posts a
7
+ * "daemon unreachable" alert instead of failing silently — the cron user is
8
+ * specifically watching for this telemetry, so silence is worse than noise.
9
+ */
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { composeDigest, } from "../core/digest/compose.js";
14
+ import { checkHookLiveness } from "../core/digest/hook-liveness.js";
15
+ import { hookAuthHeaders } from "../hook/hook-auth.js";
16
+ export async function runDigest(opts) {
17
+ const base = `http://localhost:${opts.port}`;
18
+ const timeoutMs = opts.timeoutMs ?? 8000;
19
+ let stats = null;
20
+ let recent = [];
21
+ let sessions = [];
22
+ let daemonError = null;
23
+ try {
24
+ const [statsRes, recentRes, datasetRes] = await Promise.all([
25
+ fetchJson(`${base}/api/recall/stats`, timeoutMs),
26
+ fetchJson(`${base}/api/recall/recent?limit=200`, timeoutMs),
27
+ fetchJson(`${base}/api/dataset`, timeoutMs * 2),
28
+ ]);
29
+ stats = statsRes;
30
+ recent = (recentRes.entries) ?? [];
31
+ sessions = (datasetRes.sessions) ?? [];
32
+ }
33
+ catch (e) {
34
+ daemonError = e instanceof Error ? e.message : String(e);
35
+ }
36
+ if (daemonError !== null || stats === null) {
37
+ const text = `NLM digest — ${todayStr()}\n\n` +
38
+ `Daemon unreachable at ${base}\n${daemonError ?? "no stats returned"}`;
39
+ if (opts.telegram) {
40
+ await postTelegram(text);
41
+ return { text, delivered: "telegram-alert", daemonReachable: false };
42
+ }
43
+ process.stdout.write(`${text}\n`);
44
+ return { text, delivered: "stdout", daemonReachable: false };
45
+ }
46
+ const hookLogPath = process.env["NLM_HOOK_LOG"] ?? join(homedir(), ".nlm", "hook-log.jsonl");
47
+ const hookLogExists = existsSync(hookLogPath);
48
+ const hookLog = hookLogExists ? readHookLog(hookLogPath) : [];
49
+ const hookAlert = checkHookLiveness({
50
+ sessions,
51
+ hookLog,
52
+ hookLogPath,
53
+ hookLogExists,
54
+ });
55
+ const text = composeDigest({
56
+ stats,
57
+ recent,
58
+ port: opts.port,
59
+ hookAlert,
60
+ });
61
+ if (opts.telegram) {
62
+ await postTelegram(text);
63
+ return { text, delivered: "telegram", daemonReachable: true };
64
+ }
65
+ process.stdout.write(`${text}\n`);
66
+ return { text, delivered: "stdout", daemonReachable: true };
67
+ }
68
+ async function fetchJson(url, timeoutMs) {
69
+ const controller = new AbortController();
70
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
71
+ try {
72
+ const res = await fetch(url, {
73
+ headers: hookAuthHeaders({ "user-agent": "nlm-digest/1.0" }),
74
+ signal: controller.signal,
75
+ });
76
+ if (!res.ok) {
77
+ throw new Error(`${url} → ${res.status} ${res.statusText}`);
78
+ }
79
+ return await res.json();
80
+ }
81
+ finally {
82
+ clearTimeout(timer);
83
+ }
84
+ }
85
+ function readHookLog(path) {
86
+ const out = [];
87
+ const raw = readFileSync(path, "utf8");
88
+ for (const line of raw.split("\n")) {
89
+ if (!line)
90
+ continue;
91
+ try {
92
+ out.push(JSON.parse(line));
93
+ }
94
+ catch {
95
+ // Corrupt line — skip silently. The digest is best-effort observability,
96
+ // not a parser test.
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+ async function postTelegram(text) {
102
+ const token = process.env["TELEGRAM_BOT_TOKEN"];
103
+ const chatId = process.env["TELEGRAM_CHAT_ID"];
104
+ if (!token || !chatId) {
105
+ throw new Error("TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID must be set for --telegram");
106
+ }
107
+ const url = `https://api.telegram.org/bot${token}/sendMessage`;
108
+ const body = new URLSearchParams({
109
+ chat_id: chatId,
110
+ text,
111
+ disable_web_page_preview: "true",
112
+ });
113
+ const controller = new AbortController();
114
+ const timer = setTimeout(() => controller.abort(), 10_000);
115
+ try {
116
+ const res = await fetch(url, {
117
+ method: "POST",
118
+ headers: {
119
+ "content-type": "application/x-www-form-urlencoded",
120
+ "user-agent": "nlm-digest/1.0",
121
+ },
122
+ body,
123
+ signal: controller.signal,
124
+ });
125
+ const payload = (await res.json());
126
+ if (!payload.ok) {
127
+ throw new Error(`telegram api error: ${payload.description ?? "unknown"}`);
128
+ }
129
+ }
130
+ finally {
131
+ clearTimeout(timer);
132
+ }
133
+ }
134
+ function todayStr() {
135
+ const d = new Date();
136
+ const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d.getDay()];
137
+ const y = d.getFullYear();
138
+ const m = String(d.getMonth() + 1).padStart(2, "0");
139
+ const day = String(d.getDate()).padStart(2, "0");
140
+ return `${weekday} ${y}-${m}-${day}`;
141
+ }
142
+ //# sourceMappingURL=digest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"digest.js","sourceRoot":"","sources":["../../src/cli/digest.ts"],"names":[],"mappings":"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"}
package/dist/cli/nlm.d.ts CHANGED
@@ -21,5 +21,6 @@
21
21
  * nlm connect codex — install Codex marketplace plugin
22
22
  * nlm disconnect claude-code — remove MCP block from ~/.mcp.json
23
23
  * nlm disconnect codex — remove Codex plugin
24
+ * nlm digest — print a daily-activity digest (or --telegram to post)
24
25
  */
25
26
  export {};
package/dist/cli/nlm.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * nlm connect codex — install Codex marketplace plugin
22
22
  * nlm disconnect claude-code — remove MCP block from ~/.mcp.json
23
23
  * nlm disconnect codex — remove Codex plugin
24
+ * nlm digest — print a daily-activity digest (or --telegram to post)
24
25
  */
25
26
  import { fileURLToPath } from "node:url";
26
27
  import { dirname, resolve, join } from "node:path";
@@ -63,6 +64,7 @@ import { MemoSweepScheduler } from "../core/hook/memo-sweep.js";
63
64
  import { isAgentLoaded, isBenignBootoutError } from "./launchctl-helpers.js";
64
65
  import { adapterFromSource } from "../core/adapters/from-source.js";
65
66
  import { scanUsefulHits } from "../core/recall/useful-scan.js";
67
+ import { runDigest } from "./digest.js";
66
68
  const __filename = fileURLToPath(import.meta.url);
67
69
  const __dirname = dirname(__filename);
68
70
  const MIGRATIONS_DIR = resolve(__dirname, "../../migrations");
@@ -1015,6 +1017,27 @@ program
1015
1017
  console.error(`nlm useful-scan: scanned ${result.total} recalls in the last ${opts.days}d — ${rate}` +
1016
1018
  (opts.dryRun ? " (dry-run)" : `, ${result.appended} appended`));
1017
1019
  });
1020
+ program
1021
+ .command("digest")
1022
+ .description("Compose a daily-activity digest from the running daemon (optionally post to Telegram)")
1023
+ .option("-p, --port <n>", "daemon port", (v) => Number.parseInt(v, 10), Number.parseInt(process.env["NLM_PORT"] ?? "3940", 10))
1024
+ .option("--telegram", "post to Telegram instead of printing to stdout (requires TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID)")
1025
+ .action(async (opts) => {
1026
+ autoloadEnv();
1027
+ try {
1028
+ const result = await runDigest({
1029
+ port: opts.port,
1030
+ telegram: opts.telegram === true,
1031
+ });
1032
+ if (!result.daemonReachable) {
1033
+ process.exit(1);
1034
+ }
1035
+ }
1036
+ catch (e) {
1037
+ console.error("nlm digest:", e instanceof Error ? e.message : e);
1038
+ process.exit(1);
1039
+ }
1040
+ });
1018
1041
  program.parseAsync().catch((e) => {
1019
1042
  console.error("nlm: fatal", e);
1020
1043
  process.exit(1);