plugmem 0.8.0 → 0.9.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 +104 -46
  2. package/index.d.ts +121 -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,30 @@ 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.
182
187
 
183
188
  ```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.
189
+ const decision = await db.rememberGuarded({
190
+ text: "the user prefers async-std",
191
+ entity: "user",
192
+ });
193
+ if (decision.status === "blocked") {
194
+ for (const s of decision.similar) {
195
+ // revise/forget an old fact, or use ordinary remember to keep both
196
+ console.log(s.id, s.score, s.reason);
197
+ }
188
198
  }
189
199
  ```
190
200
 
201
+ `blocked` has no `outcome`: it allocated no id and changed neither indexes nor
202
+ journal. Ordinary `remember` is also a safe complete write; it simply never rejects
203
+ one. Do not use `recall` for this check. Recall returns ranked context and can
204
+ return a weak nearest vector; its fused score is not cosine similarity or a
205
+ conflict threshold.
206
+
191
207
  ## API
192
208
 
193
209
  Every method wraps the identically-named verb of the Rust `Database`; this layer
@@ -198,9 +214,11 @@ only moves arguments and results across the boundary.
198
214
  | Method | Does |
199
215
  |---|---|
200
216
  | `remember(args)` | store a fact; resolves with its id and similar facts |
217
+ | `rememberGuarded(args)` | check similarity and store only if clear, without a check/write race |
201
218
  | `rememberMany(args[])` | store a batch: one embedding round-trip, one journal sync |
202
219
  | `revise(id, args)` | close a fact and record its successor |
203
220
  | `forget(id)` | tombstone a fact; resolves with whether it was live |
221
+ | `removeTag(tag)` | remove a tag from every current fact while preserving facts/history |
204
222
  | `link(args)` | upsert a typed edge, optionally with `provenance` |
205
223
  | `unlink(args)` | close the current edge; resolves with whether one was open |
206
224
 
@@ -211,6 +229,7 @@ only moves arguments and results across the boundary.
211
229
  | `recall(args?)` | ranked, fused, token-budgeted result (async) |
212
230
  | `get(id)` | one fact's full card, or `null` (sync) |
213
231
  | `tagsOf(id)` | that fact's tags (sync) |
232
+ | `listTags(options?)` | bounded lexical page of current tags and counts (async) |
214
233
  | `stats()` | engine size counters (sync) |
215
234
  | `path()` | the file this handle resolved to (sync) |
216
235
  | `export()` | every open fact as one array (async, unbounded — see below) |
@@ -223,11 +242,18 @@ only moves arguments and results across the boundary.
223
242
  | Method | Does |
224
243
  |---|---|
225
244
  | `maintain(mode?)` | `"auto"` (default), `"compact"`, `"reindex-text"`, `"optimize-vectors"`, `"full"`. No mode ever drops a revision or an edge version |
245
+ | `reembed(batchSize?)` | explicitly recompute every retained vector with the configured model and publish atomically; never invoked by `maintain('auto')` |
226
246
  | `checkpoint()` | flush the journal into a fresh snapshot |
227
247
  | `verify()` | full content-integrity sweep; rejects on the first inconsistency |
228
248
  | `scrub(options?)` | start a resumable byte-level check of the snapshot |
229
249
  | `recover(src, dst, options?)` | module function: salvage a damaged file into a clean copy |
230
250
 
251
+ The snapshot stores the model's readable vector-space identity, not only its
252
+ dimension. A changed model makes ordinary automatic embedding reject instead
253
+ of mixing incompatible vectors. `reembed` is the deliberate transition; it
254
+ runs on a libuv worker, leaves the JavaScript event loop responsive, keeps reads
255
+ live and makes concurrent writes reject with `PLUGMEM_BUSY`.
256
+
231
257
  **Read-only handles** (`{ readOnly: true }`) observe another process's writer
232
258
  over a published snapshot. The read verbs answer, the write verbs throw, and two
