@ponraaj/dimple-sdk 0.0.0-alpha.5 → 0.0.0-alpha.7

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.
Files changed (3) hide show
  1. package/README.md +55 -514
  2. package/dist/index.js +66 -45
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,35 +1,18 @@
1
1
  # @ponraaj/dimple-sdk
2
2
 
3
- A local-first memory system for AI agents. Store text memories (and whole
4
- codebases), retrieve them with hybrid semantic + lexical search, and let a
5
- **self-organizing topic tree + artifact graph + concepts** keep everything
6
- structured as it grows. Everything runs locally: ONNX embeddings on your
3
+ A local-first memory system for AI agents: store text memories and whole
4
+ codebases, retrieve them with hybrid semantic + lexical search, and let a
5
+ self-organizing **topic tree + artifact graph + concepts** keep everything
6
+ structured as it grows. Everything runs locally — ONNX embeddings on your
7
7
  CPU, SQLite files on disk.
8
8
 
9
- This is the programmatic SDK — the
10
- [`@ponraaj/dimple`](https://www.npmjs.com/package/@ponraaj/dimple) CLI is a
11
- thin facade over it.
9
+ > **Documentation**: [dimple.ponraaj.me](https://dimple.ponraaj.me) —
10
+ > quickstart, architecture, config reference, changelog ·
11
+ > **Package**: [npmx.dev/package/@ponraaj/dimple](https://npmx.dev/package/@ponraaj/dimple) ·
12
+ > **Status**: alpha (`0.0.0-alpha.n`) — the API is stabilizing.
12
13
 
13
- > **Status**: alpha (`0.0.0-alpha.n`). The API is stabilizing but may change.
14
-
15
- ### The one-liner
16
-
17
- ```ts
18
- import { makeDimple } from "@ponraaj/dimple-sdk"; // ← makeDimple, not dimple
19
-
20
- const dimple = await makeDimple({ config: "./dimple.jsonc" });
21
- const hits = await dimple.query("what did we decide about X", {
22
- limit: 5,
23
- contentType: "text/plain", // ← code-vs-message separation
24
- }); // query(text, options) — text is the FIRST positional argument
25
- await dimple.dispose();
26
- ```
27
-
28
- Calling conventions: every method takes **plain arguments** (`query(text,
29
- opts)`, `write(input, opts)`, `index({ paths })`), every failure is a
30
- rejected promise with a closed `_tag` (`"Sdk.NotFound"`, `"Sdk.InputError"`,
31
- …), and `dispose()` is **required** before `process.exit(0)` — the durable
32
- worker keeps the event loop alive (`dispose()` stops it; it is idempotent).
14
+ This is the programmatic SDK. The [`@ponraaj/dimple`](https://www.npmjs.com/package/@ponraaj/dimple)
15
+ CLI is a thin facade over it.
33
16
 
34
17
  ## Install
35
18
 
@@ -37,16 +20,15 @@ worker keeps the event loop alive (`dispose()` stops it; it is idempotent).
37
20
  npm install @ponraaj/dimple-sdk
38
21
  ```
39
22
 
40
- Runtime dependencies are the only things installed (Effect, libSQL,
41
- Transformers.js, drizzle) — the workspace internals (`@dimple/*`) are
42
- bundled into the package, including fully **self-contained type
43
- declarations** (the publish build vendors every internal d.ts and rewrites
44
- the `@dimple/*` specifiers — plain consumers never need the workspace).
23
+ Runtime deps only (Effect, libSQL, transformers) — workspace internals are
24
+ bundled, types are self-contained, and the default install ships **zero
25
+ postinstall scripts** (the local ONNX embedder is an optional peer,
26
+ auto-installed on first use).
45
27
 
46
28
  ## Quickstart
47
29
 
48
30
  ```ts
49
- import { makeDimple } from "@ponraaj/dimple-sdk";
31
+ import { makeDimple } from "@ponraaj/dimple-sdk"; // ← makeDimple, not dimple
50
32
 
51
33
  const dimple = await makeDimple({ config: "./dimple.jsonc" });
52
34
 
@@ -57,500 +39,59 @@ const mem = await dimple.write({
57
39
  });
58
40
 
59
41
  // hybrid semantic + FTS search
60
- const results = await dimple.query("how does the pipeline deploy", {
61
- limit: 3,
62
- });
63
-
64
- // index real code — files + extracted functions/classes/methods become memories
65
- const indexed = await dimple.index({ paths: ["./src"] });
66
-
67
- await dimple.dispose(); // stop the worker, close DBs (idempotent)
68
- ```
69
-
70
- ## The problem & the approach
71
-
72
- **Problem**: naive memory stores scan every vector per query (cost grows
73
- with memory count), return flat results with no structure, and give the
74
- agent no notion of _why_ a memory is relevant, what supersedes it, or what
75
- contradicts it.
76
-
77
- **Approach** — three structures maintained automatically by a durable
78
- background worker:
79
-
80
- 1. **Topic tree** — memories are routed by embedding similarity into tree
81
- leaves. Queries descend the tree (beam search on centroids) and only
82
- score the reached leaves: **search cost grows with depth, not with
83
- memory count**. A durable worker splits overflowing leaves and merges
84
- underflowing ones with deterministic algorithms (CF-triplet k-means) —
85
- same embeddings ⇒ same tree, anywhere.
86
- 2. **Hybrid retrieval** — vector (cosine) + FTS5 lexical legs fused with
87
- Reciprocal Rank Fusion, optional recency boost, and per-result graph
88
- context.
89
- 3. **Artifact graph + concepts** — directed `memory_supports` /
90
- `memory_supersedes` / `memory_contradicts` edges written by structural
91
- rules, code indexing, the agent, and the optional **dream** job (LLM
92
- classification of candidates + deterministic concept clustering).
93
- Retrieval consumes the graph: superseded memories demote, conflicts
94
- surface, supports ride along as provenance; concept filters give the
95
- query sovereignty.
96
-
97
- All structural decisions are **deterministic**; everything is **durable**
98
- (two SQLite files, resume-safe jobs).
99
-
100
- ## Configuration
101
-
102
- Pass a config file path or an inline object:
103
-
104
- ```ts
105
- const dimple = await makeDimple({ config: "./dimple.jsonc" });
106
- // or — the inline object mirrors the JSON config exactly (quoted keys):
107
- const dimple = await makeDimple({
108
- config: {
109
- storeUrl: "file:./.dimple/dimple.db",
110
- jobs: { dbUrl: "file:./.dimple/jobs.db" },
111
- embedding: {
112
- defaultModel: "minilm",
113
- models: [
114
- {
115
- id: "minilm",
116
- provider: "transformers",
117
- model: "Xenova/all-MiniLM-L6-v2",
118
- dimensions: 384,
119
- },
120
- ],
121
- },
122
- },
123
- });
124
- ```
125
-
126
- ### Config sections (`dimple.jsonc`)
42
+ const hits = await dimple.query("how does the pipeline deploy", { limit: 3 });
127
43
 
128
- | Section | Fields | Notes |
129
- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
130
- | `storeUrl` | string | memory store DB (`file:` or remote libSQL) |
131
- | `jobs` | `dbUrl`, `execution` (reserved), `concurrency`, `leaseTimeoutMs` (reserved), `maxAttempts` (1–100), `pollIntervalMs` (reserved) | separate DB for the durable queue |
132
- | `embedding` | `defaultModel` + `models[]` | **required** — see Models below |
133
- | `topics` | tree shape (`minLeafItems`, `maxLeafItems`, …) | optional, defaults apply |
134
- | `graph` | graph config | optional |
135
- | `llm` | `baseURL`, `apiKey`, `modelId`, `temperature` | enables `dream`/`repair` |
136
- | `logging` | `enabled`, `level`, `format` (`jsonl`\|`pretty`), `file`, `scopes`, `ringSize` | silent by default; `file` = durable JSONL |
137
- | `defaultK` / `defaultLimit` | numbers | retrieval defaults |
138
- | `defaultTtlSecs` | number | write TTL default |
44
+ // index a codebase (tree-sitter: one unit per file + per definition)
45
+ await dimple.index({ paths: ["./src"] });
139
46
 
140
- ### Remote databases (Turso)
141
-
142
- `storeUrl` and `jobs.dbUrl` accept **remote libSQL URLs** (`libsql://`,
143
- `https://`, `ws://` — Turso or any libsql-compatible host). The same
144
- engine runs everything the local file does — FTS5, vector search, the
145
- topic tree, durable jobs — over the wire. Verified end-to-end against
146
- the real Turso service (`tests/store/turso-remote.test.ts`).
147
-
148
- Turso tokens are **per-database**, so each URL carries its own token.
149
- `dimple.jsonc` resolves `${ENV_VAR}` in values, so tokens stay out of
150
- config files:
151
-
152
- ```jsonc
153
- {
154
- "storeUrl": "libsql://<org>-<memdb>.turso.io?authToken=${TURSO_MEMORIES_TOKEN}",
155
- "jobs": {
156
- "dbUrl": "libsql://<org>-<jobsdb>.turso.io?authToken=${TURSO_JOBS_TOKEN}",
157
- },
158
- "embedding": {
159
- /* hosted providers for serverless — local models are
160
- local-only (auto-install needs Node) */
161
- },
162
- }
163
- ```
164
-
165
- Notes:
166
-
167
- - The **jobs DB must also be remote** when the store is remote (the
168
- durable queue + workflow state are cross-invocation)
169
- - `file:`-only behaviors (parent-dir creation, migration lock) skip
170
- for remote URLs
171
- - A single-DB setup can share one URL + one token (`TURSO_TOKEN`)
172
- - For Cloudflare Workers the client swaps to the pure-JS entry
173
- (`@libsql/client/web` — one bundler alias, no source changes)
174
-
175
- ### Embedding models
176
-
177
- Local embeddings (`provider: "transformers"`) auto-install on first
178
- use — `@huggingface/transformers` is an **optional peer** so hosted-only
179
- consumers never pull its tree (the `sharp` + `onnxruntime-node`
180
- postinstall scripts that abort npm 11 installs). The first `embed()`
181
- installs it with the project's own package manager, scripts skipped
182
- (proven unnecessary for embedding), and retries in-process. Offline or
183
- restricted environments get a typed `Sdk.Embedding` error with the manual
184
- command. Set `autoInstall: false` on the model to disable:
185
- `embedding.models[].autoInstall: false`
186
-
187
- ### Embedding models
188
-
189
- Two providers — **local** (Transformers.js ONNX, runs on your CPU, no
190
- keys) and **hosted** (any OpenAI-compatible endpoint, plus native
191
- provider families):
192
-
193
- ```jsonc
194
- "embedding": {
195
- "defaultModel": "bge",
196
- "models": [
197
- { "id": "minilm", "provider": "transformers",
198
- "model": "Xenova/all-MiniLM-L6-v2", "dimensions": 384 },
199
- { "id": "bge", "provider": "transformers",
200
- "model": "Xenova/bge-small-en-v1.5", "dimensions": 384 },
201
- { "id": "bge-base", "provider": "transformers",
202
- "model": "Xenova/bge-base-en-v1.5", "dimensions": 768 },
203
- { "id": "gemini", "provider": "openai",
204
- "model": "gemini-embedding-2", "dimensions": 3072, "apiKey": "…",
205
- "baseURL": "https://generativelanguage.googleapis.com/v1beta/openai/" },
206
- { "id": "openai", "provider": "openai",
207
- "model": "text-embedding-3-small", "dimensions": 1536, "apiKey": "…" }
208
- ]
209
- }
47
+ await dimple.dispose(); // required before process.exit — the durable worker
210
48
  ```
211
49
 
212
- **Hosted providers** — the config `provider` field selects the family:
213
-
214
- | `provider` | Models | `baseURL` needed? |
215
- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- |
216
- | `openai` (default) | any OpenAI-compatible endpoint — OpenAI, vLLM, **ollama** (`http://localhost:11434/v1`), Ollama Cloud, LM Studio, Together, Fireworks, Groq, Mistral, … | optional (defaults to OpenAI) — set it for custom endpoints |
217
- | `google` | `gemini-embedding-001`, `gemini-embedding-2`, `gemini-embedding-2-preview` (native provider) | ❌ no |
218
- | `cohere` | `embed-english-v3.0`, `embed-multilingual-v3.0`, … (native provider) | ❌ no |
219
- | `bedrock` | `amazon.titan-embed-text-v2:0`, `amazon.nova-embed-text-v2:0`, `cohere.embed-*` (native provider; AWS creds from env) | ❌ no |
220
-
221
- `dimensions` is sent as a provider option where the API supports it
222
- (openai-compatible/`outputDimensionality`/`outputDimension`/titan `dimensions`
223
- and nova `embeddingDimension`) and ALWAYS enforced on the output.
224
- Verified end-to-end against real `gemini-embedding-2` (3072-dim), local
225
- ollama `Qwen3-Embedding-0.6B` (1024-dim), and the openai-compatible
226
- contract.
227
-
228
- - `dimensions` must match the model's real output — a mismatch fails typed
229
- on first use
230
- - Multiple models coexist: each gets its own vector tables
231
- (`vec_<dims>_<model>`) and its own topic tree; queries route per model
232
- - **Per-model runtime options** (transformers provider, all optional):
233
- `device` (`auto` | `gpu` | `cpu` | `wasm` | `webgpu` | `cuda` | `dml` |
234
- `coreml` | `webnn` — `cpu`/`wasm` tested; GPU/CUDA needs the onnxruntime
235
- CUDA libs, fails typed without them), `dtype` (`fp32` | `fp16` | `q8` |
236
- `int8` | `uint8` | `q4` | `bnb4` | `q4f16` | `q2` | `q2f16` | `q1` |
237
- `q1f16` — quantizes model WEIGHTS only, never the output vectors;
238
- `fp32`/`q8`/`int8`/`q4`/`fp16` verified end-to-end, others depend on the
239
- HF repo shipping that ONNX variant), `pooling` (`mean` | `cls` | `none`),
240
- `normalize` (boolean), `cacheDir`
241
- - **DB-level vector quantization** via `vectorType` (`float32` |
242
- `float16` | `float8` | `float1bit`): the per-memory search vectors are
243
- stored as int8/bfloat16/binary blobs and searched with libSQL's native
244
- `vector_distance_cos` — `float8` verified end-to-end at **395 B vs
245
- 1536 B (4× smaller)** for 384-dim. The topic tree's centroid math and
246
- `memories.embedding` always stay float32.
247
- - **Switching models**: set `defaultModel` to a configured model (multi-
248
- model configs coexist — each model gets its own tree + `vec_<dims>_<model>`
249
- tables). After a restart the new default handles all writes/queries;
250
- the old model's tree stays intact. Existing memories are backfilled via
251
- the durable `memory.embed` job (re-embed + re-route into the new tree,
252
- idempotent/resume-safe) — the store-level switch (`setActiveModel` +
253
- `findStale`) drives it, reachable through the `{ core: … }` path. Until
254
- backfilled, old memories remain in the old tree (queries descend the
255
- ACTIVE model's tree), and the runtime is bound to one active model at a
256
- time (per-model reads for another model fail typed with
257
- `topics.ModelMismatch`).
258
- - Vectors are always stored as float32 (`F32_BLOB`, 4 B/dim: 384 = 1536 B,
259
- 768 = 3072 B) regardless of `dtype`
260
-
261
- ### Power users: `{ core: … }`
262
-
263
- Instead of a config, pass full `CoreOptions` (store/jobs URLs, models,
264
- `defaultModelId`, `embedderFor` factory, topics/graph/logging/llm,
265
- `concurrency`, `maxAttempts`, `extraDefs`/`extraHandlers` for custom durable
266
- jobs — register `{ name: "my.job", handler }` and enqueue it via the
267
- runtime; `extraHandlers` entries override built-ins by name). This is the
268
- injection seam used by tests (synthetic embedders).
50
+ Calling conventions: **plain arguments** (`query(text, opts)`,
51
+ `write(input, opts)`, `index({ paths })`); every failure is a rejected
52
+ promise with a closed `_tag` (`"Sdk.NotFound"`, `"Sdk.InputError"`, …);
53
+ `dispose()` is idempotent.
269
54
 
270
55
  ## API surface
271
56
 
272
- ### `write(input, options?)`
273
-
274
- ```ts
275
- await dimple.write({
276
- logicalKey: "deploy-1", // required, non-empty
277
- content: "…", // required
278
- namespace?: "default",
279
- contentType?: "text/plain",
280
- ttlSecs?: 604800,
281
- metadata?: "{\"json\":true}", // JSON string
282
- typedMetadata?: { … },
283
- }, { embed: true }); // embed + topic-route (default); false for externally-managed embeddings
284
- ```
285
-
286
- Returns the persisted `MemoryUnit`: `{ id, logicalKey, content,
287
- contentType, state: "active" | "superseded" | …, createdAt, ttlSecs,
288
- namespace, metadata, embedding? }` (re-fetched after embedding). Duplicate
289
- active `logicalKey` → `Sdk.DuplicateKey` (carries `logicalKey`/`namespace`).
290
-
291
- **Memory expiry**: `ttlSecs` (or the config `defaultTtlSecs`) schedules
292
- expiry; a sweep job retires expired memories in the background.
57
+ | Method | What it does |
58
+ | --------------------------------------------------------- | --------------------------------------------------------- |
59
+ | `write(input, { embed })` | Store a memory (idempotent per `logicalKey`) |
60
+ | `get(id)` / `forget(id)` | Fetch / remove by id or logical key |
61
+ | `query(text, options)` | Hybrid search — profiles, budgets, content-type filter |
62
+ | `updateEmbedding(id, vec, modelKey?)` | Attach an external embedding |
63
+ | `index({ paths })` | Codebase ingestion (idempotent re-index) |
64
+ | `graph.addEdge/confirm/reject/expire/candidates/coverage` | Memory relationships: supports / supersedes / contradicts |
65
+ | `explain({ kind, id }, options)` | Bounded graph neighborhood |
66
+ | `dream(modelKey?)` / `repair(modelKey?)` | Consolidation + maintenance jobs |
67
+ | `health(modelKey?)` | Topic-tree invariant health |
68
+ | `reload(input?)` | Blue-green config reload |
69
+
70
+ Errors are a closed hierarchy (`InputError`, `NotFound`, `DuplicateKey`,
71
+ `JobFailed`, `LLMNotConfigured`, `StoreFailure`) — see the docs site.
293
72
 
294
- `query(text, { budget: { maxUnits, maxChars, maxMs } })` — LLM-facing
295
- context guardrail: caps the row count, truncates result content to the
296
- total char budget, and marks `results.truncated` when the soft time cap
297
- is exceeded.
298
-
299
- ### `get(id)` / `forget(id)`
300
-
301
- Fetch one memory; soft-delete (cascades edges, rebalances the tree).
302
-
303
- ### `query(text, options?)`
304
-
305
- ```ts
306
- await dimple.query("how does the pipeline deploy", {
307
- limit?: 10,
308
- k?: 60, // RRF smoothing
309
- namespace?: "default",
310
- profile?: {
311
- recencyBoost?: 0, // 0 = off; hyperbolically decays with age
312
- modeWeights?: { vector?: 0.5, fts?: 0.5 },
313
- conceptFilter?: string[], // concept ids (query sovereignty)
314
- } | "hybrid" | "semantic" | "keyword",
315
- topics?: { topicKeys?: string[] },
316
- });
317
- ```
318
-
319
- Returns `AugmentedMemory[]` — each entry carries the memory, the fused
320
- score, its 1-based `rank`, and the graph context (`supersededBy`,
321
- `conflicts`, `supportedBy`). Zero-result queries resolve to `[]` (the
322
- absent-vector-table case is an empty dense leg, never an error).
323
-
324
- ### `index(options)` — codebase ingestion
325
-
326
- ```ts
327
- await dimple.index({
328
- paths: ["./src", "./packages"], // dirs (recursive) and/or files
329
- keyPrefix?: "code-",
330
- namespace?: "default",
331
- embed?: true,
332
- maxFileBytes?: 64 * 1024,
333
- maxChunkBytes?: 8 * 1024,
334
- extensions?: [".ts", ".tsx", ".js", ".jsx", …],
335
- });
336
- // → { files, indexed, updated, reconciled, skipped, commit, unsupported, failed }
337
- ```
338
-
339
- - Tree-sitter parse (TypeScript/JavaScript/TSX/JSX) → the extractor
340
- produces one **file unit** per file (with its import graph in metadata)
341
- and one **chunk unit** per definition (function/class/method/interface/
342
- type/enum/variable, with kind/name/line-range/docstring metadata)
343
- - Every unit embeds + topic-routes (unless `embed: false`)
344
- - AST-derived graph: relative imports resolve to indexed files and
345
- class→method containment become **confirmed `memory_supports` edges**
346
- (`source: "rule"`)
347
- - **Idempotent + upserting**: keys are cwd-anchored + deterministic
348
- (chunk keys drop the line number, so edits ABOVE a definition update
349
- it instead of duplicating it). Re-indexing skips unchanged units
350
- (`skipped`), and changed units are **superseded + rewritten under the
351
- same key** (`updated`) with a confirmed identity `memory_supersedes`
352
- edge — stale copies never accumulate
353
- - **Edges re-form after `forget`**: phase-2 resolves endpoints by logical
354
- key against the store (not just the current run), so a re-created unit
355
- re-connects to its pre-existing dependencies
356
- - **Reconcile + rename detection**: units whose source vanished (deleted
357
- file/definition) are demoted (`reconciled`, `source_removed` — gone
358
- from search; domain-scoped to the walked roots, so `index lib` never
359
- touches other files). A vanished unit whose embedding is a near-duplicate
360
- of a new unit becomes a PENDING `memory_supersedes` (reason rename) —
361
- `graph confirm` completes it. Code units are TTL-exempt
362
- (`ttlSecs: null` — the index owns their lifecycle); `commit` stamps the
363
- repo HEAD into every unit's metadata as `codeVersion`
364
- - **`code_calls` edges**: every call site inside a definition resolves to
365
- the callee's chunk (same file, then imported files) → confirmed edges —
366
- `explain(id, { edgeKinds: ["code_calls"] })` gives one-hop impact
367
- analysis
368
- - Per-file failures are collected, never fatal
369
-
370
- ### `updateEmbedding(id, embedding, modelKey?)` / `embed(text)`
371
-
372
- Attach an externally-computed embedding (completes the `{ embed: false }`
373
- workflow — the memory becomes semantically retrievable + topic-routed), or
374
- embed text with the SDK's own pipeline.
375
-
376
- ### `dream(modelKey)` / `repair(modelKey)`
377
-
378
- Run the consolidation job (concept clustering + LLM edge classification;
379
- requires the `llm` config section) or the graph repair pass. Each call
380
- gets a fresh run id (always executes). Failures are typed:
381
- `Sdk.LLMNotConfigured`, `Sdk.JobFailed`.
382
-
383
- ### `explain({ kind: "memory" | "topic" | "concept", id }, options?)`
384
-
385
- Graph neighborhood traversal — `{ nodes: [{ kind, id, depth, edgeKind }] }`.
386
- `options.edgeKinds` scopes the answer (memory-only navigation: pass the
387
- `memory_*` kinds; `code_calls` shows the call graph).
388
-
389
- ### `health(modelKey)` / `graph.*`
390
-
391
- - `health` — topic-tree invariant sweep (`warnCount` + violation strings)
392
- - `graph.confirm(id)` / `graph.reject(id)` / `graph.expire(id)` — the
393
- pending-edge lifecycle: promote to confirmed (a `memory_supersedes`
394
- confirm demotes the old endpoint in the same transaction), reject, or
395
- soft-delete. `Sdk.EdgeNotFound` for stale ids
396
- - `graph.addEdge({ fromMemoryId, toMemoryId, edgeKind: "memory_supports" |
397
- "memory_supersedes" | "memory_contradicts", source: "rule" | "llm" |
398
- "agent", weight?, confidence?, confirmed? })` — explicit edges; typed
399
- validation (no self-loops, known kinds, weight > 0, confidence ∈ [0,1])
400
- - `graph.candidates()` — pending structural candidates + isolated count
401
- - `graph.coverage(modelKey)` — coverage %, pending, concept count
402
-
403
- ### `reload(input?)` — apply a config change
404
-
405
- Pauses and re-boots with the re-resolved config:
406
-
407
- 1. the config source is re-resolved **typed** — a `dimple.jsonc` path is
408
- re-read from disk, an inline object re-validated (a bad config rejects
409
- the reload with **zero impact** — nothing pauses);
410
- 2. an **identical** config resolves as `{ reloaded: false }` — no pause,
411
- no restart (safe to call from a file watcher);
412
- 3. new calls wait, in-flight calls settle on the old stack;
413
- 4. a fresh stack boots (new worker fleet, embedders, logger — jobs are
414
- durable, so an interrupted handler's lease expires and re-claims after
415
- boot);
416
- 5. the swap: the old stack is disposed, the new one serves.
417
-
418
- Resolves with `{ config, changed, reloaded }` — `changed` flags which
419
- sections actually moved (`storeUrl` / `jobsUrl` / `rest`).
420
-
421
- Works for `{ config: … }`-built dimples (reload with no argument re-reads
422
- the original source). `{ core: … }`-built dimples have no config source
423
- and reject with `Sdk.Reload`. Boot failure leaves the previous system
424
- running (blue-green); a drain that cannot settle in-flight calls (30s)
425
- rejects with `Sdk.Reload` and nothing is torn down.
426
-
427
- **What happens to data when the database URL changes:** the old database
428
- file is preserved on disk but no longer served — nothing is migrated or
429
- merged, the new db is bootstrapped empty (a warning is logged for the
430
- switch; roll back with `reload(oldConfig)`). Pending jobs in a switched
431
- `jobs.dbUrl` stay durable in the old db, re-claimable by switching back
432
- or draining it externally.
433
-
434
- ```ts
435
- await dimple.reload(); // re-read dimple.jsonc
436
- await dimple.reload({ storeUrl: "file:…", … }); // or a fresh config
437
- ```
438
-
439
- ### `dispose()`
440
-
441
- Stops the worker, closes sinks + the store. Idempotent; post-dispose calls
442
- reject with a closed `Sdk.StoreFailure`.
443
-
444
- ## Errors — the closed hierarchy
445
-
446
- Every promise rejects with typed errors from one family (never raw Effect
447
- internals). Export surface: `ConfigError`, `NotFoundError`,
448
- `DuplicateKeyError` (with `logicalKey`/`namespace`), `DuplicateEdgeError`,
449
- `LLMNotConfiguredError`, `JobFailedError`, `StoreFailure`, `InputError`,
450
- plus the `remapError` helper (maps unknown failures onto the hierarchy).
451
- Also importable from the `@ponraaj/dimple-sdk/errors` subpath.
452
-
453
- **Type exports** (all importable from the package root): `Dimple` (the
454
- facade interface), `MakeDimpleOptions`, `ConfigInput`, `QueryOptions`,
455
- `IndexOptions`, `IndexResult`, plus the error classes and the `DimpleError`
456
- union type. Errors also carry stable `_tag` values (`Sdk.DuplicateKey`,
457
- `Sdk.NotFound`, `Sdk.InputError`, `Sdk.JobFailed`, `Sdk.LLMNotConfigured`,
458
- `Sdk.StoreFailure`, …) — the recommended discriminator.
459
-
460
- ## Background jobs (what runs while you work)
461
-
462
- | Job | Trigger | Work |
463
- | ------------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------- |
464
- | `topic.split` / `topic.merge` / `topic.rebalance` | automatic per write | keep every leaf within its size budget (deterministic splits, donor borrow, nearest-sibling merge, uneven-split rebalance) |
465
- | `memory.embed` | model backfill | re-embed a memory with a target model + route into its tree |
466
- | `memory.dream` | `dream()` | concept clustering + LLM edge classification |
467
- | `graph.repair` | `repair()` | graph invariant repair |
468
- | `audit.write` | query path | async audit-log writes (never blocks queries) — one FUSED row per hybrid query with the per-leg breakdown |
469
-
470
- Jobs are durable (leased, retried up to `maxAttempts`, resume-safe across
471
- processes). Every `makeDimple` boots the worker; `dispose` stops it.
472
-
473
- Every `query` also writes a retrieval audit row (query, namespace, mode,
474
- per-leg counts/scores, sources) into `retrieval_log` — machine-readable
475
- query history, persisted asynchronously (never blocks the query).
476
-
477
- ## Storage & logs
73
+ ## Configuration
478
74
 
479
- | What | Where |
480
- | ------------------------------ | -------------------------------------------------------------------------------------------- |
481
- | Memories, tree, graph, audit | `storeUrl` (`file:…`) |
482
- | Durable queue + workflow state | `jobs.dbUrl` (separate DB) |
483
- | Logs | stdout + in-memory ring (4096) by default; durable JSONL **only** when `logging.file` is set |
484
- | Grammar WASMs | `~/.cache/dimple/tree-sitter/` (downloaded once, offline after) |
485
- | ONNX model | bun/npm cache (~90 MB, first use) |
75
+ `makeDimple({ config: "./dimple.jsonc" })` — every section is optional
76
+ except `embedding`. `dimple init` (CLI) writes the full annotated schema;
77
+ `${ENV_VAR}` placeholders keep tokens out of files. The complete reference
78
+ (store / jobs / embeddings / topics / graph / llm / logging / Turso) is on
79
+ the docs site: [Configuration reference](https://dimple.ponraaj.me/reference/config).
486
80
 
487
- ## Changelog
81
+ ## MCP server
488
82
 
489
- Every release creates a GitHub Release with the per-version changes (the
490
- tag-based publish workflow): repo → **Releases** page.
83
+ `dimple mcp` (from the CLI package) exposes dimple to any MCP client over
84
+ the 2026-07-28 protocol — API discovery plus sandboxed code execution with
85
+ the whole API as a `dimple` namespace. Guide:
86
+ [MCP server & code mode](https://dimple.ponraaj.me/guides/mcp).
491
87
 
492
88
  ## Agent integration patterns
493
89
 
494
- These are the shapes that work well when the consumer is another agent
495
- (a harness) rather than a human with a REPL.
496
-
497
- ### Session-start injection
498
-
499
- The guaranteed-usage path: a warmup query returns a compact memory block
500
- the harness injects as context, and the pending-graph review list rides
501
- along:
502
-
503
- ```ts
504
- const [context, pending] = await Promise.all([
505
- dimple.query("ongoing work and decisions", { limit: 8, budget: { maxChars: 4000 } }),
506
- dimple.graph.candidates(),
507
- ]);
508
- // context → inject the top-N content strings into the system prompt
509
- // pending → offer each candidate as a confirm/reject decision
510
- ```
511
-
512
- `graph.candidates()` pending `memory_supersedes` edges from the rename
513
- scanner (rule 0.9x near-duplicates) are the highest-value batch to review.
514
-
515
- ### Supersedes recipe
90
+ Session-start injection, the supersedes recipe, and namespaces as the
91
+ code/message partition are covered on the docs site (Architecture section)
92
+ and in `docs/MCP.md` in the repository.
516
93
 
517
- `addEdge({ … edgeKind: "memory_supersedes", confirmed: true })` performs
518
- the full state flip in one transaction (the old memory demotes
519
- immediately) — it is safe to use directly. For human-gated flows the
520
- pending → confirm pair keeps the old memory ranking until a reviewer
521
- agrees:
522
-
523
- ```ts
524
- const edge = await dimple.graph.addEdge({
525
- fromMemoryId: oldId,
526
- toMemoryId: newId,
527
- edgeKind: "memory_supersedes",
528
- source: "agent",
529
- }); // pending — nothing changed yet
530
- await dimple.graph.confirm(edge.id); // ← the flip happens here
531
- ```
532
-
533
- ### Namespaces as the code/message partition
534
-
535
- Until a query-time content-type filter exists, namespaces give clean
536
- isolation: `index({ paths, namespace: "code" })` and
537
- `write({ …, namespace: "messages" })` partition the store; query with
538
- `namespace: "code"` for code and `namespace: "messages"` for
539
- conversations. (`query({ contentType })` now does the same within one
540
- namespace — the two levers compose.)
541
-
542
- ### Remember
543
-
544
- - `dimple.reload(newConfig)` swaps the whole stack blue-green (validate →
545
- pause → boot → drain → swap); a failed boot leaves the old stack
546
- serving.
547
- - JSON output is compact by default: CLI `--json` omits embeddings (pass
548
- `--embedding` to include them); the SDK always keeps them on
549
- `memory.embedding`.
550
-
551
- ## Notes
94
+ ## Changelog
552
95
 
553
- - Determinism: same data + same config ⇒ same tree, same clusters, same
554
- audit hashes — reproducible across machines and runs
555
- - `migrationFolder` is accepted in `makeDimple` options for advanced users;
556
- the default path uses migrations embedded at build time (nothing to ship)
96
+ [Release history](https://dimple.ponraaj.me/changelog/sdk) · repository
97
+ [`CHANGELOG.md`](./CHANGELOG.md)
package/dist/index.js CHANGED
@@ -508,7 +508,10 @@ var consoleSink = (pretty) => {
508
508
  };
509
509
  };
510
510
  var fileSink = (path) => Effect5.tryPromise(async () => {
511
- const { openSync, closeSync, fsyncSync, writeSync } = await import("node:fs");
511
+ const fs = await import("node:fs");
512
+ const { dirname } = await import("node:path");
513
+ fs.mkdirSync(dirname(path), { recursive: true });
514
+ const { openSync, closeSync, fsyncSync, writeSync } = fs;
512
515
  const fd = openSync(path, "a");
513
516
  let disposed = false;
514
517
  const flush = () => {
@@ -2971,17 +2974,13 @@ var workflowTagFor = (workflows, store, executionId) => Effect17.gen(function* (
2971
2974
  return;
2972
2975
  return workflows.get(record.workflowTag)?.workflow;
2973
2976
  });
2974
- var ensureDbDir = (url) => {
2975
- if (url.startsWith("file:")) {
2976
- import("node:fs").then((fs) => import("node:path").then(({ dirname }) => {
2977
- fs.mkdirSync(dirname(url.slice("file:".length)), { recursive: true });
2978
- }));
2979
- }
2980
- };
2981
- var libsqlStack = (options) => {
2982
- ensureDbDir(options.url);
2983
- return Layer6.mergeAll(PersistenceNamespace2.layerSql, PersistedQueue2.layer.pipe(Layer6.provide(storeSqliteLayer))).pipe(Layer6.provide(exports_LibsqlClient.layer(options)));
2984
- };
2977
+ var ensureDbDir = (url) => url.startsWith("file:") ? import("node:fs").then((fs) => import("node:path").then(({ dirname }) => {
2978
+ fs.mkdirSync(dirname(url.slice("file:".length)), { recursive: true });
2979
+ })) : Promise.resolve();
2980
+ var libsqlStack = (options) => Layer6.unwrap(Effect17.tryPromise({
2981
+ try: () => ensureDbDir(options.url),
2982
+ catch: (cause) => new Error(String(cause))
2983
+ }).pipe(Effect17.catch(() => Effect17.void), Effect17.map(() => Layer6.mergeAll(PersistenceNamespace2.layerSql, PersistedQueue2.layer.pipe(Layer6.provide(storeSqliteLayer))).pipe(Layer6.provide(exports_LibsqlClient.layer(options))))));
2985
2984
  var layerDurableLibsql = (options) => layerDurable.pipe(Layer6.provide(libsqlStack(options)));
2986
2985
 
2987
2986
  // ../workflow/src/runtime.ts
@@ -4734,6 +4733,36 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
4734
4733
  if (!result)
4735
4734
  return yield* new NotFound({ id: key });
4736
4735
  });
4736
+ const decrementTreeCf = (model, membership, x) => Effect26.tryPromise({
4737
+ try: () => db.transaction(async (tx) => {
4738
+ const leafRows = await tx.select({
4739
+ topicKey: topics.topicKey,
4740
+ memberCount: topics.memberCount,
4741
+ sumSquares: topics.sumSquares
4742
+ }).from(topics).where(and8(eq8(topics.topicKey, membership.topicKey), eq8(topics.modelKey, membership.modelKey)));
4743
+ const leaf = leafRows[0];
4744
+ if (!leaf)
4745
+ return;
4746
+ const next = cfRemove({
4747
+ count: Number(leaf.memberCount),
4748
+ ls: new Float32Array(0),
4749
+ ss: Number(leaf.sumSquares)
4750
+ }, x);
4751
+ await tx.update(topics).set({
4752
+ memberCount: Math.max(0, next.count),
4753
+ sumSquares: Math.max(0, next.ss),
4754
+ updatedAt: Date.now()
4755
+ }).where(and8(eq8(topics.topicKey, membership.topicKey), eq8(topics.modelKey, membership.modelKey)));
4756
+ const relRows = await tx.select({ parentKey: topicRelations.parentKey }).from(topicRelations).where(and8(eq8(topicRelations.childKey, membership.topicKey), eq8(topicRelations.modelKey, membership.modelKey), eq8(topicRelations.relationKind, "parent_child")));
4757
+ let ancestorKey = relRows[0]?.parentKey;
4758
+ while (ancestorKey) {
4759
+ await tx.update(topics).set({ memberCount: sql7`max(0, ${topics.memberCount} - 1)`, updatedAt: Date.now() }).where(and8(eq8(topics.topicKey, ancestorKey), eq8(topics.modelKey, membership.modelKey)));
4760
+ const up = await tx.select({ parentKey: topicRelations.parentKey }).from(topicRelations).where(and8(eq8(topicRelations.childKey, ancestorKey), eq8(topicRelations.modelKey, membership.modelKey), eq8(topicRelations.relationKind, "parent_child")));
4761
+ ancestorKey = up[0]?.parentKey;
4762
+ }
4763
+ }),
4764
+ catch: (cause) => new StoreError({ message: `Store: tree CF decrement: ${cause}` })
4765
+ });
4737
4766
  const forget = Effect26.fn("Store.forget")(function* (id) {
4738
4767
  let membership;
4739
4768
  if (topicsEnabled) {
@@ -4747,37 +4776,7 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
4747
4776
  const vectorRows = yield* backend.fetchMemoryVectors([id], modelConfig);
4748
4777
  if (vectorRows.length > 0) {
4749
4778
  const x = new Float32Array(vectorRows[0].vector);
4750
- const now = Date.now();
4751
- yield* Effect26.tryPromise({
4752
- try: () => db.transaction(async (tx) => {
4753
- const leafRows = await tx.select({
4754
- topicKey: topics.topicKey,
4755
- memberCount: topics.memberCount,
4756
- sumSquares: topics.sumSquares
4757
- }).from(topics).where(and8(eq8(topics.topicKey, mem.topicKey), eq8(topics.modelKey, mem.modelKey)));
4758
- const leaf = leafRows[0];
4759
- if (!leaf)
4760
- return;
4761
- const next = cfRemove({
4762
- count: Number(leaf.memberCount),
4763
- ls: new Float32Array(0),
4764
- ss: Number(leaf.sumSquares)
4765
- }, x);
4766
- await tx.update(topics).set({
4767
- memberCount: Math.max(0, next.count),
4768
- sumSquares: Math.max(0, next.ss),
4769
- updatedAt: now
4770
- }).where(and8(eq8(topics.topicKey, mem.topicKey), eq8(topics.modelKey, mem.modelKey)));
4771
- const relRows = await tx.select({ parentKey: topicRelations.parentKey }).from(topicRelations).where(and8(eq8(topicRelations.childKey, mem.topicKey), eq8(topicRelations.modelKey, mem.modelKey), eq8(topicRelations.relationKind, "parent_child")));
4772
- let ancestorKey = relRows[0]?.parentKey;
4773
- while (ancestorKey) {
4774
- await tx.update(topics).set({ memberCount: sql7`max(0, ${topics.memberCount} - 1)`, updatedAt: now }).where(and8(eq8(topics.topicKey, ancestorKey), eq8(topics.modelKey, mem.modelKey)));
4775
- const up = await tx.select({ parentKey: topicRelations.parentKey }).from(topicRelations).where(and8(eq8(topicRelations.childKey, ancestorKey), eq8(topicRelations.modelKey, mem.modelKey), eq8(topicRelations.relationKind, "parent_child")));
4776
- ancestorKey = up[0]?.parentKey;
4777
- }
4778
- }),
4779
- catch: (cause) => new StoreError({ message: `Store.forget: tree CF: ${cause}` })
4780
- });
4779
+ yield* decrementTreeCf(modelConfig, mem, x);
4781
4780
  yield* updateTopicVectorForget(backend, modelConfig, mem.topicKey, x);
4782
4781
  }
4783
4782
  }
@@ -5004,6 +5003,28 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
5004
5003
  return yield* new NotFound({ id });
5005
5004
  if (modelConfig && modelConfig.id === modelName) {
5006
5005
  yield* backend.ensureVectorTable(modelConfig);
5006
+ if (topicsEnabled) {
5007
+ const memberRows = yield* Effect26.tryPromise({
5008
+ try: () => db.select({
5009
+ topicKey: topicMemberships.topicKey,
5010
+ modelKey: topicMemberships.modelKey
5011
+ }).from(topicMemberships).where(eq8(topicMemberships.memoryId, id)),
5012
+ catch: (cause) => new StoreError({ message: `Store.updateEmbedding: load membership: ${cause}` })
5013
+ });
5014
+ const mem = memberRows[0];
5015
+ if (mem && mem.modelKey === modelName) {
5016
+ const oldVec = yield* backend.fetchMemoryVectors([id], modelConfig);
5017
+ if (oldVec.length > 0) {
5018
+ const x = new Float32Array(oldVec[0].vector);
5019
+ yield* decrementTreeCf(modelConfig, mem, x);
5020
+ yield* updateTopicVectorForget(backend, modelConfig, mem.topicKey, x);
5021
+ }
5022
+ yield* Effect26.tryPromise({
5023
+ try: () => db.delete(topicMemberships).where(and8(eq8(topicMemberships.memoryId, id), eq8(topicMemberships.modelKey, modelName))),
5024
+ catch: (cause) => new StoreError({ message: `Store.updateEmbedding: delete membership: ${cause}` })
5025
+ });
5026
+ }
5027
+ }
5007
5028
  yield* backend.insertVector(id, embedding, modelConfig);
5008
5029
  if (topicsEnabled) {
5009
5030
  const routed = yield* routeInsert(id, embedding, modelConfig, topicsConfig).pipe(Effect26.provideService(Service2, backend), Effect26.mapError((cause) => new StoreError({ message: `Store.updateEmbedding topic route: ${cause}` })));
@@ -6856,8 +6877,8 @@ var layer11 = (options) => Layer13.effect(Service10, Effect35.gen(function* () {
6856
6877
  const vWeight = profile.modeWeights?.vector ?? 1;
6857
6878
  const fWeight = profile.modeWeights?.fts ?? 1;
6858
6879
  const mode = vWeight === 0 && fWeight > 0 ? "keyword" : fWeight === 0 && vWeight > 0 ? "semantic" : "hybrid";
6859
- const denseRanks = vectorResults.length > 0 ? vectorResults.map((s) => [s.memory.id, s.score * vWeight]) : null;
6860
- const fts5Ranks = fts5Results.length > 0 ? fts5Results.map((s) => [s.memory.id, s.score * fWeight]) : null;
6880
+ const denseRanks = vWeight > 0 && vectorResults.length > 0 ? vectorResults.map((s) => [s.memory.id, s.score * vWeight]) : null;
6881
+ const fts5Ranks = fWeight > 0 && fts5Results.length > 0 ? fts5Results.map((s) => [s.memory.id, s.score * fWeight]) : null;
6861
6882
  if (!denseRanks && !fts5Ranks) {
6862
6883
  yield* store.logAudit({
6863
6884
  query: query2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponraaj/dimple-sdk",
3
- "version": "0.0.0-alpha.5",
3
+ "version": "0.0.0-alpha.7",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist"