plugmem 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +136 -46
  2. package/index.d.ts +131 -22
  3. package/index.js +2 -1
  4. package/package.json +7 -7
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # plugmem
2
2
 
3
- > ⚠️ Experimental. plugmem is mostly an AI-built experiment written with
3
+ > ⚠️ Experimental. plugmem is mostly an AI-built experiment, written with
4
4
  > the help of a small local model (Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf) and various
5
5
  > Claude models, in roughly equal measure. Expect non-professional design
6
6
  > choices, rough edges, broken behavior, or mistakes. Use it at your own risk.
@@ -9,11 +9,11 @@ An embeddable bitemporal memory database for local-first applications and
9
9
  agents, embedded in your Node process. It stores short facts and answers a
10
10
  query with ranked facts and edges plus an optional bounded rendered block.
11
11
 
12
- File-backed on disk, no server, no daemon. It links into your process the way
13
- SQLite does: the engine is [`plugmem-host`](https://docs.rs/plugmem-host/latest)
14
- compiled to a native addon through [napi-rs](https://napi.rs), so there is no
15
- WebAssembly copy of the file in RAM and no 4 GiB ceiling. Runs on Node, Deno and
16
- Bun.
12
+ File-backed on disk, no server, no daemon. The
13
+ [`plugmem-host`](https://docs.rs/plugmem-host/latest) engine is compiled to a
14
+ native addon through [napi-rs](https://napi.rs) and linked directly into the
15
+ process, so there is no WebAssembly copy of the file in RAM and no 4 GiB
16
+ ceiling. Runs on Node, Deno and Bun.
17
17
 
18
18
  **Contents:** [Install](#install) · [Quick start](#quick-start) ·
19
19
  [What it stores](#what-it-stores) · [Two clocks](#two-clocks) ·
@@ -47,11 +47,15 @@ const res = await db.recall({ query: "tokio", k: 5 });
47
47
  console.log(res.rendered); // paste this into the prompt
48
48
  // - [f0] user: the user prefers tokio (2026-08; active) #pref
49
49
 
50
+ const tags = await db.listTags({ prefix: "pre", limit: 64 });
51
+ console.log(tags.items); // [{ name: "pref", count: 1 }]
52
+ // await db.removeTag("pref"); // global: revises every current fact carrying it
53
+
50
54
  db.close();
51
55
  ```
52
56
 
53
- The query is `"tokio"` and not `"which runtime?"` for a reason worth knowing up
54
- front: with no embedder configured, recall matches on **words**, and "runtime"
57
+ The query is `"tokio"` rather than `"which runtime?"` because recall matches on
58
+ **words** when no embedder is configured, and "runtime"
55
59
  appears nowhere in that fact, so the more natural question returns nothing.
56
60
  Reach it through the graph instead with `entities: ["user"]`, or configure an
57
61
  [embedder](#configuration-and-embeddings) and the meaning matches too. Only one
@@ -116,16 +120,16 @@ the second query has something to answer with.
116
120
 
117
121
  `asOf` moves **both** clocks: a fact answers only if it was valid at that
118
122
  instant *and* had already been recorded by then. The second half is the one
119
- people trip over an `asOf` earlier than a fact's `recordedAt` sees nothing,
120
- because the memory genuinely knew nothing then. Answering with today's knowledge
123
+ people trip over: an `asOf` earlier than a fact's `recordedAt` sees nothing,
124
+ because the memory had not recorded the fact yet. Answering with today's knowledge
121
125
  would be the wrong answer to "what did I hold".
122
126
 
123
127
  `validFrom` is the other half: a statement that became true before you heard of
124
128
  it. Recording today that someone moved a week ago closes the previous interval a
125
- week ago rather than now, so a query as of three days back finds **neither** —
126
- the old fact had stopped being true, and the new one was not yet known. That is
127
- not a hole in the model; it is the honest answer for that instant, and it is
128
- what a single timestamp cannot express.
129
+ week ago rather than now, so a query as of three days back finds **neither**.
130
+ The old fact had stopped being true, and the new one was not yet known. That
131
+ result follows directly from the two clocks; a single timestamp cannot express
132
+ it.
129
133
 
130
134
  Two more queries over the same axes:
131
135
 
@@ -176,18 +180,45 @@ res.edges; // { src, rel, dst, provenance }[] — what the graph walked
176
180
  res.truncated; // true if selection stopped at k or the budget with more left
177
181
  ```
178
182
 
179
- `remember` returns the new fact's id **plus any live facts it may duplicate or
180
- contradict**. The engine never merges or deletes on its own it surfaces the
181
- tension and your code decides:
183
+ `remember` stores the new fact and returns its id **plus any live facts it may
184
+ duplicate or contradict**. If a preflight must not write, use
185
+ `rememberGuarded`: the database holds one write scope across its similarity
186
+ check and conditional insertion, so concurrent preflights cannot both pass.
187
+
188
+ **`entity` is what makes the guard a guard.** The detector compares the new text
189
+ against that entity's most recent live facts and against nothing else, so a
190
+ `rememberGuarded` call with **no** `entity` has no candidates and always returns
191
+ `status: "stored"` - it does not fail, it simply has nothing to compare against.
192
+ Six identical guarded writes with no entity produce six facts; the same six with
193
+ `entity` produce one and five `blocked`.
194
+
195
+ `checked` on the result says whether a comparison happened at all: `false` is a
196
+ fact stored exactly as `remember` would have stored it. Do not read `status:
197
+ "stored"` as "checked and clear" without it.
198
+
199
+ `similar` carries `{ id, score, reason }` - the ids, not the text. Resolve a
200
+ hit's wording with `get(id)` when you want to show the caller what it collided
201
+ with.
182
202
 
183
203
  ```typescript
184
- const out = await db.remember({ text: "the user prefers async-std", entity: "user" });
185
- for (const s of out.similar) {
186
- // s.id, s.score, s.reason: "LexicalOverlap" | "VectorCosine"
187
- // → revise it, forget it, or keep both. Your call.
204
+ const decision = await db.rememberGuarded({
205
+ text: "the user prefers async-std",
206
+ entity: "user",
207
+ });
208
+ if (decision.status === "blocked") {
209
+ for (const s of decision.similar) {
210
+ // revise/forget an old fact, or use ordinary remember to keep both
211
+ console.log(s.id, s.score, s.reason);
212
+ }
188
213
  }
189
214
  ```
190
215
 
216
+ `blocked` has no `outcome`: it allocated no id and changed neither indexes nor
217
+ journal. Ordinary `remember` is also a safe complete write; it simply never rejects
218
+ one. Do not use `recall` for this check. Recall returns ranked context and can
219
+ return a weak nearest vector; its fused score is not cosine similarity or a
220
+ conflict threshold.
221
+
191
222
  ## API
192
223
 
193
224
  Every method wraps the identically-named verb of the Rust `Database`; this layer
@@ -198,9 +229,11 @@ only moves arguments and results across the boundary.
198
229
  | Method | Does |
199
230
  |---|---|
200
231
  | `remember(args)` | store a fact; resolves with its id and similar facts |
232
+ | `rememberGuarded(args)` | check similarity and store only if clear, without a check/write race |
201
233
  | `rememberMany(args[])` | store a batch: one embedding round-trip, one journal sync |
202
234
  | `revise(id, args)` | close a fact and record its successor |
203
235
  | `forget(id)` | tombstone a fact; resolves with whether it was live |
236
+ | `removeTag(tag)` | remove a tag from every current fact while preserving facts/history |
204
237
  | `link(args)` | upsert a typed edge, optionally with `provenance` |
205
238
  | `unlink(args)` | close the current edge; resolves with whether one was open |
206
239
 
@@ -211,6 +244,7 @@ only moves arguments and results across the boundary.
211
244
  | `recall(args?)` | ranked, fused, token-budgeted result (async) |
212
245
  | `get(id)` | one fact's full card, or `null` (sync) |
213
246
  | `tagsOf(id)` | that fact's tags (sync) |
247
+ | `listTags(options?)` | bounded lexical page of current tags and counts (async) |
214
248
  | `stats()` | engine size counters (sync) |
215
249
  | `path()` | the file this handle resolved to (sync) |
216
250
  | `export()` | every open fact as one array (async, unbounded — see below) |
@@ -223,11 +257,35 @@ only moves arguments and results across the boundary.
223
257
  | Method | Does |
224
258
  |---|---|
225
259
  | `maintain(mode?)` | `"auto"` (default), `"compact"`, `"reindex-text"`, `"optimize-vectors"`, `"full"`. No mode ever drops a revision or an edge version |
260
+ | `reembed(batchSize?)` | explicitly recompute every retained vector with the configured model and publish atomically; never invoked by `maintain('auto')` |
226
261
  | `checkpoint()` | flush the journal into a fresh snapshot |
227
262
  | `verify()` | full content-integrity sweep; rejects on the first inconsistency |
228
263
  | `scrub(options?)` | start a resumable byte-level check of the snapshot |
229
264
  | `recover(src, dst, options?)` | module function: salvage a damaged file into a clean copy |
230
265
 
266
+ The snapshot stores the model's readable vector-space identity, not only its
267
+ dimension. A changed model makes ordinary automatic embedding reject instead
268
+ of mixing incompatible vectors. `reembed` is the deliberate transition; it
269
+ runs on a libuv worker, leaves the JavaScript event loop responsive, keeps reads
270
+ live and makes concurrent writes reject with `PLUGMEM_BUSY`.
271
+
272
+ A mismatch does **not** stop the database opening, on a writer or a read-only
273
+ handle, and loses nothing. What fails is exactly two things: `recall` with a
274
+ `query` and `remember` with `text`. Everything else - `stats`, `get`, `tagsOf`,
275
+ `listTags`, entity/graph recall, `exportPage`, `forget`, `link`, `verify`,
276
+ `maintain`, `checkpoint`, `reembed` - keeps answering. So the content is safe
277
+ and recovery is always available, and a consumer only finds out at its first
278
+ lookup after the change: detect it by making the cheapest text recall and
279
+ watching for the error, rather than from a note of what was configured last
280
+ time.
281
+
282
+ `reembed` is idempotent; it rebuilds ONE database, so a workspace needs a pass
283
+ over every memory in it. On an EMPTY database it still makes one request whose
284
+ input is the empty string - a provider that rejects empty input fails a rebuild
285
+ that had nothing to rebuild. And switching an embedder on over a database built
286
+ without one breaks nothing and warns about nothing: compare `stats().vectors`
287
+ with `stats().facts` to notice the facts that have no vectors yet.
288
+
231
289
  **Read-only handles** (`{ readOnly: true }`) observe another process's writer
232
290
  over a published snapshot. The read verbs answer, the write verbs throw, and two
233
291
  more appear: `generation()` (the pinned snapshot number) and `refresh()` (adopt
@@ -408,6 +466,7 @@ half_life_days = 30 # and treat anything older than a month as stale
408
466
  enabled = true # false keeps settings but makes no embedder calls
409
467
  url = "http://localhost:11434/v1/embeddings"
410
468
  model = "nomic-embed-text"
469
+ space_id = "nomic-embed-text@v1" # optional; defaults to model
411
470
  api_key_env = "OPENAI_API_KEY" # env var holding the bearer token
412
471
 
413
472
  [maintenance]
@@ -440,7 +499,9 @@ embedder, its dimension governs and `dim` must agree.
440
499
  The host uses one `OpenAiCompatEmbedder` implementation for OpenAI, Ollama,
441
500
  LM Studio, vLLM and other OpenAI-compatible servers. `url` is the complete
442
501
  embeddings endpoint exactly as provided (nothing is appended), and `model` is
443
- the model name understood by that server. Set `enabled = false` to keep the
502
+ the model name understood by that server. `space_id` optionally identifies the
503
+ exact semantic space and defaults to `model`; it is never discovered over the
504
+ network. Set `enabled = false` to keep the
444
505
  settings without creating or calling the embedder; `$PLUGMEM_EMBEDDER_ENABLED`
445
506
  overrides it with `true` or `false`.
446
507
 
@@ -459,14 +520,15 @@ embedder's HTTP round trip or an fsync would freeze every timer, socket and
459
520
  callback in the process. Anything that can do that runs on a libuv worker and
460
521
  returns a promise instead.
461
522
 
462
- Promises: `Plugmem.open`, `remember`, `rememberMany`, `revise`, `recall`,
463
- `forget`, `link`, `unlink`, `export`, `exportPage`, `verify`, `maintain`,
464
- `checkpoint`, and every `Workspace` verb except `closeIdle`, `openCount` and
465
- `close`.
523
+ Promises: `Plugmem.open`, `remember`, `rememberGuarded`, `rememberMany`, `revise`, `recall`,
524
+ `forget`, `removeTag`, `listTags`, `link`, `unlink`, `export`, `exportPage`, `verify`, `maintain`,
525
+ `checkpoint`, every database verb on `WorkspaceMemory`, and every registry
526
+ verb on `Workspace`.
466
527
 
467
- Synchronous: `path`, `get`, `stats`, `tagsOf`, `generation`, `refresh`, `close`.
468
- These touch mapped memory and return in microseconds, where a promise would be
469
- pure ceremony.
528
+ Synchronous: the direct handle's `path`, `get`, `stats`, `tagsOf`, `generation`,
529
+ `refresh`, `close`; and `Workspace.memory`, `release`, `closeIdle`, `openCount`,
530
+ `close`. Creating a logical reference touches no file. Its own database verbs
531
+ are promises because acquiring a cold lease may open and replay a file.
470
532
 
471
533
  Arguments are still checked on your thread: a refused one **throws** at the call
472
534
  site rather than rejecting later, so a mistake in your code and a failure in the
@@ -542,13 +604,13 @@ import { Workspace, type DbEntry } from "plugmem";
542
604
 
543
605
  const ws = new Workspace("/srv/memories");
544
606
 
545
- // The same `Plugmem` class comes back, so a named memory has exactly the verbs
546
- // a path-opened one has. A first write to an unused name creates it.
547
- const chat = await ws.open("chat-42");
607
+ // This is only a name plus a weak reference to `ws`: no file is opened and no
608
+ // writer lock is held until a verb runs. A first write creates the memory.
609
+ const chat = ws.memory("chat-42");
548
610
  await chat.remember({ text: "prefers tokio" });
549
611
 
550
612
  // Another name is another memory. They cannot see each other.
551
- const other = await ws.open("chat-99");
613
+ const other = ws.memory("chat-99");
552
614
  (await other.recall({ query: "tokio" })).facts.length; // 0
553
615
  ```
554
616
 
@@ -568,10 +630,10 @@ const byOwner: DbEntry[] = await ws.find("ann"); // → the same memo
568
630
  ```
569
631
 
570
632
  A name is `[a-z0-9][a-z0-9_-]*` and **cannot express a path**, so it resolves to
571
- exactly one named database inside the directory — traversal is not filtered out, it is
572
- unconstructible. `ws.open(name, false)` refuses a name that does not exist yet,
573
- which is what a read should do so a typo is diagnosed rather than answered with
574
- an empty result.
633
+ exactly one named database inside the directory — traversal is not filtered out,
634
+ it is unconstructible. `memory(name)` itself creates nothing. A write verb creates
635
+ an unused name; a read verb refuses it, so a typo is diagnosed rather than
636
+ answered with an empty result.
575
637
 
576
638
  **Who may reach which memory is not this package's job.** The name comes from
577
639
  your code, so the policy belongs there.
@@ -580,7 +642,8 @@ your code, so the policy belongs there.
580
642
 
581
643
  | Method | Does |
582
644
  |---|---|
583
- | `open(name, create?)` | open (default: create if missing) and hand back a `Plugmem` |
645
+ | `memory(name)` | return a lock-free logical `WorkspaceMemory` reference |
646
+ | `release(name)` | evict one inactive pooled handle; references remain valid |
584
647
  | `list()` | every memory in the directory, from the filesystem — including undescribed ones |
585
648
  | `entries()` | every described memory, from the registry |
586
649
  | `find(query, k?)` | memories whose description or owner best matches |
@@ -592,20 +655,47 @@ your code, so the policy belongs there.
592
655
  | `openCount()` | how many are open right now (sync) |
593
656
  | `close()` | close every pooled memory and the registry |
594
657
 
595
- `closeIdle()` matters more than it looks. An open memory holds its file's
658
+ `closeIdle()` matters more than it looks. A pooled database holds its file's
596
659
  exclusive lock, so a long-running process that never lets go makes its memories
597
660
  unreachable from anything else on the machine. Call it on a timer — that is what
598
- the idle timeout is for, liveness rather than memory. The pool bounds how many
599
- stay open at once (`maxOpen`, default 16, least-recently-used closed to make
600
- room):
661
+ the idle timeout is for, liveness rather than memory. The pool is a hard bound on
662
+ open databases (`maxOpen`, default 16): an inactive least-recently-used entry is
663
+ closed to make room. If every slot belongs to an active verb, a different memory
664
+ gets `PLUGMEM_BUSY` immediately instead of waiting or opening a hidden extra
665
+ handle:
601
666
 
602
667
  ```ts
603
668
  const ws = new Workspace("/srv/memories", { maxOpen: 16, idleTimeoutMs: 60_000 });
604
669
  setInterval(() => ws.closeIdle(), 30_000);
605
670
  ```
606
671
 
607
- A `Plugmem` handed out by `open()` is **not** closed by `ws.close()`: it is its
608
- own handle holding its own lock until you close it or it is garbage collected.
672
+ Each `WorkspaceMemory` verb takes a scoped lease. While it runs, `release`,
673
+ `closeIdle` and LRU eviction cannot take that entry; after it returns, the entry
674
+ is eligible immediately. `ws.close()` invalidates every logical reference.
675
+ Garbage collection of a `WorkspaceMemory` neither opens nor closes anything.
676
+
677
+ This lifecycle applies only to workspaces. A direct `Plugmem.open(path)` still
678
+ returns an explicitly owned native handle, and `close()` remains how its writer
679
+ lock is released.
680
+
681
+ ### Migration from the handle-returning workspace API
682
+
683
+ This is a breaking ownership change:
684
+
685
+ ```ts
686
+ // before: a second native owner whose lifetime depended on JavaScript GC
687
+ const memory = await ws.open("chat-42");
688
+ memory.close();
689
+
690
+ // now: a stable logical reference; each verb owns one scoped lease
691
+ const memory = ws.memory("chat-42");
692
+ ws.release("chat-42"); // optional: release an inactive pooled lock now
693
+ ```
694
+
695
+ There is no `WorkspaceMemory.close()`: it owns nothing to close. Database reads
696
+ such as `get`, `stats` and `tagsOf` are promises on this class because a cold
697
+ call may have to reopen and replay the file. The same methods on a direct
698
+ `Plugmem` remain synchronous.
609
699
 
610
700
  `verify()` reports and never repairs, because a workspace is a directory a
611
701
  person can edit, and guessing at their intent is how a consistency check loses
@@ -627,7 +717,7 @@ search. For those, use a dedicated system — [Qdrant](https://qdrant.tech),
627
717
 
628
718
  ## Other ways in
629
719
 
630
- The same engine ships five ways. This package is the Node one.
720
+ plugmem also ships interfaces for Rust, Python, agents and the terminal.
631
721
 
632
722
  | You are | Use |
633
723
  |---|---|
@@ -638,7 +728,7 @@ The same engine ships five ways. This package is the Node one.
638
728
  | a person at a terminal | [`plugmem-cli`](https://docs.rs/plugmem-cli/latest) |
639
729
 
640
730
  **Working with an LLM agent?** There is a companion
641
- [skill](https://github.com/m62624/plugmem/blob/main/skill/SKILL.md) describing
731
+ [skill](https://github.com/m62624/plugmem/blob/main/skills/plugmem/SKILL.md) describing
642
732
  the remember/recall loop, the contradiction workflow and the verbs. This package
643
733
  ships it: `skill()` returns the text and `skillVersion()` the version it was
644
734
  written against.
package/index.d.ts CHANGED
@@ -133,6 +133,15 @@ export interface LinkArgs {
133
133
  */
134
134
  provenance?: number
135
135
  }
136
+ /** Bounded tag-catalog options. Omitted fields select the first 64 entries. */
137
+ export interface TagListOptions {
138
+ /** Exact, case-sensitive prefix. */
139
+ prefix?: string
140
+ /** Opaque cursor returned by the previous page. */
141
+ cursor?: string
142
+ /** Page size (maximum 256). */
143
+ limit?: number
144
+ }
136
145
  /** Options for [`recover`]. */
137
146
  export interface RecoverOptions {
138
147
  /**
@@ -201,6 +210,25 @@ export interface RememberOutcome {
201
210
  */
202
211
  similar: Array<Similar>
203
212
  }
213
+ /**
214
+ * Result of `rememberGuarded`. Blocked results have no `outcome` because no
215
+ * fact id was allocated.
216
+ */
217
+ export interface GuardedRememberOutcome {
218
+ status: 'stored' | 'blocked'
219
+ outcome?: RememberOutcome
220
+ similar: Array<Similar>
221
+ /**
222
+ * Whether the similarity detector had anything to compare against.
223
+ *
224
+ * `false` means the fact was stored WITHOUT a duplicate check: the
225
+ * detector is scoped to the fact's entity, so a call carrying no
226
+ * `entity` has no candidate set and cannot block anything, now or after
227
+ * any number of later writes. Always `true` on a blocked result, which
228
+ * by definition compared something.
229
+ */
230
+ checked: boolean
231
+ }
204
232
  /** One recalled fact. */
205
233
  export interface RecalledFact {
206
234
  /** The fact id. */
@@ -373,6 +401,31 @@ export interface ExportPage {
373
401
  /** Pass this opaque cursor to the next call; absent when the scan is done. */
374
402
  nextCursor?: number
375
403
  }
404
+ /** One active tag and the number of current facts carrying it. */
405
+ export interface TagSummary {
406
+ name: string
407
+ count: number
408
+ }
409
+ /** One bounded, stable page of current tags. */
410
+ export interface TagPage {
411
+ items: Array<TagSummary>
412
+ nextCursor?: string
413
+ }
414
+ /** Result of removing one tag from all current facts. */
415
+ export interface RemoveTagReport {
416
+ affected: number
417
+ }
418
+ /** The report of an explicit complete vector-axis replacement. */
419
+ export interface ReembedReport {
420
+ previousSpace?: string
421
+ newSpace: string
422
+ previousDim: number
423
+ newDim: number
424
+ embedded: number
425
+ tombstonesSkipped: number
426
+ vectorBytes: number
427
+ hnswIndexed: number
428
+ }
376
429
  /** The report of a `maintain` pass. */
377
430
  export interface MaintainReport {
378
431
  /** Tombstoned facts physically removed by this pass. */
@@ -448,7 +501,7 @@ export const enum MaintainMode {
448
501
  * round-trip it would be the wrong direction of dependency.
449
502
  */
450
503
  export interface DbEntry {
451
- /** The memory's name — its identity, and what `Workspace.open` takes. */
504
+ /** The memory's name — its identity, and what `Workspace.memory` takes. */
452
505
  db: string
453
506
  /** What it is for. */
454
507
  description: string
@@ -636,6 +689,14 @@ export declare class Plugmem {
636
689
  * @throws synchronously in read-only mode.
637
690
  */
638
691
  remember(args: RememberArgs): Promise<RememberOutcome>
692
+ /**
693
+ * Stores only when the same bounded Jaccard/cosine detector used by
694
+ * `remember().similar` finds no candidate. No other write can slip between
695
+ * the check and possible write; a blocked result performs no mutation.
696
+ * Runs on a libuv worker because
697
+ * automatic embedding and durable writes are blocking work.
698
+ */
699
+ rememberGuarded(args: RememberArgs): Promise<GuardedRememberOutcome>
639
700
  /**
640
701
  * Stores a batch of facts and resolves with one outcome per input.
641
702
  *
@@ -755,6 +816,17 @@ export declare class Plugmem {
755
816
  scrub(options?: ScrubOptions | undefined | null): Promise<Scrub>
756
817
  /** One fact's tags, or an empty array for an unknown or tombstoned id. */
757
818
  tagsOf(id: number): Array<string>
819
+ /**
820
+ * One bounded page of current tags. Runs on a libuv worker so neither a
821
+ * mapped-page fault nor host locking can pause the JavaScript event loop.
822
+ */
823
+ listTags(options?: TagListOptions | undefined | null): Promise<TagPage>
824
+ /**
825
+ * Removes a tag from every current fact by creating successor revisions.
826
+ * The bulk write and journal sync run on a libuv worker.
827
+ * @throws synchronously in read-only mode.
828
+ */
829
+ removeTag(tag: string): Promise<RemoveTagReport>
758
830
  /**
759
831
  * Content-integrity check; rejects on the first inconsistency found.
760
832
  *
@@ -774,6 +846,16 @@ export declare class Plugmem {
774
846
  * the only mode that reclaims edge-history page slack.
775
847
  */
776
848
  maintain(mode?: 'auto' | 'compact' | 'reindex-text' | 'optimize-vectors' | 'full'): Promise<MaintainReport>
849
+ /**
850
+ * Explicitly recomputes every retained fact with the configured embedder
851
+ * and atomically replaces the complete vector axis. This is never invoked
852
+ * by `maintain('auto')`.
853
+ *
854
+ * **Async**: provider and snapshot work runs on a libuv worker; the event
855
+ * loop remains responsive. Reads continue while it runs and writes reject
856
+ * with `PLUGMEM_BUSY` instead of waiting.
857
+ */
858
+ reembed(batchSize?: number | undefined | null): Promise<ReembedReport>
777
859
  /**
778
860
  * Flushes the journal into a fresh snapshot. **Async** (returns a `Promise`):
779
861
  * it writes and fsyncs a snapshot file, so it runs on a libuv worker thread.
@@ -838,9 +920,38 @@ export declare class Scrub {
838
920
  active(): boolean
839
921
  }
840
922
  /**
841
- * A directory of named memories the napi mirror of
842
- * [`plugmem_host::Workspace`].
923
+ * A logical reference to one named memory.
924
+ *
925
+ * It owns no open database and no file lock. Each verb obtains a scoped
926
+ * workspace lease on a libuv worker, and dropping this JavaScript object has
927
+ * no resource-management meaning. The workspace is the owner; closing it
928
+ * invalidates every reference.
843
929
  */
930
+ export declare class WorkspaceMemory {
931
+ /** The stable workspace name this reference addresses. */
932
+ name(): string
933
+ remember(args: RememberArgs): Promise<RememberOutcome>
934
+ rememberGuarded(args: RememberArgs): Promise<GuardedRememberOutcome>
935
+ rememberMany(args: Array<RememberArgs>): Promise<RememberOutcome[]>
936
+ revise(id: number, args: RememberArgs): Promise<RememberOutcome>
937
+ recall(args?: RecallArgs | undefined | null): Promise<RecallResult>
938
+ forget(id: number): Promise<boolean>
939
+ link(args: LinkArgs): Promise<void>
940
+ unlink(args: LinkArgs): Promise<boolean>
941
+ get(id: number): Promise<FactSnapshot | null>
942
+ stats(): Promise<Stats>
943
+ export(): Promise<ExportedFact[]>
944
+ exportPage(cursor?: number | undefined | null): Promise<ExportPage>
945
+ exportEdges(onBatch: (edges: ExportedEdge[]) => void): Promise<number>
946
+ scrub(options?: ScrubOptions | undefined | null): Promise<Scrub>
947
+ tagsOf(id: number): Promise<string[]>
948
+ listTags(options?: TagListOptions | undefined | null): Promise<TagPage>
949
+ removeTag(tag: string): Promise<RemoveTagReport>
950
+ verify(): Promise<void>
951
+ maintain(mode?: 'auto' | 'compact' | 'reindex-text' | 'optimize-vectors' | 'full'): Promise<MaintainReport>
952
+ reembed(batchSize?: number | undefined | null): Promise<ReembedReport>
953
+ checkpoint(): Promise<void>
954
+ }
844
955
  export declare class Workspace {
845
956
  /**
846
957
  * Opens the workspace rooted at `root`. Creates nothing: the directories
@@ -850,22 +961,22 @@ export declare class Workspace {
850
961
  */
851
962
  constructor(root: string, options?: WorkspaceOptions | undefined | null)
852
963
  /**
853
- * Opens the memory named `db` and returns it as a [`Plugmem`] — the same
854
- * class, and the same verbs, as a memory opened by path.
964
+ * Returns a logical reference to the memory named `db`.
855
965
  *
856
- * `create` defaults to `true`: a first use of an unused name brings that
857
- * memory into being, which is what makes a new conversation need no
858
- * registration step. Pass `false` to require that it already exists, which
859
- * is what a read should do so a misspelled name is diagnosed rather than
860
- * answered with nothing.
966
+ * This does not open a file, acquire a lock or create the memory. Read
967
+ * verbs require it to exist; write verbs create it on first use. The
968
+ * returned object stays valid across pool eviction and `release()` and
969
+ * transparently reopens the memory on its next verb.
970
+ */
971
+ memory(db: string): WorkspaceMemory
972
+ /**
973
+ * Evicts one inactive memory from the pool and releases its file lock.
861
974
  *
862
- * @throws if the name is not a usable memory name, if it does not exist and
863
- * `create` is false, or if another process holds it.
864
- * **Async**: a first open replays the memory's journal and maps its
865
- * snapshot, and making room in the pool closes another memory — file work
866
- * that the one thread running JavaScript must not be holding.
975
+ * Logical references remain valid. A later verb reopens the memory. If a
976
+ * verb is currently using it, this throws a typed `BUSY` error instead of
977
+ * waiting.
867
978
  */
868
- open(db: string, create?: boolean | undefined | null): Promise<Plugmem>
979
+ release(db: string): boolean
869
980
  /**
870
981
  * Every memory in the directory, sorted by name.
871
982
  *
@@ -934,12 +1045,10 @@ export declare class Workspace {
934
1045
  /** How many memories are open right now. */
935
1046
  openCount(): number
936
1047
  /**
937
- * Closes every pooled memory and the registry, releasing their file locks,
938
- * and closes the workspace. Every method then throws.
939
- *
940
- * A [`Plugmem`] handed out by `open()` is **not** closed by this: it is its
941
- * own handle and holds its own lock until it is closed or garbage
942
- * collected.
1048
+ * Invalidates every [`WorkspaceMemory`] reference, closes inactive pooled
1049
+ * memories and the registry, and closes the workspace. A verb already in
1050
+ * flight finishes with its scoped handle; its lock is released when that
1051
+ * verb returns. Every later call throws `CLOSED`.
943
1052
  */
944
1053
  close(): void
945
1054
  }
package/index.js CHANGED
@@ -310,12 +310,13 @@ if (!nativeBinding) {
310
310
  throw new Error(`Failed to load native binding`)
311
311
  }
312
312
 
313
- const { Plugmem, recover, Scrub, MaintainMode, Workspace, version, about, settingsHelp, skill, skillFull, skillVersion } = nativeBinding
313
+ const { Plugmem, recover, Scrub, MaintainMode, WorkspaceMemory, Workspace, version, about, settingsHelp, skill, skillFull, skillVersion } = nativeBinding
314
314
 
315
315
  module.exports.Plugmem = Plugmem
316
316
  module.exports.recover = recover
317
317
  module.exports.Scrub = Scrub
318
318
  module.exports.MaintainMode = MaintainMode
319
+ module.exports.WorkspaceMemory = WorkspaceMemory
319
320
  module.exports.Workspace = Workspace
320
321
  module.exports.version = version
321
322
  module.exports.about = about
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugmem",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Native Node.js addon for plugmem: an embedded bitemporal memory and retrieval engine for local-first applications and agents (remember / recall / revise / forget over one local database).",
5
5
  "repository": {
6
6
  "type": "git",
@@ -43,11 +43,11 @@
43
43
  "typecheck": "tsc -p tsconfig.json"
44
44
  },
45
45
  "optionalDependencies": {
46
- "plugmem-linux-x64-gnu": "0.8.0",
47
- "plugmem-linux-arm64-gnu": "0.8.0",
48
- "plugmem-darwin-x64": "0.8.0",
49
- "plugmem-darwin-arm64": "0.8.0",
50
- "plugmem-win32-x64-msvc": "0.8.0",
51
- "plugmem-win32-arm64-msvc": "0.8.0"
46
+ "plugmem-linux-x64-gnu": "0.10.0",
47
+ "plugmem-linux-arm64-gnu": "0.10.0",
48
+ "plugmem-darwin-x64": "0.10.0",
49
+ "plugmem-darwin-arm64": "0.10.0",
50
+ "plugmem-win32-x64-msvc": "0.10.0",
51
+ "plugmem-win32-arm64-msvc": "0.10.0"
52
52
  }
53
53
  }