233
259
  more appear: `generation()` (the pinned snapshot number) and `refresh()` (adopt
@@ -408,6 +434,7 @@ half_life_days = 30 # and treat anything older than a month as stale
408
434
  enabled = true # false keeps settings but makes no embedder calls
409
435
  url = "http://localhost:11434/v1/embeddings"
410
436
  model = "nomic-embed-text"
437
+ space_id = "nomic-embed-text@v1" # optional; defaults to model
411
438
  api_key_env = "OPENAI_API_KEY" # env var holding the bearer token
412
439
 
413
440
  [maintenance]
@@ -440,7 +467,9 @@ embedder, its dimension governs and `dim` must agree.
440
467
  The host uses one `OpenAiCompatEmbedder` implementation for OpenAI, Ollama,
441
468
  LM Studio, vLLM and other OpenAI-compatible servers. `url` is the complete
442
469
  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
470
+ the model name understood by that server. `space_id` optionally identifies the
471
+ exact semantic space and defaults to `model`; it is never discovered over the
472
+ network. Set `enabled = false` to keep the
444
473
  settings without creating or calling the embedder; `$PLUGMEM_EMBEDDER_ENABLED`
445
474
  overrides it with `true` or `false`.
446
475
 
@@ -459,14 +488,15 @@ embedder's HTTP round trip or an fsync would freeze every timer, socket and
459
488
  callback in the process. Anything that can do that runs on a libuv worker and
460
489
  returns a promise instead.
461
490
 
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`.
491
+ Promises: `Plugmem.open`, `remember`, `rememberGuarded`, `rememberMany`, `revise`, `recall`,
492
+ `forget`, `removeTag`, `listTags`, `link`, `unlink`, `export`, `exportPage`, `verify`, `maintain`,
493
+ `checkpoint`, every database verb on `WorkspaceMemory`, and every registry
494
+ verb on `Workspace`.
466
495
 
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.
496
+ Synchronous: the direct handle's `path`, `get`, `stats`, `tagsOf`, `generation`,
497
+ `refresh`, `close`; and `Workspace.memory`, `release`, `closeIdle`, `openCount`,
498
+ `close`. Creating a logical reference touches no file. Its own database verbs
499
+ are promises because acquiring a cold lease may open and replay a file.
470
500
 
471
501
  Arguments are still checked on your thread: a refused one **throws** at the call
472
502
  site rather than rejecting later, so a mistake in your code and a failure in the
@@ -542,13 +572,13 @@ import { Workspace, type DbEntry } from "plugmem";
542
572
 
543
573
  const ws = new Workspace("/srv/memories");
544
574
 
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");
575
+ // This is only a name plus a weak reference to `ws`: no file is opened and no
576
+ // writer lock is held until a verb runs. A first write creates the memory.
577
+ const chat = ws.memory("chat-42");
548
578
  await chat.remember({ text: "prefers tokio" });
549
579
 
550
580
  // Another name is another memory. They cannot see each other.
551
- const other = await ws.open("chat-99");
581
+ const other = ws.memory("chat-99");
552
582
  (await other.recall({ query: "tokio" })).facts.length; // 0
553
583
  ```
554
584
 
@@ -568,10 +598,10 @@ const byOwner: DbEntry[] = await ws.find("ann"); // → the same memo
568
598
  ```
569
599
 
570
600
  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.
601
+ exactly one named database inside the directory — traversal is not filtered out,
602
+ it is unconstructible. `memory(name)` itself creates nothing. A write verb creates
603
+ an unused name; a read verb refuses it, so a typo is diagnosed rather than
604
+ answered with an empty result.
575
605
 
576
606
  **Who may reach which memory is not this package's job.** The name comes from
577
607
  your code, so the policy belongs there.
@@ -580,7 +610,8 @@ your code, so the policy belongs there.
580
610
 
581
611
  | Method | Does |
582
612
  |---|---|
583
- | `open(name, create?)` | open (default: create if missing) and hand back a `Plugmem` |
613
+ | `memory(name)` | return a lock-free logical `WorkspaceMemory` reference |
614
+ | `release(name)` | evict one inactive pooled handle; references remain valid |
584
615
  | `list()` | every memory in the directory, from the filesystem — including undescribed ones |
585
616
  | `entries()` | every described memory, from the registry |
586
617
  | `find(query, k?)` | memories whose description or owner best matches |
@@ -592,20 +623,47 @@ your code, so the policy belongs there.
592
623
  | `openCount()` | how many are open right now (sync) |
593
624
  | `close()` | close every pooled memory and the registry |
594
625
 
595
- `closeIdle()` matters more than it looks. An open memory holds its file's
626
+ `closeIdle()` matters more than it looks. A pooled database holds its file's
596
627
  exclusive lock, so a long-running process that never lets go makes its memories
597
628
  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):
629
+ the idle timeout is for, liveness rather than memory. The pool is a hard bound on
630
+ open databases (`maxOpen`, default 16): an inactive least-recently-used entry is
631
+ closed to make room. If every slot belongs to an active verb, a different memory
632
+ gets `PLUGMEM_BUSY` immediately instead of waiting or opening a hidden extra
633
+ handle:
601
634
 
602
635
  ```ts
603
636
  const ws = new Workspace("/srv/memories", { maxOpen: 16, idleTimeoutMs: 60_000 });
604
637
  setInterval(() => ws.closeIdle(), 30_000);
605
638
  ```
606
639
 
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.
640
+ Each `WorkspaceMemory` verb takes a scoped lease. While it runs, `release`,
641
+ `closeIdle` and LRU eviction cannot take that entry; after it returns, the entry
642
+ is eligible immediately. `ws.close()` invalidates every logical reference.
643
+ Garbage collection of a `WorkspaceMemory` neither opens nor closes anything.
644
+
645
+ This lifecycle applies only to workspaces. A direct `Plugmem.open(path)` still
646
+ returns an explicitly owned native handle, and `close()` remains how its writer
647
+ lock is released.
648
+
649
+ ### Migration from the handle-returning workspace API
650
+
651
+ This is a breaking ownership change:
652
+
653
+ ```ts
654
+ // before: a second native owner whose lifetime depended on JavaScript GC
655
+ const memory = await ws.open("chat-42");
656
+ memory.close();
657
+
658
+ // now: a stable logical reference; each verb owns one scoped lease
659
+ const memory = ws.memory("chat-42");
660
+ ws.release("chat-42"); // optional: release an inactive pooled lock now
661
+ ```
662
+
663
+ There is no `WorkspaceMemory.close()`: it owns nothing to close. Database reads
664
+ such as `get`, `stats` and `tagsOf` are promises on this class because a cold
665
+ call may have to reopen and replay the file. The same methods on a direct
666
+ `Plugmem` remain synchronous.
609
667
 
610
668
  `verify()` reports and never repairs, because a workspace is a directory a
611
669
  person can edit, and guessing at their intent is how a consistency check loses
@@ -627,7 +685,7 @@ search. For those, use a dedicated system — [Qdrant](https://qdrant.tech),
627
685
 
628
686
  ## Other ways in
629
687
 
630
- The same engine ships five ways. This package is the Node one.
688
+ plugmem also ships interfaces for Rust, Python, agents and the terminal.
631
689
 
632
690
  | You are | Use |
633
691
  |---|---|
@@ -638,7 +696,7 @@ The same engine ships five ways. This package is the Node one.
638
696
  | a person at a terminal | [`plugmem-cli`](https://docs.rs/plugmem-cli/latest) |
639
697
 
640
698
  **Working with an LLM agent?** There is a companion
641
- [skill](https://github.com/m62624/plugmem/blob/main/skill/SKILL.md) describing
699
+ [skill](https://github.com/m62624/plugmem/blob/main/skills/plugmem/SKILL.md) describing
642
700
  the remember/recall loop, the contradiction workflow and the verbs. This package
643
701
  ships it: `skill()` returns the text and `skillVersion()` the version it was
644
702
  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,15 @@ 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
+ }
204
222
  /** One recalled fact. */
205
223
  export interface RecalledFact {
206
224
  /** The fact id. */
@@ -373,6 +391,31 @@ export interface ExportPage {
373
391
  /** Pass this opaque cursor to the next call; absent when the scan is done. */
374
392
  nextCursor?: number
375
393
  }
394
+ /** One active tag and the number of current facts carrying it. */
395
+ export interface TagSummary {
396
+ name: string
397
+ count: number
398
+ }
399
+ /** One bounded, stable page of current tags. */
400
+ export interface TagPage {
401
+ items: Array<TagSummary>
402
+ nextCursor?: string
403
+ }
404
+ /** Result of removing one tag from all current facts. */
405
+ export interface RemoveTagReport {
406
+ affected: number
407
+ }
408
+ /** The report of an explicit complete vector-axis replacement. */
409
+ export interface ReembedReport {
410
+ previousSpace?: string
411
+ newSpace: string
412
+ previousDim: number
413
+ newDim: number
414
+ embedded: number
415
+ tombstonesSkipped: number
416
+ vectorBytes: number
417
+ hnswIndexed: number
418
+ }
376
419
  /** The report of a `maintain` pass. */
377
420
  export interface MaintainReport {
378
421
  /** Tombstoned facts physically removed by this pass. */
@@ -448,7 +491,7 @@ export const enum MaintainMode {
448
491
  * round-trip it would be the wrong direction of dependency.
449
492
  */
450
493
  export interface DbEntry {
451
- /** The memory's name — its identity, and what `Workspace.open` takes. */
494
+ /** The memory's name — its identity, and what `Workspace.memory` takes. */
452
495
  db: string
453
496
  /** What it is for. */
454
497
  description: string
@@ -636,6 +679,14 @@ export declare class Plugmem {
636
679
  * @throws synchronously in read-only mode.
637
680
  */
638
681
  remember(args: RememberArgs): Promise<RememberOutcome>
682
+ /**
683
+ * Stores only when the same bounded Jaccard/cosine detector used by
684
+ * `remember().similar` finds no candidate. No other write can slip between
685
+ * the check and possible write; a blocked result performs no mutation.
686
+ * Runs on a libuv worker because
687
+ * automatic embedding and durable writes are blocking work.
688
+ */
689
+ rememberGuarded(args: RememberArgs): Promise<GuardedRememberOutcome>
639
690
  /**
640
691
  * Stores a batch of facts and resolves with one outcome per input.
641
692
  *
@@ -755,6 +806,17 @@ export declare class Plugmem {
755
806
  scrub(options?: ScrubOptions | undefined | null): Promise<Scrub>
756
807
  /** One fact's tags, or an empty array for an unknown or tombstoned id. */
757
808
  tagsOf(id: number): Array<string>
809
+ /**
810
+ * One bounded page of current tags. Runs on a libuv worker so neither a
811
+ * mapped-page fault nor host locking can pause the JavaScript event loop.
812
+ */
813
+ listTags(options?: TagListOptions | undefined | null): Promise<TagPage>
814
+ /**
815
+ * Removes a tag from every current fact by creating successor revisions.
816
+ * The bulk write and journal sync run on a libuv worker.
817
+ * @throws synchronously in read-only mode.
818
+ */
819
+ removeTag(tag: string): Promise<RemoveTagReport>
758
820
  /**
759
821
  * Content-integrity check; rejects on the first inconsistency found.
760
822
  *
@@ -774,6 +836,16 @@ export declare class Plugmem {
774
836
  * the only mode that reclaims edge-history page slack.
775
837
  */
776
838
  maintain(mode?: 'auto' | 'compact' | 'reindex-text' | 'optimize-vectors' | 'full'): Promise<MaintainReport>
839
+ /**
840
+ * Explicitly recomputes every retained fact with the configured embedder
841
+ * and atomically replaces the complete vector axis. This is never invoked
842
+ * by `maintain('auto')`.
843
+ *
844
+ * **Async**: provider and snapshot work runs on a libuv worker; the event
845
+ * loop remains responsive. Reads continue while it runs and writes reject
846
+ * with `PLUGMEM_BUSY` instead of waiting.
847
+ */
848
+ reembed(batchSize?: number | undefined | null): Promise<ReembedReport>
777
849
  /**
778
850
  * Flushes the journal into a fresh snapshot. **Async** (returns a `Promise`):
779
851
  * it writes and fsyncs a snapshot file, so it runs on a libuv worker thread.
@@ -838,9 +910,38 @@ export declare class Scrub {
838
910
  active(): boolean
839
911
  }
840
912
  /**
841
- * A directory of named memories the napi mirror of
842
- * [`plugmem_host::Workspace`].
913
+ * A logical reference to one named memory.
914
+ *
915
+ * It owns no open database and no file lock. Each verb obtains a scoped
916
+ * workspace lease on a libuv worker, and dropping this JavaScript object has
917
+ * no resource-management meaning. The workspace is the owner; closing it
918
+ * invalidates every reference.
843
919
  */
920
+ export declare class WorkspaceMemory {
921
+ /** The stable workspace name this reference addresses. */
922
+ name(): string
923
+ remember(args: RememberArgs): Promise<RememberOutcome>
924
+ rememberGuarded(args: RememberArgs): Promise<GuardedRememberOutcome>
925
+ rememberMany(args: Array<RememberArgs>): Promise<RememberOutcome[]>
926
+ revise(id: number, args: RememberArgs): Promise<RememberOutcome>
927
+ recall(args?: RecallArgs | undefined | null): Promise<RecallResult>
928
+ forget(id: number): Promise<boolean>
929
+ link(args: LinkArgs): Promise<void>
930
+ unlink(args: LinkArgs): Promise<boolean>
931
+ get(id: number): Promise<FactSnapshot | null>
932
+ stats(): Promise<Stats>
933
+ export(): Promise<ExportedFact[]>
934
+ exportPage(cursor?: number | undefined | null): Promise<ExportPage>
935
+ exportEdges(onBatch: (edges: ExportedEdge[]) => void): Promise<number>
936
+ scrub(options?: ScrubOptions | undefined | null): Promise<Scrub>
937
+ tagsOf(id: number): Promise<string[]>
938
+ listTags(options?: TagListOptions | undefined | null): Promise<TagPage>
939
+ removeTag(tag: string): Promise<RemoveTagReport>
940
+ verify(): Promise<void>
941
+ maintain(mode?: 'auto' | 'compact' | 'reindex-text' | 'optimize-vectors' | 'full'): Promise<MaintainReport>
942
+ reembed(batchSize?: number | undefined | null): Promise<ReembedReport>
943
+ checkpoint(): Promise<void>
944
+ }
844
945
  export declare class Workspace {
845
946
  /**
846
947
  * Opens the workspace rooted at `root`. Creates nothing: the directories
@@ -850,22 +951,22 @@ export declare class Workspace {
850
951
  */
851
952
  constructor(root: string, options?: WorkspaceOptions | undefined | null)
852
953
  /**
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.
954
+ * Returns a logical reference to the memory named `db`.
855
955
  *
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.
956
+ * This does not open a file, acquire a lock or create the memory. Read
957
+ * verbs require it to exist; write verbs create it on first use. The
958
+ * returned object stays valid across pool eviction and `release()` and
959
+ * transparently reopens the memory on its next verb.
960
+ */
961
+ memory(db: string): WorkspaceMemory
962
+ /**
963
+ * Evicts one inactive memory from the pool and releases its file lock.
861
964
  *
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.
965
+ * Logical references remain valid. A later verb reopens the memory. If a
966
+ * verb is currently using it, this throws a typed `BUSY` error instead of
967
+ * waiting.
867
968
  */
868
- open(db: string, create?: boolean | undefined | null): Promise<Plugmem>
969
+ release(db: string): boolean
869
970
  /**
870
971
  * Every memory in the directory, sorted by name.
871
972
  *
@@ -934,12 +1035,10 @@ export declare class Workspace {
934
1035
  /** How many memories are open right now. */
935
1036
  openCount(): number
936
1037
  /**
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.
1038
+ * Invalidates every [`WorkspaceMemory`] reference, closes inactive pooled
1039
+ * memories and the registry, and closes the workspace. A verb already in
1040
+ * flight finishes with its scoped handle; its lock is released when that
1041
+ * verb returns. Every later call throws `CLOSED`.
943
1042
  */
944
1043
  close(): void
945
1044
  }
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.9.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.9.0",
47
+ "plugmem-linux-arm64-gnu": "0.9.0",
48
+ "plugmem-darwin-x64": "0.9.0",
49
+ "plugmem-darwin-arm64": "0.9.0",
50
+ "plugmem-win32-x64-msvc": "0.9.0",
51
+ "plugmem-win32-arm64-msvc": "0.9.0"
52
52
  }
53
53
  }