@ponraaj/dimple-sdk 0.0.0-alpha.6 → 0.0.0-alpha.8

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,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,60 @@ 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
+ | `writeMany(units, { embed })` | Bulk ingest: batch insert + batch embed + routing in one pass (skips the per-write candidate scan — quadratic under bulk; run the dream job afterwards for graph edges) |
61
+ | `get(id)` / `forget(id)` | Fetch / remove by id or logical key |
62
+ | `query(text, options)` | Hybrid search — profiles, budgets, content-type filter |
63
+ | `updateEmbedding(id, vec, modelKey?)` | Attach an external embedding |
64
+ | `index({ paths })` | Codebase ingestion (idempotent re-index) |
65
+ | `graph.addEdge/confirm/reject/expire/candidates/coverage` | Memory relationships: supports / supersedes / contradicts |
66
+ | `explain({ kind, id }, options)` | Bounded graph neighborhood |
67
+ | `dream(modelKey?)` / `repair(modelKey?)` | Consolidation + maintenance jobs |
68
+ | `health(modelKey?)` | Topic-tree invariant health |
69
+ | `reload(input?)` | Blue-green config reload |
70
+
71
+ Errors are a closed hierarchy (`InputError`, `NotFound`, `DuplicateKey`,
72
+ `JobFailed`, `LLMNotConfigured`, `StoreFailure`) — see the docs site.
293
73
 
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
74
+ ## Configuration
478
75
 
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) |
76
+ `makeDimple({ config: "./dimple.jsonc" })` — every section is optional
77
+ except `embedding`. `dimple init` (CLI) writes the full annotated schema;
78
+ `${ENV_VAR}` placeholders keep tokens out of files. The complete reference
79
+ (store / jobs / embeddings / topics / graph / llm / logging / Turso) is on
80
+ the docs site: [Configuration reference](https://dimple.ponraaj.me/reference/config).
486
81
 
487
- ## Changelog
82
+ ## MCP server
488
83
 
489
- Every release creates a GitHub Release with the per-version changes (the
490
- tag-based publish workflow): repo → **Releases** page.
84
+ `dimple mcp` (from the CLI package) exposes dimple to any MCP client over
85
+ the 2026-07-28 protocol — API discovery plus sandboxed code execution with
86
+ the whole API as a `dimple` namespace. Guide:
87
+ [MCP server & code mode](https://dimple.ponraaj.me/guides/mcp).
491
88
 
492
89
  ## Agent integration patterns
493
90
 
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
91
+ Session-start injection, the supersedes recipe, and namespaces as the
92
+ code/message partition are covered on the docs site (Architecture section)
93
+ and in `docs/MCP.md` in the repository.
516
94
 
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
95
+ ## Changelog
552
96
 
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)
97
+ [Release history](https://dimple.ponraaj.me/changelog/sdk/) · repository
98
+ [`CHANGELOG.md`](./CHANGELOG.md)
package/dist/dimple.d.ts CHANGED
@@ -117,6 +117,12 @@ export interface Dimple {
117
117
  * `stats` use when no model is passed. */
118
118
  readonly defaultModelId: string;
119
119
  readonly write: (input: WriteMemoryInput, options?: WriteOptions) => Promise<MemoryUnit>;
120
+ /** Bulk write: batch insert + batch embed + topic routing in ONE pass.
121
+ * Skips the per-write graph candidate scan (O(leaf size) per write —
122
+ * quadratic under bulk ingest); callers that need near-duplicate/
123
+ * conflict edges on bulk content should run the dream job afterwards.
124
+ * All-or-nothing: a duplicate logical key rejects the whole batch. */
125
+ readonly writeMany: (units: readonly WriteMemoryInput[], options?: WriteOptions) => Promise<readonly MemoryUnit[]>;
120
126
  readonly get: (id: string) => Promise<MemoryUnit>;
121
127
  readonly query: (text: string, options?: QueryOptions) => Promise<AugmentedMemory[]>;
122
128
  readonly forget: (id: string) => Promise<void>;
package/dist/index.js CHANGED
@@ -766,6 +766,7 @@ import { Effect as Effect7, Schema as Schema7 } from "effect";
766
766
  import { parse, parseTree, printParseErrorCode } from "jsonc-parser";
767
767
  var CONFIG_FILE_NAMES = ["dimple.jsonc", "dimple.json"];
768
768
  var GLOBAL_DIR = ".config/dimple";
769
+ var resolveCwd = () => process.env["DIMPLE_CWD"] ?? process.cwd();
769
770
  var defaults = {
770
771
  storeUrl: "file:./.dimple/dimple.db",
771
772
  defaultK: 60,
@@ -871,7 +872,7 @@ var loadFs = () => Effect7.tryPromise(() => import("node:fs"));
871
872
  var findProjectConfig = () => Effect7.gen(function* () {
872
873
  const fs = yield* loadFs();
873
874
  const path = yield* Effect7.tryPromise(() => import("node:path"));
874
- let dir = process.cwd();
875
+ let dir = resolveCwd();
875
876
  for (let i = 0;i < 20; i++) {
876
877
  for (const name of CONFIG_FILE_NAMES) {
877
878
  const p = path.join(dir, name);
@@ -940,6 +941,23 @@ var resolveEnvVars = (value) => {
940
941
  }
941
942
  return value;
942
943
  };
944
+ var resolveCwdUrls = (value) => {
945
+ const cwd = process.env["DIMPLE_CWD"];
946
+ if (cwd === undefined)
947
+ return value;
948
+ if (typeof value === "string" && value.startsWith("file:./")) {
949
+ return `file:${cwd}${cwd.endsWith("/") ? "" : "/"}${value.slice("file:./".length)}`;
950
+ }
951
+ if (Array.isArray(value))
952
+ return value.map(resolveCwdUrls);
953
+ if (value !== null && typeof value === "object") {
954
+ const result = {};
955
+ for (const [k, v] of Object.entries(value))
956
+ result[k] = resolveCwdUrls(v);
957
+ return result;
958
+ }
959
+ return value;
960
+ };
943
961
  var deepMerge = (base, over) => {
944
962
  const out = { ...base };
945
963
  for (const [key, value] of Object.entries(over)) {
@@ -957,7 +975,7 @@ var loadConfigFromFile = (path) => Effect7.gen(function* () {
957
975
  const parsed = yield* parseJsoncEffect(content, (cause) => new Error(`config parse ${path}: ${cause.message}`));
958
976
  const merged = deepMerge(defaults, parsed);
959
977
  const resolved = resolveEnvVars(merged);
960
- return yield* Schema7.decodeUnknownEffect(DimpleConfig, { errors: "all" })(resolved).pipe(Effect7.mapError((e) => new Error(`config validation: ${e.message}`)));
978
+ return yield* Schema7.decodeUnknownEffect(DimpleConfig, { errors: "all" })(resolveCwdUrls(resolved)).pipe(Effect7.mapError((e) => new Error(`config validation: ${e.message}`)));
961
979
  });
962
980
  var loadConfig = (overrides) => Effect7.gen(function* () {
963
981
  const fileParts = yield* loadFromFiles();
@@ -970,7 +988,7 @@ var loadConfig = (overrides) => Effect7.gen(function* () {
970
988
  if (overrides)
971
989
  merged = deepMerge(merged, overrides);
972
990
  const resolved = resolveEnvVars(merged);
973
- return yield* Schema7.decodeUnknownEffect(DimpleConfig, { errors: "all" })(resolved).pipe(Effect7.mapError((e) => new Error(`config validation: ${e.message}`)));
991
+ return yield* Schema7.decodeUnknownEffect(DimpleConfig, { errors: "all" })(resolveCwdUrls(resolved)).pipe(Effect7.mapError((e) => new Error(`config validation: ${e.message}`)));
974
992
  });
975
993
 
976
994
  // ../config/src/service.ts
@@ -1236,7 +1254,7 @@ var makeBackend = (url, client, db, migrationFolder) => ({
1236
1254
  ORDER BY f.rank
1237
1255
  LIMIT ?`,
1238
1256
  args: [
1239
- `"${query.replaceAll('"', '""')}"`,
1257
+ query.split(/\s+/).filter((token) => token.length > 0).map((token) => `"${token.replaceAll('"', '""')}"`).join(" "),
1240
1258
  namespace,
1241
1259
  ...topicKeys && topicKeys.length > 0 ? [...topicKeys, modelKey ?? ""] : [],
1242
1260
  ...typeof contentType === "string" ? [contentType] : contentType ?? [],
@@ -4610,6 +4628,58 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
4610
4628
  embeddingModel: undefined
4611
4629
  };
4612
4630
  });
4631
+ const writeMany = Effect26.fn("Store.writeMany")(function* (inputs) {
4632
+ if (inputs.length === 0)
4633
+ return [];
4634
+ for (const input of inputs) {
4635
+ if (input.content.includes("\x00")) {
4636
+ return yield* new StoreError({
4637
+ message: "Store.writeMany: content contains a NUL byte (libSQL would silently truncate it)"
4638
+ });
4639
+ }
4640
+ }
4641
+ const now = Date.now();
4642
+ const rows = inputs.map((input) => {
4643
+ const metadataBlob = input.typedMetadata !== undefined ? mergeMetadataBlob(input.metadata, input.typedMetadata) : input.metadata ?? null;
4644
+ return {
4645
+ id: crypto.randomUUID(),
4646
+ logicalKey: input.logicalKey,
4647
+ content: input.content,
4648
+ contentType: input.contentType ?? "text/plain",
4649
+ createdAt: now,
4650
+ ttlSecs: input.ttlSecs === null ? null : input.ttlSecs ?? defaultTtlSecs,
4651
+ namespace: input.namespace ?? "default",
4652
+ metadata: metadataBlob
4653
+ };
4654
+ });
4655
+ yield* Effect26.tryPromise({
4656
+ try: () => db.transaction(async (tx) => {
4657
+ await tx.insert(memories2).values(rows);
4658
+ await tx.insert(memoryEvents2).values(rows.map((row) => ({
4659
+ memoryId: row.id,
4660
+ eventType: "created",
4661
+ createdAt: now
4662
+ })));
4663
+ }),
4664
+ catch: (cause) => backend.isUniqueViolation(cause) ? new DuplicateActiveKey({
4665
+ logicalKey: inputs[0].logicalKey,
4666
+ namespace: inputs[0].namespace ?? "default"
4667
+ }) : new StoreError({ message: `Store.writeMany: ${cause}` })
4668
+ });
4669
+ return rows.map((row) => ({
4670
+ id: row.id,
4671
+ logicalKey: row.logicalKey,
4672
+ content: row.content,
4673
+ contentType: row.contentType,
4674
+ state: "active",
4675
+ createdAt: row.createdAt,
4676
+ ttlSecs: row.ttlSecs,
4677
+ namespace: row.namespace,
4678
+ metadata: row.metadata ?? undefined,
4679
+ embedding: undefined,
4680
+ embeddingModel: undefined
4681
+ }));
4682
+ });
4613
4683
  const get = Effect26.fn("Store.get")(function* (id) {
4614
4684
  const rows = yield* Effect26.tryPromise({
4615
4685
  try: () => db.select(memoryFields).from(memories2).where(eq8(memories2.id, id)).limit(1),
@@ -4733,6 +4803,36 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
4733
4803
  if (!result)
4734
4804
  return yield* new NotFound({ id: key });
4735
4805
  });
4806
+ const decrementTreeCf = (model, membership, x) => Effect26.tryPromise({
4807
+ try: () => db.transaction(async (tx) => {
4808
+ const leafRows = await tx.select({
4809
+ topicKey: topics.topicKey,
4810
+ memberCount: topics.memberCount,
4811
+ sumSquares: topics.sumSquares
4812
+ }).from(topics).where(and8(eq8(topics.topicKey, membership.topicKey), eq8(topics.modelKey, membership.modelKey)));
4813
+ const leaf = leafRows[0];
4814
+ if (!leaf)
4815
+ return;
4816
+ const next = cfRemove({
4817
+ count: Number(leaf.memberCount),
4818
+ ls: new Float32Array(0),
4819
+ ss: Number(leaf.sumSquares)
4820
+ }, x);
4821
+ await tx.update(topics).set({
4822
+ memberCount: Math.max(0, next.count),
4823
+ sumSquares: Math.max(0, next.ss),
4824
+ updatedAt: Date.now()
4825
+ }).where(and8(eq8(topics.topicKey, membership.topicKey), eq8(topics.modelKey, membership.modelKey)));
4826
+ 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")));
4827
+ let ancestorKey = relRows[0]?.parentKey;
4828
+ while (ancestorKey) {
4829
+ 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)));
4830
+ 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")));
4831
+ ancestorKey = up[0]?.parentKey;
4832
+ }
4833
+ }),
4834
+ catch: (cause) => new StoreError({ message: `Store: tree CF decrement: ${cause}` })
4835
+ });
4736
4836
  const forget = Effect26.fn("Store.forget")(function* (id) {
4737
4837
  let membership;
4738
4838
  if (topicsEnabled) {
@@ -4746,37 +4846,7 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
4746
4846
  const vectorRows = yield* backend.fetchMemoryVectors([id], modelConfig);
4747
4847
  if (vectorRows.length > 0) {
4748
4848
  const x = new Float32Array(vectorRows[0].vector);
4749
- const now = Date.now();
4750
- yield* Effect26.tryPromise({
4751
- try: () => db.transaction(async (tx) => {
4752
- const leafRows = await tx.select({
4753
- topicKey: topics.topicKey,
4754
- memberCount: topics.memberCount,
4755
- sumSquares: topics.sumSquares
4756
- }).from(topics).where(and8(eq8(topics.topicKey, mem.topicKey), eq8(topics.modelKey, mem.modelKey)));
4757
- const leaf = leafRows[0];
4758
- if (!leaf)
4759
- return;
4760
- const next = cfRemove({
4761
- count: Number(leaf.memberCount),
4762
- ls: new Float32Array(0),
4763
- ss: Number(leaf.sumSquares)
4764
- }, x);
4765
- await tx.update(topics).set({
4766
- memberCount: Math.max(0, next.count),
4767
- sumSquares: Math.max(0, next.ss),
4768
- updatedAt: now
4769
- }).where(and8(eq8(topics.topicKey, mem.topicKey), eq8(topics.modelKey, mem.modelKey)));
4770
- 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")));
4771
- let ancestorKey = relRows[0]?.parentKey;
4772
- while (ancestorKey) {
4773
- 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)));
4774
- 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")));
4775
- ancestorKey = up[0]?.parentKey;
4776
- }
4777
- }),
4778
- catch: (cause) => new StoreError({ message: `Store.forget: tree CF: ${cause}` })
4779
- });
4849
+ yield* decrementTreeCf(modelConfig, mem, x);
4780
4850
  yield* updateTopicVectorForget(backend, modelConfig, mem.topicKey, x);
4781
4851
  }
4782
4852
  }
@@ -4972,7 +5042,7 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
4972
5042
  }
4973
5043
  return yield* topicHealth(modelKey, topicsConfig).pipe(Effect26.provideService(Service2, backend));
4974
5044
  });
4975
- const updateEmbedding = Effect26.fn("Store.updateEmbedding")(function* (id, embedding, modelName) {
5045
+ const updateEmbedding = Effect26.fn("Store.updateEmbedding")(function* (id, embedding, modelName, options) {
4976
5046
  const now = Date.now();
4977
5047
  const affected = yield* Effect26.tryPromise({
4978
5048
  try: () => db.transaction(async (tx) => {
@@ -5003,10 +5073,34 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
5003
5073
  return yield* new NotFound({ id });
5004
5074
  if (modelConfig && modelConfig.id === modelName) {
5005
5075
  yield* backend.ensureVectorTable(modelConfig);
5076
+ if (topicsEnabled) {
5077
+ const memberRows = yield* Effect26.tryPromise({
5078
+ try: () => db.select({
5079
+ topicKey: topicMemberships.topicKey,
5080
+ modelKey: topicMemberships.modelKey
5081
+ }).from(topicMemberships).where(eq8(topicMemberships.memoryId, id)),
5082
+ catch: (cause) => new StoreError({ message: `Store.updateEmbedding: load membership: ${cause}` })
5083
+ });
5084
+ const mem = memberRows[0];
5085
+ if (mem && mem.modelKey === modelName) {
5086
+ const oldVec = yield* backend.fetchMemoryVectors([id], modelConfig);
5087
+ if (oldVec.length > 0) {
5088
+ const x = new Float32Array(oldVec[0].vector);
5089
+ yield* decrementTreeCf(modelConfig, mem, x);
5090
+ yield* updateTopicVectorForget(backend, modelConfig, mem.topicKey, x);
5091
+ }
5092
+ yield* Effect26.tryPromise({
5093
+ try: () => db.delete(topicMemberships).where(and8(eq8(topicMemberships.memoryId, id), eq8(topicMemberships.modelKey, modelName))),
5094
+ catch: (cause) => new StoreError({ message: `Store.updateEmbedding: delete membership: ${cause}` })
5095
+ });
5096
+ }
5097
+ }
5006
5098
  yield* backend.insertVector(id, embedding, modelConfig);
5007
5099
  if (topicsEnabled) {
5008
5100
  const routed = yield* routeInsert(id, embedding, modelConfig, topicsConfig).pipe(Effect26.provideService(Service2, backend), Effect26.mapError((cause) => new StoreError({ message: `Store.updateEmbedding topic route: ${cause}` })));
5009
- yield* scanLeafCandidates(id, embedding, routed.topicKey, modelConfig);
5101
+ if (options?.scanCandidates !== false) {
5102
+ yield* scanLeafCandidates(id, embedding, routed.topicKey, modelConfig);
5103
+ }
5010
5104
  if (routed.overflow) {
5011
5105
  const enqueueSplit = topicsOptions?.enqueueSplit;
5012
5106
  if (enqueueSplit) {
@@ -5566,6 +5660,7 @@ var makeStore2 = (backend, modelConfig, topicsOptions, graphConfig, auditOptions
5566
5660
  });
5567
5661
  return {
5568
5662
  write,
5663
+ writeMany,
5569
5664
  writeSuperseding,
5570
5665
  reconcile,
5571
5666
  reconcileRenameCandidates,
@@ -6531,10 +6626,16 @@ import { existsSync as existsSync2, readFileSync } from "node:fs";
6531
6626
  import { join as join2 } from "node:path";
6532
6627
  import { pathToFileURL } from "node:url";
6533
6628
 
6534
- // ../embedder/src/auto-install.ts
6535
- import { execFile } from "node:child_process";
6629
+ // ../installer/src/index.ts
6536
6630
  import { existsSync } from "node:fs";
6537
6631
  import { join } from "node:path";
6632
+
6633
+ class InstallerError extends Error {
6634
+ constructor(message) {
6635
+ super(message);
6636
+ this.name = "InstallerError";
6637
+ }
6638
+ }
6538
6639
  var detectPackageManager = (cwd) => {
6539
6640
  let dir = cwd;
6540
6641
  while (true) {
@@ -6549,38 +6650,74 @@ var detectPackageManager = (cwd) => {
6549
6650
  return { pm: "npm", root: dir };
6550
6651
  const parent = join(dir, "..");
6551
6652
  if (parent === dir)
6552
- return { pm: "bun", root: cwd };
6653
+ return { pm: "npm", root: cwd };
6553
6654
  dir = parent;
6554
6655
  }
6555
6656
  };
6556
- var installCommand = (pm) => {
6557
- switch (pm) {
6558
- case "bun":
6559
- return ["bun", "add", "@huggingface/transformers"];
6560
- case "npm":
6561
- return [
6562
- "npm",
6563
- "install",
6564
- "@huggingface/transformers",
6565
- "--ignore-scripts",
6566
- "--no-audit",
6567
- "--no-fund"
6568
- ];
6569
- case "pnpm":
6570
- return ["pnpm", "add", "@huggingface/transformers", "--ignore-scripts"];
6571
- case "yarn":
6572
- return ["yarn", "add", "@huggingface/transformers", "--ignore-scripts"];
6573
- }
6657
+ var resolveExecutable = (pm, platform = process.platform) => platform === "win32" && pm !== "bun" ? `${pm}.cmd` : pm;
6658
+ var installArgs = (pm, packages, root) => {
6659
+ const argv = [pm === "npm" ? "install" : "add", ...packages];
6660
+ if (pm !== "bun")
6661
+ argv.push("--ignore-scripts");
6662
+ if (pm === "npm")
6663
+ argv.push("--no-audit", "--no-fund");
6664
+ if (pm === "pnpm" && existsSync(join(root, "pnpm-workspace.yaml")))
6665
+ argv.push("-w");
6666
+ return argv;
6574
6667
  };
6575
- var installTransformers = (cwd) => new Promise((resolve, reject) => {
6668
+ var installOnce = async (target, packages, opts) => {
6669
+ const { spawn } = await import("node:child_process");
6670
+ return new Promise((resolve, reject) => {
6671
+ const timeoutMs = opts.timeoutMs ?? 300000;
6672
+ const cmd = resolveExecutable(target.pm, opts.platform);
6673
+ const args = installArgs(target.pm, packages, target.root);
6674
+ const manual = `${cmd} ${args.join(" ")}`;
6675
+ const child = spawn(cmd, args, { cwd: target.root, stdio: "ignore" });
6676
+ let settled = false;
6677
+ const timer = setTimeout(() => {
6678
+ child.kill("SIGKILL");
6679
+ if (settled)
6680
+ return;
6681
+ settled = true;
6682
+ reject(new InstallerError(`auto-install timed out after ${timeoutMs} ms — the child was killed; run it manually: ${manual}`));
6683
+ }, timeoutMs);
6684
+ child.on("error", (cause) => {
6685
+ clearTimeout(timer);
6686
+ if (settled)
6687
+ return;
6688
+ settled = true;
6689
+ reject(new InstallerError(`auto-install failed to start (${manual}): ${cause.message} — run it manually`));
6690
+ });
6691
+ child.on("exit", (code, signal) => {
6692
+ clearTimeout(timer);
6693
+ if (settled)
6694
+ return;
6695
+ settled = true;
6696
+ if (code === 0) {
6697
+ resolve();
6698
+ return;
6699
+ }
6700
+ reject(new InstallerError(`auto-install failed (${manual} exited ${signal !== null ? `with ${signal}` : `code ${code ?? "?"}`}) — run it manually`));
6701
+ });
6702
+ });
6703
+ };
6704
+ var inflight = new Map;
6705
+ var installOptionalPeers = (packages, opts) => {
6706
+ const cwd = opts?.cwd ?? process.cwd();
6576
6707
  const target = detectPackageManager(cwd);
6577
- const [cmd, ...args] = installCommand(target.pm);
6578
- execFile(cmd, args, { cwd: target.root, encoding: "utf8" }, (error) => {
6579
- if (error) {
6580
- reject(new Error(`auto-install of @huggingface/transformers failed (${target.pm} add …): ${error.message} — ` + "run it manually or set `autoInstall: false` and configure a hosted embedding provider"));
6581
- return;
6582
- }
6583
- resolve();
6708
+ const key = `${target.root}\x00${packages.join(",")}`;
6709
+ const existing = inflight.get(key);
6710
+ if (existing !== undefined)
6711
+ return existing;
6712
+ const run = installOnce(target, packages, opts ?? {});
6713
+ inflight.set(key, run);
6714
+ run.then(() => inflight.delete(key), () => inflight.delete(key));
6715
+ return run;
6716
+ };
6717
+ // ../embedder/src/auto-install.ts
6718
+ var installTransformers = (cwd) => installOptionalPeers(["@huggingface/transformers"], { cwd, timeoutMs: 300000 }).catch((cause) => {
6719
+ throw new EmbeddingError({
6720
+ message: cause instanceof InstallerError ? `${cause.message} — or set \`autoInstall: false\` and configure a hosted embedding provider` : `auto-install of @huggingface/transformers failed: ${String(cause)} — run it manually ` + "or set `autoInstall: false` and configure a hosted embedding provider"
6584
6721
  });
6585
6722
  });
6586
6723
 
@@ -6645,9 +6782,12 @@ var make5 = (options) => {
6645
6782
  }
6646
6783
  return Effect32.gen(function* () {
6647
6784
  yield* Effect32.logInfo("auto-installing @huggingface/transformers (first local-embed use — scripts skipped)").pipe(Effect32.annotateLogs({ scope: "@dimple/embedder", event: "embedder.auto-install" }));
6648
- yield* Effect32.tryPromise(() => installTransformers(process.cwd())).pipe(Effect32.timeout("5 minutes"), Effect32.catch((cause) => Effect32.fail(new EmbeddingError({
6649
- message: cause._tag === "TimeoutError" ? "auto-install of @huggingface/transformers timed out (5 minutes) — run " + "`bun add @huggingface/transformers` manually" : cause.cause instanceof Error ? cause.cause.message : cause instanceof Error ? cause.message : `auto-install failed: ${String(cause)}`
6650
- }))));
6785
+ yield* Effect32.tryPromise(() => installTransformers(process.cwd())).pipe(Effect32.catch((cause) => {
6786
+ const inner = cause.cause;
6787
+ return Effect32.fail(inner instanceof EmbeddingError ? inner : new EmbeddingError({
6788
+ message: `auto-install failed: ${inner instanceof Error ? inner.message : String(cause)}`
6789
+ }));
6790
+ }));
6651
6791
  return yield* Effect32.tryPromise(importFromDisk).pipe(Effect32.mapError(() => new EmbeddingError({
6652
6792
  message: "@huggingface/transformers still unavailable after auto-install — run `bun add " + "@huggingface/transformers` manually (or set `autoInstall: false` and configure a hosted embedding provider)"
6653
6793
  })));
@@ -6855,8 +6995,8 @@ var layer11 = (options) => Layer13.effect(Service10, Effect35.gen(function* () {
6855
6995
  const vWeight = profile.modeWeights?.vector ?? 1;
6856
6996
  const fWeight = profile.modeWeights?.fts ?? 1;
6857
6997
  const mode = vWeight === 0 && fWeight > 0 ? "keyword" : fWeight === 0 && vWeight > 0 ? "semantic" : "hybrid";
6858
- const denseRanks = vectorResults.length > 0 ? vectorResults.map((s) => [s.memory.id, s.score * vWeight]) : null;
6859
- const fts5Ranks = fts5Results.length > 0 ? fts5Results.map((s) => [s.memory.id, s.score * fWeight]) : null;
6998
+ const denseRanks = vWeight > 0 && vectorResults.length > 0 ? vectorResults.map((s) => [s.memory.id, s.score * vWeight]) : null;
6999
+ const fts5Ranks = fWeight > 0 && fts5Results.length > 0 ? fts5Results.map((s) => [s.memory.id, s.score * fWeight]) : null;
6860
7000
  if (!denseRanks && !fts5Ranks) {
6861
7001
  yield* store.logAudit({
6862
7002
  query: query2,
@@ -8395,8 +8535,8 @@ var walkFiles = (paths2, extensions) => {
8395
8535
  const files = [];
8396
8536
  const errors3 = [];
8397
8537
  let unsupported = 0;
8398
- const resolved = paths2.map((p) => resolve(p));
8399
- const root = commonRoot([...resolved, process.cwd()]);
8538
+ const resolved = paths2.map((p) => resolve(process.env["DIMPLE_CWD"] ?? process.cwd(), p));
8539
+ const root = commonRoot([...resolved, process.env["DIMPLE_CWD"] ?? process.cwd()]);
8400
8540
  const visit = (path2) => {
8401
8541
  let stat;
8402
8542
  try {
@@ -8789,6 +8929,30 @@ var makeFacade = (state, reload, dispose) => {
8789
8929
  return yield* invalid;
8790
8930
  return yield* writeUnit(input, options?.embed !== false);
8791
8931
  })),
8932
+ writeMany: (units, options) => run(Effect49.gen(function* () {
8933
+ if (!Array.isArray(units)) {
8934
+ return yield* new InputError({ message: "writeMany: units must be an array" });
8935
+ }
8936
+ for (const input of units) {
8937
+ const invalid = validateWriteInput(input);
8938
+ if (invalid !== undefined)
8939
+ return yield* invalid;
8940
+ }
8941
+ const mems = yield* core().store.writeMany(units);
8942
+ if (options?.embed !== false) {
8943
+ yield* core().store.ensureTopicTree(core().defaultModel.id);
8944
+ const vectors = yield* core().defaultEmbedder.embed(mems.map((m) => m.content));
8945
+ for (let i = 0;i < mems.length; i++) {
8946
+ yield* core().store.updateEmbedding(mems[i].id, [...vectors[i]], core().defaultModel.id, {
8947
+ scanCandidates: false
8948
+ });
8949
+ }
8950
+ return yield* Effect49.forEach(mems, (mem) => core().store.get(mem.id), {
8951
+ discard: false
8952
+ });
8953
+ }
8954
+ return mems;
8955
+ })),
8792
8956
  get: (id) => run(resolveMemory(id)),
8793
8957
  query: runQuery,
8794
8958
  forget: (id) => run(resolveMemory(id).pipe(Effect49.flatMap((memory) => core().store.forget(memory.id)))),
@@ -9089,7 +9253,7 @@ var makeDimple = async (options) => {
9089
9253
  let disposed = false;
9090
9254
  let gate = Promise.resolve();
9091
9255
  let releaseGate;
9092
- let inflight = 0;
9256
+ let inflight2 = 0;
9093
9257
  let drainWaiters = [];
9094
9258
  let reloadChain = Promise.resolve();
9095
9259
  const run = (effect) => {
@@ -9101,12 +9265,12 @@ var makeDimple = async (options) => {
9101
9265
  return Promise.reject(new StoreFailure({ message: "SDK disposed" }));
9102
9266
  }
9103
9267
  const stack = current;
9104
- inflight += 1;
9268
+ inflight2 += 1;
9105
9269
  return stack.runPromise(effect.pipe(Effect50.mapError(remapError))).catch((cause) => {
9106
9270
  return Promise.reject(remapError(cause));
9107
9271
  }).finally(() => {
9108
- inflight -= 1;
9109
- if (inflight === 0 && drainWaiters.length > 0) {
9272
+ inflight2 -= 1;
9273
+ if (inflight2 === 0 && drainWaiters.length > 0) {
9110
9274
  const waiters = drainWaiters;
9111
9275
  drainWaiters = [];
9112
9276
  for (const wake of waiters)
@@ -9127,7 +9291,7 @@ var makeDimple = async (options) => {
9127
9291
  release?.();
9128
9292
  };
9129
9293
  const drain = () => {
9130
- if (inflight === 0)
9294
+ if (inflight2 === 0)
9131
9295
  return Promise.resolve();
9132
9296
  return new Promise((resolve2) => {
9133
9297
  drainWaiters.push(resolve2);
@@ -1,35 +1,10 @@
1
- /**
2
- * First-use auto-install of the OPTIONAL `@huggingface/transformers` peer.
3
- *
4
- * `@huggingface/transformers` is an optional peer (its tree pulls the
5
- * `sharp` + `onnxruntime-node` postinstall scripts that abort npm 11
6
- * installs). When the config declares a `transformers` provider model,
7
- * the intent is explicit — so the FIRST local-embed use installs the
8
- * package automatically with the project's own package manager, instead
9
- * of failing with a manual step.
10
- *
11
- * Safety notes:
12
- * - The install is scoped to the detected project root (the manifest's
13
- * directory) and uses the manager the project already uses.
14
- * - EVERY install runs `--ignore-scripts` (npm/pnpm/yarn) or bun's
15
- * default script-blocking — verified harmless: the onnxruntime-node
16
- * binaries ship in the package; its postinstall is not required for
17
- * embedding, and sharp is never loaded for text embeddings.
18
- * - Failure (offline, restricted env) is a typed EmbeddingError with the
19
- * manual command — never a crash.
20
- */
21
- export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
22
- export interface InstallTarget {
23
- readonly pm: PackageManager;
24
- readonly root: string;
25
- }
26
- /** Detect the project's package manager by walking UP from `cwd` for a
27
- * lockfile (bun.lock/bun.lockb → bun, package-lock.json → npm,
28
- * pnpm-lock.yaml → pnpm, yarn.lock → yarn). Falls back to bun + cwd. */
29
- export declare const detectPackageManager: (cwd: string) => InstallTarget;
30
- /** The install argv for a manager. `--ignore-scripts` on npm/pnpm/yarn:
31
- * the blocked postinstalls are proven unnecessary for embedding (bun
32
- * blocks them by default already). */
1
+ import { type PackageManager } from "./internal/installer/index.js";
2
+ export type { InstallTarget, PackageManager } from "./internal/installer/index.js";
3
+ export { detectPackageManager } from "./internal/installer/index.js";
4
+ /** The install argv for a manager with the transformers package (kept for
5
+ * callers/tests that assert the shape). */
33
6
  export declare const installCommand: (pm: PackageManager) => readonly [string, ...string[]];
34
- /** Run the install for the project at `cwd`; rejects on non-zero exit. */
7
+ /** Install transformers at the project root detected from `cwd`. The
8
+ * shared installer kills the child on timeout and dedups concurrent
9
+ * first-use installs; failures reject with the manual command. */
35
10
  export declare const installTransformers: (cwd: string) => Promise<void>;
@@ -0,0 +1,26 @@
1
+ export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
2
+ export interface InstallTarget {
3
+ readonly pm: PackageManager;
4
+ readonly root: string;
5
+ }
6
+ export declare class InstallerError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** Detect the project's package manager by walking UP from `cwd` for a
10
+ * lockfile. NO lockfile → npm (the most universal — a bun fallback broke
11
+ * Node-only machines with ENOENT). */
12
+ export declare const detectPackageManager: (cwd: string) => InstallTarget;
13
+ /** The executable name for a manager. win32 ships `.cmd` shims for
14
+ * npm/pnpm/yarn — the bare names fail with ENOENT. */
15
+ export declare const resolveExecutable: (pm: PackageManager, platform?: NodeJS.Platform) => string;
16
+ /** The base install argv for a manager. Scripts are skipped everywhere;
17
+ * pnpm at a workspace root additionally gets `-w`. */
18
+ export declare const installArgs: (pm: PackageManager, packages: readonly string[], root: string) => string[];
19
+ export interface InstallOptions {
20
+ readonly cwd?: string;
21
+ /** Hard deadline; the child is SIGKILLed when it trips (default 5 min). */
22
+ readonly timeoutMs?: number;
23
+ /** Injectable for tests (defaults to process.platform). */
24
+ readonly platform?: NodeJS.Platform;
25
+ }
26
+ export declare const installOptionalPeers: (packages: readonly string[], opts?: InstallOptions) => Promise<void>;
@@ -65,6 +65,11 @@ export interface ExplainResult {
65
65
  }
66
66
  export interface Interface {
67
67
  readonly write: (input: WriteMemoryInput) => Effect.Effect<MemoryUnit, StoreError | DuplicateActiveKey>;
68
+ /** Bulk write: batch insert in ONE transaction (all-or-nothing; a
69
+ * duplicate logical key fails the whole batch with DuplicateActiveKey).
70
+ * The per-write graph candidate scan is NOT part of this — pair with
71
+ * `updateEmbedding(..., { scanCandidates: false })` for bulk ingest. */
72
+ readonly writeMany: (inputs: readonly WriteMemoryInput[]) => Effect.Effect<readonly MemoryUnit[], StoreError | DuplicateActiveKey>;
68
73
  /** Upsert for the index path — the identity rule (same logical key ⇒
69
74
  * confirmed `memory_supersedes`) made reachable for ACTIVE keys: demote
70
75
  * the active holder of the key (if any), write the new unit, and link
@@ -139,7 +144,9 @@ export interface Interface {
139
144
  readonly forget: (id: string) => Effect.Effect<void, NotFound | StoreError>;
140
145
  readonly sweep: (namespace: string) => Effect.Effect<SweepResult, StoreError>;
141
146
  readonly events: (memoryId: string) => Effect.Effect<readonly MemoryEvent[], StoreError>;
142
- readonly updateEmbedding: (id: string, embedding: ReadonlyArray<number>, model: string) => Effect.Effect<void, NotFound | StoreError>;
147
+ readonly updateEmbedding: (id: string, embedding: ReadonlyArray<number>, model: string, options?: {
148
+ readonly scanCandidates?: boolean;
149
+ }) => Effect.Effect<void, NotFound | StoreError>;
143
150
  readonly findStale: (model: string) => Effect.Effect<readonly string[], StoreError>;
144
151
  readonly createConcept: (name: string, embedding: ReadonlyArray<number>) => Effect.Effect<Concept, StoreError>;
145
152
  readonly getConcept: (id: string) => Effect.Effect<Concept, NotFound | StoreError>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ponraaj/dimple-sdk",
3
- "version": "0.0.0-alpha.6",
3
+ "version": "0.0.0-alpha.8",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist"