@staix/vault-mem 0.2.3 → 0.3.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.3.0] - 2026-11-01
6
+
7
+ - A vault whose PII policy has members this build does not know (from a newer-format config) fails closed: note writes, `check`, tidy, `promote-from-amem`, `promote`, move and delete and log appends on it are refused with `PII_POLICY` instead of screening with an incomplete policy. Reads and `vaults pii` show still work.
8
+ - Versions coexist on one machine (#82). `vmem mcp` loads every module a tool can run at startup, the agent-mem root included, so a running server keeps its own code and tool list when its install is replaced or a newer release is installed beside it.
9
+ - `vmem setup <target> --root <path>` registers another install root, such as `<install>/releases/<version>/`, resolved to its real path. Without `--root` setup registers the running install as before.
10
+ - `vmem.config.json` accepts an optional `version` (format 1). A config from a newer format is read tolerantly and its unknown keys survive registry writes; at or below format 1 unknown keys stay errors. Log screening accepts the same extended PII policy, and `vaults pii --set` refuses to replace a policy whose unknown members it would drop.
11
+ - The derived index is `indexes/<sha256>-links<N>.sqlite`, named per link-resolution version, so two versions alternating on one vault no longer rebuild each other's links. The first run after upgrading builds it once.
12
+ - `vmem manifest --json` lists the version, the config format, the MCP tool names and the CLI commands.
13
+ - The embedded `@staix/agent-mem` pin moves from 0.11.0 to 0.17.0, at or above agent-mem's 0.13 floor for agent workflows.
14
+ - N-1: 0.2.4 runs beside this release. It keeps its own unversioned index file and reads the config this release writes, since this release never adds `version`; 0.2.4 refuses a config that carries `version` or any other key it does not know.
15
+
16
+ ## [0.2.4] - 2026-11-01
17
+
18
+ - Report names (`graph-report-YYMMDD.md`, `lint-report-YYMMDD.md`), graph trend rows, L11 staleness, `promoted_at` (agent-mem and org promotion) and the skill-candidate report date now use the local calendar date, the same clock as the log lines, instead of UTC. A run early in the morning in a timezone ahead of UTC is no longer dated the previous day. `options.today` and `now` overrides still apply. Skill mining's `unique_days` keeps the UTC day of each record's `...Z` timestamp, as Python's `.date()` does.
19
+ - Console plugin for `acon` (agent-console api 1), exported as `@staix/vault-mem/ui-plugin`: Overview, Search, Note, Promote, Lint and Log panels, inbox items for promotion candidates and lint alerts, palette search, and two-step `delete`, `move`, `promote` and `promote-from-amem` behind the console's confirm dialog.
20
+ - `vmem setup` writes, reports and undoes the console plugin manifest `$AGENTHUB_HOME/console/plugins/vmem.json`.
21
+
3
22
  ## [0.2.3] - 2026-10-31
4
23
 
5
24
  - Graph communities use a deterministic, dependency-free Leiden (modularity, resolution 1.0) instead of label propagation, which collapsed most of a real vault into one community. Every community is connected, ids are numbered by size then smallest member, and `vault-graph.json` and the report Overview record the partitioner, resolution and modularity. The vault graph, the workspace graph and skill mining share it.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  An Obsidian-compatible knowledge vault tool for agents and people. Requires Bun >=1.4.2. The full check (`bun run check`) needs Bun, git and tar; it includes a package dry-run and clean installed CLI/MCP smoke. Node/npm are used by the separate release workflow, not required for the ordinary Bun gate. CI runs on Ubuntu and macOS for pull requests and for pushes to `main`.
4
4
 
5
- Implemented: configuration, vault registration, lossless frontmatter parsing/emission, wiki-link resolution and derived SQLite FTS5 indexing with bounded read commands. Validated note writes, the append-only log, the `vmem mcp` stdio server, reversible `vmem setup` host registration and promotion from agent-mem (`promote-from-amem`) are also implemented below. Lint/graph analysis, promotion to org vaults (`vmem promote`) and explicit `vmem sync` are implemented below. The `acon` console remains planned under issues [#1](https://github.com/STAIxBWLB/vault-mem/issues/1) and [#2](https://github.com/STAIxBWLB/vault-mem/issues/2). Do not use this increment to replace an existing vault writer.
5
+ Implemented: configuration, vault registration, lossless frontmatter parsing/emission, wiki-link resolution and derived SQLite FTS5 indexing with bounded read commands. Validated note writes, the append-only log, the `vmem mcp` stdio server, reversible `vmem setup` host registration and promotion from agent-mem (`promote-from-amem`) are also implemented below. Lint/graph analysis, promotion to org vaults (`vmem promote`) and explicit `vmem sync` are implemented below. The vmem plugin for the `acon` console ([agent-console](https://github.com/STAIxBWLB/agent-console)) is described under "Console plugin"; vault-mem ships no console bin. Do not use this increment to replace an existing vault writer.
6
6
 
7
7
  ```sh
8
8
  bun install
@@ -25,7 +25,7 @@ Home precedence: `VMEM_HOME` (the pointer is not consulted), else a `home` point
25
25
  {"vaults":[{"id":"personal","path":"/absolute/vault","kind":"personal","write":"mcp"}],"denyPaths":[],"piiPatterns":[]}
26
26
  ```
27
27
 
28
- Configuration is limited to 64 KiB both on read and after serialization (a change whose pretty-printed result exceeds the limit is refused and the file is unchanged), rejects unknown keys, and is updated under an exclusive lock using a flushed temporary file and atomic rename. Errors name the config file or vault id and path but never echo config or note contents.
28
+ Configuration is limited to 64 KiB both on read and after serialization (a change whose pretty-printed result exceeds the limit is refused and the file is unchanged), rejects unknown keys, and is updated under an exclusive lock using a flushed temporary file and atomic rename. An optional top-level `version` (positive integer, absent means 1) names the config format; this build writes format 1 and never adds `version` itself, so 0.2.4 and older keep reading what it writes. A config whose `version` is newer than this build's format is read tolerantly: unknown keys at the top level, in vaults and in PII policies are kept and written back unchanged by registry commands, while the keys this build knows are still validated. A PII policy with members this build does not know cannot be enforced, so it fails closed: every write on that vault (note writes and `check`, two-step previews and confirms, tidy, `promote-from-amem`, `promote` and log appends) is refused with `PII_POLICY`, naming the members, until the newer vmem that wrote the policy does the write. Reads and `vaults pii` show are unaffected. `vaults pii --set` refuses to replace a policy that carries such members, since the replacement would drop them; change it with the newer vmem. Versions that add a key bump the format. Errors name the config file or vault id and path but never echo config or note contents.
29
29
 
30
30
  Durability: the temporary file is fsynced before the rename and the home directory is fsynced after it. On Linux this makes the new config durable once the command returns. On macOS directory fsync succeeds but does not flush the drive cache (no `F_FULLFSYNC`), so a power loss right after a change can revert to the previous complete config, never to a partial one. Windows is not a supported platform for durability claims; the rename stays atomic.
31
31
 
@@ -33,7 +33,7 @@ Crash recovery: the lock file records the writer's pid, host and start time. Not
33
33
 
34
34
  Vault fields: `id`, absolute `path` (must contain a real `notes/` directory), `kind` (`personal|org`), `write` (`mcp|cli|none`), optional `pii` (the `allow|deny` shorthand or a policy object, see PII policy below; default `allow` for personal and `deny` for org, and `status`/`vaults list` always report the effective shorthand and `piiPolicy` matrix, including for unhealthy entries), optional `remote` (any nonempty string such as an `https://` or `git@host:path` URL) and optional `registry` (absolute path). For healthy vaults, `doctor` checks locally that the vault is a git repository root with an origin whose fetch and push URLs all match `remote` (URL rewrites are refused), and that `registry` passes the same deny-path, size and project-id validation as log appends. Results appear under each vault's `checks.remote` and `checks.registry`; a failed check exits 1. These checks use no network and do not test reachability. `status` is unchanged. Policy fields are stored for later note-writing stages, not enforced by the read commands. Writer commands enforce write/channel policy and configured deny/PII screening.
35
35
 
36
- The only runtime dependency is the exact first-party pin `@staix/agent-mem@0.11.0`; it is not loaded by registry commands. Public library exports cover configuration, home resolution, registry operations, frontmatter, link resolution and the derived index. No console bin or UI contract is advertised before its implementation.
36
+ The only runtime dependency is the exact first-party pin `@staix/agent-mem@0.17.0`; it is not loaded by registry commands. Public library exports cover configuration, home resolution, registry operations, frontmatter, link resolution and the derived index. No console bin or UI contract is advertised before its implementation.
37
37
 
38
38
  # PII policy
39
39
 
@@ -87,7 +87,7 @@ bun bin/vmem.js links personal "Note or alias" --limit 50 --json
87
87
 
88
88
  All four commands require a registered vault id. They read only flat, regular `notes/*.md` files (case-sensitive `.md` extension), skip subdirectories and symlink files, and refuse a replaced vault/notes directory. Notes are limited to 2 MiB each. They never write note content, registry configuration or `.obsidian/`. `rebuild` rebuilds the derived cache; other commands scan metadata on every invocation and rehash new or metadata-changed files. Content-identical changes only update metadata; deleted/renamed paths leave the index. SQLite transactions make each scan atomic. A concurrently changing note aborts that scan, so retry after its writer finishes. No watcher, model calls or embeddings run.
89
89
 
90
- The cache is `$VMEM_HOME/indexes/<canonical-vault-path-sha256>.sqlite` using the resolved home, not necessarily the environment variable. Cache files use mode 0600 on creation. The location must be outside every registered vault, including through symlinks; linked cache directories/files are refused. It contains note text and is disposable private derived state. Removing it while commands are stopped allows the next read to build it again. SQLite uses its own transaction lock and a five-second busy timeout. There is no automatic migration or live vault cutover.
90
+ The cache is `$VMEM_HOME/indexes/<canonical-vault-path-sha256>-links<N>.sqlite` using the resolved home, not necessarily the environment variable. `N` is the link-resolution version (currently 2), so two installed vmem versions that resolve links differently keep separate caches instead of rebuilding each other's links; 0.2.4 and older use the unversioned `<sha256>.sqlite`, and a first run after upgrading builds the new file once. Cache files use mode 0600 on creation. The location must be outside every registered vault, including through symlinks; linked cache directories/files are refused. It contains note text and is disposable private derived state. Removing it while commands are stopped allows the next read to build it again. SQLite uses its own transaction lock and a five-second busy timeout. There is no automatic migration or live vault cutover.
91
91
 
92
92
  Search uses FTS5 `unicode61` over title, description and body with BM25 weights 5:2:1, ascending score and path tie-breaking. Whitespace-separated query terms are literal AND terms, not a raw FTS expression; quote the query as one shell argument. `--type` and `--domain` are exact scalar frontmatter filters. Results include a bounded snippet and metadata, not the full body. Search queries and note targets accept at most 1024 UTF-8 bytes. Search/links default to 50 results, with an enforced 1..500 range. Show defaults to 16384 UTF-8 bytes and permits 1..65536, returning `truncated` and the full note SHA-256. `--section "Exact H2"` selects one real H2 through the next H1/H2, including its H3 children; code-fenced headings are ignored and duplicate/missing names refuse. Truncated reads include bounded `headings` (at most 64 names/8KiB metadata; `headingsTruncated` when omitted) and `nextOffset`. `--offset N` is a UTF-8 byte offset within the selected section or whole note; use the returned `nextOffset` to page even a single section larger than 64 KiB, and compare note hashes across pages. Invalid UTF-8 boundaries and zero-progress byte budgets refuse. Link string fields are capped at 1024 bytes in command results. Oversized arguments fail instead of silently raising a bound.
93
93
 
@@ -146,7 +146,7 @@ Readers never create a home, log or lock, and work with `write: none`. They skip
146
146
 
147
147
  # MCP server
148
148
 
149
- `vmem mcp` serves newline-delimited JSON-RPC 2.0 (MCP tools, protocol 2025-06-18 and earlier) on stdio. stdout carries protocol messages only; it needs no TTY and runs in agent sessions. Requests are limited to 64 KiB per frame and results to 256 KiB (a larger result becomes a structured `LIMIT` refusal). Arguments are validated against each tool's bounded schema (`additionalProperties: false`, string/array/integer caps); invalid arguments or unknown tools return JSON-RPC `-32602`, unknown methods `-32601`, malformed frames `-32700`/`-32600`. The `@staix/agent-mem` root is never loaded at startup.
149
+ `vmem mcp` serves newline-delimited JSON-RPC 2.0 (MCP tools, protocol 2025-06-18 and earlier) on stdio. stdout carries protocol messages only; it needs no TTY and runs in agent sessions. Requests are limited to 64 KiB per frame and results to 256 KiB (a larger result becomes a structured `LIMIT` refusal). Arguments are validated against each tool's bounded schema (`additionalProperties: false`, string/array/integer caps); invalid arguments or unknown tools return JSON-RPC `-32602`, unknown methods `-32601`, malformed frames `-32700`/`-32600`. At startup it loads every module a tool can run, the `@staix/agent-mem` root included, so a server keeps its own code and tool list when its install is replaced or a newer release is installed beside it; restart the host session to pick up the new release.
150
150
 
151
151
  The vault set is read once at startup: every tool's `vault` property is an enum of the registered ids and defaults to the first `personal` vault. Restart the host session after `vaults add/remove`. Reads go through the same index as the CLI; writes go through `applyWrite`/`appendLog` with channel `mcp`, so they require `write: mcp` and pass the same V01-V10 gates. Every tool returns a JSON result: a receipt, or `{refused: true, issues}` for policy, validation or lookup failures.
152
152
 
@@ -183,14 +183,17 @@ vmem setup claude # dry-run: lists the file and change per host, writ
183
183
  vmem setup all --apply # claude codex kimi pi qwen jcode
184
184
  vmem setup agy --apply # run inside the workspace; writes ./.agents/mcp_config.json
185
185
  vmem setup all --undo
186
+ vmem setup all --root ~/.local/share/vmem/releases/0.3.0 --apply # register a versioned install root
186
187
  ```
187
188
 
188
- Setup registers one server entry named `vmem` (`<bun> --smol <package>/bin/vmem.js mcp`) by editing the host's own MCP configuration: `~/.claude.json` (`mcpServers`; `$CLAUDE_CONFIG_DIR/.claude.json` when set), `$CODEX_HOME/config.toml` (`[mcp_servers.vmem]`), `$KIMI_CODE_HOME/mcp.json`, `$PI_CODING_AGENT_DIR/mcp.json`, `$QWEN_HOME/settings.json` (all `mcpServers`), `$JCODE_HOME/mcp.json` (`servers`) and the workspace's `.agents/mcp_config.json` for Antigravity. Defaults are `~/.codex`, `~/.kimi-code`, `~/.pi/agent`, `~/.qwen` and `~/.jcode`; overrides must be absolute. It does not call host CLIs and does not need a TTY. Run it while the host is closed: Claude Code in particular rewrites `.claude.json` while it runs and can overwrite a concurrent change.
189
+ Setup registers one server entry named `vmem` (`<bun> --smol <package>/bin/vmem.js mcp`) by editing the host's own MCP configuration: `~/.claude.json` (`mcpServers`; `$CLAUDE_CONFIG_DIR/.claude.json` when set), `$CODEX_HOME/config.toml` (`[mcp_servers.vmem]`), `$KIMI_CODE_HOME/mcp.json`, `$PI_CODING_AGENT_DIR/mcp.json`, `$QWEN_HOME/settings.json` (all `mcpServers`), `$JCODE_HOME/mcp.json` (`servers`) and the workspace's `.agents/mcp_config.json` for Antigravity. Defaults are `~/.codex`, `~/.kimi-code`, `~/.pi/agent`, `~/.qwen` and `~/.jcode`; overrides must be absolute. It does not call host CLIs and does not need a TTY. `--root <absolute path>` registers another vault-mem install root instead of the running one, for example a versioned `<install>/releases/<version>/` that a newer release is installed beside; the root is resolved to its real path (a `current` link registers the release it names) and must hold the package's `package.json` (a plain file of at most 64 KiB) and `bin/vmem.js`. The report names the root and its version. The current registration changes only on `--apply`; running servers keep their release until the host session restarts. Run it while the host is closed: Claude Code in particular rewrites `.claude.json` while it runs and can overwrite a concurrent change.
189
190
 
190
- Apply is idempotent and touches only the `vmem` entry: other servers and settings are preserved (TOML is edited textually, so comments and other tables stay byte-identical; JSON is re-serialized with its indentation). Configuration files must be plain strict JSON or TOML; links, JSONC and unparsable files are refused per host without writing, and the command exits 1. Apply records the prior `vmem` value in a receipt, `$VMEM_HOME/setup/<host>-<hash of the config path>.json`, so separate host profiles (for example two `CODEX_HOME`s) sharing one `VMEM_HOME` keep separate receipts. The receipt is written before the host file; if the host write fails, the receipt is rolled back. In TOML, every `[mcp_servers.vmem]` and `[mcp_servers.vmem.*]` table belongs to the entry, even when other tables sit between them. Undo restores that prior value (or removes the entry, and the file if setup created it and nothing else was added) only while the entry is still the one setup installed; an entry edited later is left alone and reported as `refused`. No console plugin manifest is written because vmem ships no console plugin yet.
191
+ Apply is idempotent and touches only the `vmem` entry: other servers and settings are preserved (TOML is edited textually, so comments and other tables stay byte-identical; JSON is re-serialized with its indentation). Configuration files must be plain strict JSON or TOML; links, JSONC and unparsable files are refused per host without writing, and the command exits 1. Apply records the prior `vmem` value in a receipt, `$VMEM_HOME/setup/<host>-<hash of the config path>.json`, so separate host profiles (for example two `CODEX_HOME`s) sharing one `VMEM_HOME` keep separate receipts. The receipt is written before the host file; if the host write fails, the receipt is rolled back. In TOML, every `[mcp_servers.vmem]` and `[mcp_servers.vmem.*]` table belongs to the entry, even when other tables sit between them. Undo restores that prior value (or removes the entry, and the file if setup created it and nothing else was added) only while the entry is still the one setup installed; an entry edited later is left alone and reported as `refused`. Every mode also handles the console plugin manifest (see "Console plugin"): apply writes it, undo removes it while it still points at this install.
191
192
 
192
193
  `vmem completion bash|zsh|fish` prints a completion script generated from the same command table as `--help`.
193
194
 
195
+ `vmem manifest --json` prints `{name, version, configVersion, mcp: {server, tools}, commands}`: the package version, the config format it writes, the MCP tool names and the CLI command names from the same table, for tools that would otherwise hard-code them.
196
+
194
197
  # Promotion from agent-mem
195
198
 
196
199
  `vmem promote-from-amem` turns one durable agent-mem entry into a vault note and records agent-mem's own vault link back to it. Agent memory is never indexed or copied into a second store, and no agent-mem tool is added: candidates come from agent-mem's `vaultCandidates` selection and the link from its `recordVaultLink`.
@@ -202,13 +205,23 @@ vmem promote-from-amem personal mem:<id> ... --confirm --fingerprint <sha>
202
205
  vmem promote-from-amem personal mem:<id> --link-to existing-note # link an existing note instead of creating one
203
206
  ```
204
207
 
205
- - `src/amem/seam.ts` is the only module that imports the `@staix/agent-mem` root (exact pin 0.11.0), and it is loaded lazily: `vmem mcp`, reads, writes and every other command never load it. The project defaults to agent-mem's key for the current directory (`--project` overrides); `AMEM_HOME` selects the memory home as in agent-mem.
208
+ - `src/amem/seam.ts` is the only module that imports the `@staix/agent-mem` root (exact pin 0.17.0, at or above agent-mem's 0.13 floor for agent workflows). Reads, writes and other commands never load it; promotion and mining load it when they run, and `vmem mcp` loads it at startup. The project defaults to agent-mem's key for the current directory (`--project` overrides); `AMEM_HOME` selects the memory home as in agent-mem.
206
209
  - Listing and linking require agent-mem's generation profile; a shared-v1 memory home is refused. Candidate listing applies agent-mem's durable selection and policy (secrets, default PII and the vault config's `piiPatterns`) before ordering and `--limit`; previews screen the requested entry with the same policy.
207
210
  - The preview prefills the H1 and stem from the entry title, the body from its text, `about[]` as Relevant Notes hints, and provenance `promoted_from: mem:<id>` plus `promoted_at`. `--stem --type --domain --topics --description --title` and `--body file|-` override it; none of this needs a TTY. When a note with the same stem or title exists, the preview is refused with V08 and lists `link-existing <path>` choices; `--link-to` takes one. `--edit` opens `$VISUAL`/`$EDITOR` on the draft only when stdin is a terminal (the only TTY check); the edited text is previewed and confirmed with `--body`.
208
211
  - Confirm requires the preview fingerprint, then creates the note through `applyWrite` (V01-V10), appends one `LEARN <project> mem:<id> → notes/<stem>.md <em-dash> promoted: <kind>` event (`--log-project` sets the project field; it defaults to the last segment of the agent-mem project key) and records the vault link last.
209
212
  - If the final link fails, the note stays, the result reports `LINK` and `link-only notes/<stem>.md`, and the entry stays a candidate. A rerun finds the note through `promoted_from` and offers link-only, never a second note or a second LEARN event.
210
213
  - `vmem_promote_from_amem` is the same two steps over MCP and needs a vault with `write: mcp`.
211
214
 
215
+ ## Console plugin
216
+
217
+ `vmem setup <target> --apply` also writes `$AGENTHUB_HOME/console/plugins/vmem.json` (default `~/.agenthub`; directory 0700, file 0600) with the absolute path and sha256 of `src/ui/plugin.ts`. `acon ui` then shows a Vault area. Re-run setup after upgrading vmem: acon refuses a module whose hash changed.
218
+
219
+ - Panels: Overview (vaults, maps, recent log), Search, Note (body, links, actions), Promote (agent-mem candidates), Lint (typed findings linked to notes) and Log (parsed rows).
220
+ - Inbox: promotion candidates (group `candidate`), lint errors and a stale graph report (group `alert`). The palette searches notes.
221
+ - Actions: `delete`, `move`, `promote` (to an org vault) and `promote-from-amem` reuse the two-step MCP tools. Each first answers a preview with the planned changes; the write happens only after the console's confirm dialog, and only while the fingerprint still matches.
222
+ - The provider calls the `vmem mcp` handlers in process, so the console has exactly the MCP rights: writes need a vault with `write: mcp`, and log lines carry the agent attribution.
223
+ - The module is exported as `@staix/vault-mem/ui-plugin`. Lint results are cached for 60 seconds per vault.
224
+
212
225
  ## Lint and graph analysis
213
226
 
214
227
  `vmem lint <vault>` checks L01/L02/L03/L09/L10/L11/L12 and L13 (lowercase ASCII kebab-case filenames). `--note notes/example.md` limits note findings. Section names and `SUMMARY errors=N warnings=N` follow the Python reference. `--write` writes a screened lint report and LINT event; `--dry-run` previews without persistence. Findings may identify paths, link targets and malformed log prefixes. Note bodies are never included in analysis results.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/vault-mem",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Validated Obsidian-compatible knowledge vaults for agents and people",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,14 +12,16 @@
12
12
  "bun": ">=1.4.2"
13
13
  },
14
14
  "dependencies": {
15
- "@staix/agent-mem": "0.11.0"
15
+ "@staix/agent-mem": "0.17.0"
16
16
  },
17
17
  "devDependencies": {
18
+ "@staix/agent-console": "github:STAIxBWLB/agent-console#bd7234f",
18
19
  "@types/bun": "^1.3.0",
19
20
  "typescript": "^5.8.0"
20
21
  },
21
22
  "exports": {
22
- ".": "./src/index.ts"
23
+ ".": "./src/index.ts",
24
+ "./ui-plugin": "./src/ui/plugin.ts"
23
25
  },
24
26
  "bin": {
25
27
  "vmem": "bin/vmem.js"
package/src/amem/seam.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The only module that imports the `@staix/agent-mem` root. It is loaded lazily by promotion, so
3
- * `vmem mcp`, reads and writes never pay for it. No agent memory is indexed or copied here: the seam
4
- * lists candidates from amem's own selection and records amem's own vault link.
2
+ * The only module that imports the `@staix/agent-mem` root. Promotion and mining load it lazily, so reads,
3
+ * writes and other commands never pay for it; `vmem mcp` loads it at start (`preloadAmem`). No agent memory
4
+ * is indexed or copied here: the seam lists candidates from amem's own selection and records amem's own vault link.
5
5
  */
6
6
  import type { MemoryStore, View } from "@staix/agent-mem";
7
7
 
@@ -17,6 +17,8 @@ export interface AmemSeam {
17
17
  }
18
18
  export interface AmemOptions { env?: NodeJS.ProcessEnv; project?: string; cwd?: string; piiPatterns?: string[] }
19
19
  const ID = /^[a-f0-9]{32}$/;
20
+ /** Load the agent-mem root now. `vmem mcp` calls it at start; every other caller keeps the lazy load. */
21
+ export async function preloadAmem(): Promise<void> { await import("@staix/agent-mem"); }
20
22
  function view(value: View): AmemCandidate {
21
23
  return { id: value.id, project: value.project, kind: value.kind, title: value.title, text: value.text, about: value.about.map(node => String(node)), at: value.at };
22
24
  }
@@ -1,3 +1,4 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { MAX_REPORT_BYTES } from "./write-validation.ts";
2
3
  import { parseFrontmatter } from "./frontmatter.ts";
3
4
  import { sourceWorkspaceRoot, missingSourceWarnings, sourceReferences } from "./source.ts";
@@ -35,7 +36,7 @@ export function analysisInput(home: string, id: string, options: { today?: strin
35
36
  if (!vault) throw new Error("analysis: unknown vault id");
36
37
  if (options.expectedVault && JSON.stringify(options.expectedVault) !== JSON.stringify(vault)) throw new Error("analysis: startup vault binding changed; restart MCP");
37
38
  const identity = lstatSync(vault.path);
38
- const today = options.today ?? new Date().toISOString().slice(0,10);
39
+ const today = options.today ?? localDate();
39
40
  if (!/^\d{4}-\d{2}-\d{2}$/.test(today) || !Number.isFinite(Date.parse(today)) || new Date(today).toISOString().slice(0,10) !== today) throw new Error("analysis: invalid date");
40
41
  permitted(vault.path, config);
41
42
  // Check denied paths before opening the index or reading note sources.
package/src/cli.ts CHANGED
@@ -6,7 +6,9 @@ import { runOrgCommand } from "./org-commands.ts";
6
6
  import { isWriteCommand, runWriteCommand } from "./write-commands.ts";
7
7
  import { isReadCommand, runReadCommand } from "./read-commands.ts";
8
8
  import { version } from "../package.json";
9
- import { completionScript, help } from "./commands-table.ts";
9
+ import { COMMANDS, completionScript, help } from "./commands-table.ts";
10
+ import { VMEM_TOOL_NAMES } from "./mcp-vault.ts";
11
+ import { CONFIG_VERSION } from "./config.ts";
10
12
  import { runSetup, type SetupMode } from "./setup.ts";
11
13
  import { resolveHome } from "./home.ts";
12
14
  import { addVault, effectivePii, loadRegistry, recoverRegistry, registryStatus, removeVault, setVaultPii } from "./registry.ts";
@@ -28,12 +30,18 @@ function parse(args: string[], allowed: string[], values: string[]) {
28
30
  export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
29
31
  if (!args.length || (args.length === 1 && ["--help", "-h", "help"].includes(args[0]!))) return { text: help(), code: 0 };
30
32
  if (args.length === 1 && args[0] === "--version") return { text: version, code: 0 };
33
+ if (args[0] === "manifest") {
34
+ const { flags, positional } = parse(args.slice(1), ["--json"], []);
35
+ if (positional.length) throw new Error("manifest: unexpected arguments");
36
+ const manifest = { name: "@staix/vault-mem", version, configVersion: CONFIG_VERSION, mcp: { server: "vmem", tools: [...VMEM_TOOL_NAMES] }, commands: COMMANDS.map(c => c.name) };
37
+ return { text: JSON.stringify(manifest, null, flags["--json"] ? undefined : 2), code: 0 };
38
+ }
31
39
  if (args[0] === "setup") {
32
- const rest = args.slice(1), flags = rest.filter(a => a.startsWith("--")), positional = rest.filter(a => !a.startsWith("--"));
33
- if (positional.length !== 1 || flags.some(f => !["--apply", "--undo", "--json"].includes(f)) || new Set(flags).size !== flags.length || (flags.includes("--apply") && flags.includes("--undo"))) throw new Error("setup: requires one target and at most one of --apply/--undo");
34
- const mode: SetupMode = flags.includes("--apply") ? "apply" : flags.includes("--undo") ? "undo" : "dry-run";
35
- const report = runSetup(positional[0]!, mode, { home: resolveHome(env).home, env });
36
- return { text: JSON.stringify(report, null, flags.includes("--json") ? undefined : 2), code: report.actions.some(a => a.change === "refused") ? 1 : 0 };
40
+ const { flags, positional } = parse(args.slice(1), ["--apply", "--undo", "--json", "--root"], ["--root"]);
41
+ if (positional.length !== 1 || (flags["--apply"] && flags["--undo"])) throw new Error("setup: requires one target and at most one of --apply/--undo");
42
+ const mode: SetupMode = flags["--apply"] ? "apply" : flags["--undo"] ? "undo" : "dry-run";
43
+ const report = runSetup(positional[0]!, mode, { home: resolveHome(env).home, env, ...(typeof flags["--root"] === "string" ? { root: flags["--root"] } : {}) });
44
+ return { text: JSON.stringify(report, null, flags["--json"] ? undefined : 2), code: [...report.actions, report.console].some(a => a?.change === "refused") ? 1 : 0 };
37
45
  }
38
46
  if (args[0] === "completion") {
39
47
  if (args.length !== 2) throw new Error("completion: requires bash, zsh or fish");
@@ -107,9 +115,15 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
107
115
  }
108
116
  return { text: JSON.stringify(result, null, flags["--json"] ? undefined : 2), code };
109
117
  }
110
- /** `vmem mcp`: the vault set is fixed now; stdout carries protocol frames only. */
118
+ /**
119
+ * `vmem mcp`: the vault set is fixed now; stdout carries protocol frames only. Every module a tool handler
120
+ * loads lazily, the agent-mem root included, is loaded here at start, so an install replaced under a running
121
+ * server never mixes two versions in one process.
122
+ */
111
123
  export async function runMcp(env: NodeJS.ProcessEnv = process.env): Promise<void> {
112
- const [{ serveMcp }, { createVaultMcp }] = await Promise.all([import("./mcp.ts"), import("./mcp-vault.ts")]);
124
+ const [{ serveMcp }, { createVaultMcp }, , , , seam] = await Promise.all([import("./mcp.ts"), import("./mcp-vault.ts"),
125
+ import("./promote.ts"), import("./org-promote.ts"), import("./skills-mine.ts"), import("./amem/seam.ts")]);
126
+ await seam.preloadAmem();
113
127
  const server = createVaultMcp(resolveHome(env).home, env);
114
128
  await serveMcp({ tools: server.tools, handlers: server.handlers, name: "vmem", version });
115
129
  }
@@ -32,7 +32,8 @@ export const COMMANDS = [
32
32
  { name: "promote", usage: "promote <personal-vault> <note> --to ORG [--topics a,b] [--again] [--log-project ID] [--confirm --fingerprint SHA] [--json]", mutates: "confirm" },
33
33
  { name: "sync", usage: "sync <vault> [--message TEXT] [--confirm --fingerprint SHA] [--json]", mutates: "confirm" },
34
34
  { name: "mcp", usage: "mcp", mutates: false },
35
- { name: "setup", usage: "setup <claude|codex|kimi|pi|qwen|jcode|agy|all> [--apply|--undo] [--json]", mutates: "apply" },
35
+ { name: "setup", usage: "setup <claude|codex|kimi|pi|qwen|jcode|agy|all> [--root PATH] [--apply|--undo] [--json]", mutates: "apply" },
36
+ { name: "manifest", usage: "manifest [--json]", mutates: false },
36
37
  { name: "completion", usage: "completion <bash|zsh|fish>", mutates: false },
37
38
  ] as const;
38
39
  const RELATIONS = ["supports", "contradicts", "extends", "applies-to", "context-for"] as const;
package/src/config.ts CHANGED
@@ -19,14 +19,30 @@ export type PiiCell = PiiAction | Partial<Record<PiiSurface, PiiAction>>;
19
19
  export interface PiiPattern { name: string; regex: string; action?: PiiCell }
20
20
  /** Fine-grained PII policy: per category, optionally per surface; omitted cells take the vault default. */
21
21
  export interface PiiSpec { email?: PiiCell; phone?: PiiCell; custom?: PiiCell; patterns?: PiiPattern[]; allow?: string[] }
22
- export interface VmemConfig { home?: string; vaults: VaultSpec[]; denyPaths?: string[]; piiPatterns?: string[] }
22
+ /**
23
+ * The config format this build writes. A build that adds a key bumps it. A config whose `version` is newer
24
+ * than this build is read tolerantly: unknown keys are kept as they are and written back unchanged, so an
25
+ * older vmem can share a config with a newer one. At or below this version unknown keys stay errors (typos).
26
+ * An absent `version` means 1, and this build never adds one, so 0.2.4 and older keep reading what it writes.
27
+ */
28
+ export const CONFIG_VERSION = 1;
29
+ export interface VmemConfig { version?: number; home?: string; vaults: VaultSpec[]; denyPaths?: string[]; piiPatterns?: string[] }
23
30
  const controls = /[\x00-\x1f\x7f]/;
24
- function object(value: unknown, keys: string[], label: string): Record<string, unknown> {
31
+ function object(value: unknown, keys: string[], label: string, tolerant = false): Record<string, unknown> {
25
32
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected object`);
26
33
  const record = value as Record<string, unknown>;
27
- for (const key of Object.keys(record)) if (!keys.includes(key)) throw new Error(`${label}: unknown key ${key}`);
34
+ if (!tolerant) for (const key of Object.keys(record)) if (!keys.includes(key)) throw new Error(`${label}: unknown key ${key}`);
28
35
  return record;
29
36
  }
37
+ /** True when `config` is in a format newer than this build: its unknown keys are kept, not refused. */
38
+ export function newerFormat(config: unknown): boolean {
39
+ const version = config && typeof config === "object" ? (config as { version?: unknown }).version : undefined;
40
+ return typeof version === "number" && Number.isSafeInteger(version) && version > CONFIG_VERSION;
41
+ }
42
+ /** The keys of `record` this build does not know, kept only when the config is newer than this build. */
43
+ function unknown(record: Record<string, unknown>, keys: string[], tolerant: boolean): Record<string, unknown> {
44
+ return tolerant ? Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key))) : {};
45
+ }
30
46
  function string(value: unknown, label: string): string {
31
47
  if (typeof value !== "string" || !value.trim() || controls.test(value)) throw new Error(`${label}: expected nonempty string without control characters`);
32
48
  return value;
@@ -46,12 +62,12 @@ function strings(value: unknown, label: string): string[] | undefined {
46
62
  });
47
63
  }
48
64
  const ACTIONS = new Set(["allow", "warn", "deny"]);
49
- function cell(value: unknown, label: string): PiiCell {
65
+ function cell(value: unknown, label: string, tolerant = false): PiiCell {
50
66
  if (typeof value === "string") {
51
67
  if (!ACTIONS.has(value)) throw new Error(`${label}: expected allow, warn or deny`);
52
68
  return value as PiiAction;
53
69
  }
54
- const record = object(value, [...PII_SURFACES], label), result: Partial<Record<PiiSurface, PiiAction>> = {};
70
+ const record = object(value, [...PII_SURFACES], label, tolerant), result = unknown(record, [...PII_SURFACES], tolerant) as Partial<Record<PiiSurface, PiiAction>>;
55
71
  for (const surface of PII_SURFACES) if (record[surface] !== undefined) {
56
72
  if (typeof record[surface] !== "string" || !ACTIONS.has(record[surface] as string)) throw new Error(`${label}.${surface}: expected allow, warn or deny`);
57
73
  result[surface] = record[surface] as PiiAction;
@@ -59,54 +75,75 @@ function cell(value: unknown, label: string): PiiCell {
59
75
  return result;
60
76
  }
61
77
  /** Validate `vault.pii`: the `allow`/`deny` shorthand or a PiiSpec object. */
62
- export function parsePii(value: unknown): "allow" | "deny" | PiiSpec {
78
+ export function parsePii(value: unknown, tolerant = false): "allow" | "deny" | PiiSpec {
63
79
  if (value === "allow" || value === "deny") return value;
64
80
  if (typeof value === "string") throw new Error("vault.pii: expected allow, deny or a policy object");
65
- const raw = object(value, ["email", "phone", "custom", "patterns", "allow"], "vault.pii"), spec: PiiSpec = {};
66
- for (const key of ["email", "phone", "custom"] as const) if (raw[key] !== undefined) spec[key] = cell(raw[key], `vault.pii.${key}`);
81
+ const raw = object(value, PII_KEYS, "vault.pii", tolerant), spec: PiiSpec = unknown(raw, PII_KEYS, tolerant);
82
+ for (const key of ["email", "phone", "custom"] as const) if (raw[key] !== undefined) spec[key] = cell(raw[key], `vault.pii.${key}`, tolerant);
67
83
  if (raw.patterns !== undefined) {
68
84
  if (!Array.isArray(raw.patterns) || raw.patterns.length > 32) throw new Error("vault.pii.patterns: expected at most 32 patterns");
69
85
  const names = new Set<string>();
70
86
  spec.patterns = raw.patterns.map((item, index) => {
71
- const p = object(item, ["name", "regex", "action"], `vault.pii.patterns[${index}]`);
87
+ const p = object(item, PATTERN_KEYS, `vault.pii.patterns[${index}]`, tolerant);
72
88
  const name = string(p.name, "vault.pii.patterns.name"), regex = string(p.regex, "vault.pii.patterns.regex");
73
89
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(name) || names.has(name)) throw new Error(`vault.pii.patterns: invalid or duplicate name ${name.slice(0, 40)}`);
74
90
  if (Buffer.byteLength(regex) > 256) throw new Error("vault.pii.patterns.regex: exceeds 256 bytes");
75
91
  try { new RegExp(regex, "u"); } catch { throw new Error(`vault.pii.patterns.${name}: invalid regular expression`); }
76
92
  names.add(name);
77
- return { name, regex, ...(p.action !== undefined ? { action: cell(p.action, `vault.pii.patterns.${name}.action`) } : {}) };
93
+ return { ...unknown(p, PATTERN_KEYS, tolerant), name, regex, ...(p.action !== undefined ? { action: cell(p.action, `vault.pii.patterns.${name}.action`, tolerant) } : {}) };
78
94
  });
79
95
  }
80
96
  if (raw.allow !== undefined) spec.allow = strings(raw.allow, "vault.pii.allow");
81
97
  return spec;
82
98
  }
83
- export function parseVault(value: unknown): VaultSpec {
84
- const v = object(value, ["id", "path", "kind", "write", "pii", "remote", "registry"], "vault");
99
+ const PII_KEYS = ["email", "phone", "custom", "patterns", "allow"], PATTERN_KEYS = ["name", "regex", "action"];
100
+ /** Members of a parsed policy that this build does not know (kept from a newer-format config), as dotted paths. */
101
+ export function piiExtensions(pii: VaultSpec["pii"]): string[] {
102
+ if (!pii || typeof pii !== "object") return [];
103
+ const extra = (value: unknown, keys: readonly string[], at: string) => value && typeof value === "object" ? Object.keys(value).filter(key => !keys.includes(key)).map(key => at + key) : [];
104
+ const record = pii as Record<string, unknown>;
105
+ return [...extra(record, PII_KEYS, ""), ...(["email", "phone", "custom"] as const).flatMap(key => extra(record[key], PII_SURFACES, `${key}.`)),
106
+ ...(pii.patterns ?? []).flatMap(p => [...extra(p, PATTERN_KEYS, `patterns.${p.name}.`), ...extra(p.action, PII_SURFACES, `patterns.${p.name}.action.`)])];
107
+ }
108
+ /**
109
+ * Fail closed: a vault whose PII policy has members this build does not know (kept from a newer-format config)
110
+ * cannot be screened as that policy asks, so every write and log append on it is refused with this reason.
111
+ */
112
+ export function unenforceablePii(vault: VaultSpec): string | undefined {
113
+ const members = piiExtensions(vault.pii);
114
+ return members.length ? `vault ${vault.id}: PII policy has members this vmem cannot enforce (${members.slice(0, 8).join(", ")}); write with the newer vmem that wrote the policy` : undefined;
115
+ }
116
+ export function parseVault(value: unknown, tolerant = false): VaultSpec {
117
+ const keys = ["id", "path", "kind", "write", "pii", "remote", "registry"], v = object(value, keys, "vault", tolerant);
85
118
  const id = string(v.id, "vault.id");
86
119
  if (!/^[a-z][a-z0-9-]{0,31}$/.test(id)) throw new Error("vault.id: invalid id");
87
120
  if (v.kind !== "personal" && v.kind !== "org") throw new Error("vault.kind: expected personal or org");
88
121
  if (v.write !== "mcp" && v.write !== "cli" && v.write !== "none") throw new Error("vault.write: expected mcp, cli or none");
89
- const result: VaultSpec = { id, path: absolute(v.path, "vault.path"), kind: v.kind, write: v.write };
90
- if (v.pii !== undefined) result.pii = parsePii(v.pii);
122
+ const result: VaultSpec = { ...unknown(v, keys, tolerant), id, path: absolute(v.path, "vault.path"), kind: v.kind, write: v.write };
123
+ if (v.pii !== undefined) result.pii = parsePii(v.pii, tolerant);
91
124
  if (v.remote !== undefined) result.remote = string(v.remote, "vault.remote");
92
125
  if (v.registry !== undefined) result.registry = absolute(v.registry, "vault.registry");
93
126
  return result;
94
127
  }
95
128
  export function parseConfig(value: unknown): VmemConfig {
96
- const raw = object(value, ["home", "vaults", "denyPaths", "piiPatterns"], "config");
129
+ const keys = ["version", "home", "vaults", "denyPaths", "piiPatterns"];
130
+ const version = (value as Record<string, unknown> | null)?.version;
131
+ if (version !== undefined && (!Number.isSafeInteger(version) || (version as number) < 1)) throw new Error("config.version: expected a positive integer");
132
+ const tolerant = newerFormat(value), raw = object(value, keys, "config", tolerant);
133
+ const stamp = version === undefined ? {} : { version: version as number };
97
134
  if (raw.home !== undefined) {
98
- if (Object.keys(raw).length !== 1) throw new Error("config: home pointer must not contain vaults or policy");
99
- return { home: absolute(raw.home, "config.home"), vaults: [] };
135
+ if (["vaults", "denyPaths", "piiPatterns"].some(key => raw[key] !== undefined)) throw new Error("config: home pointer must not contain vaults or policy");
136
+ return { ...unknown(raw, keys, tolerant), ...stamp, home: absolute(raw.home, "config.home"), vaults: [] };
100
137
  }
101
138
  if (!Array.isArray(raw.vaults)) throw new Error("config.vaults: expected array");
102
- const vaults = raw.vaults.map(parseVault);
139
+ const vaults = raw.vaults.map(item => parseVault(item, tolerant));
103
140
  if (new Set(vaults.map(v => v.id)).size !== vaults.length) throw new Error("config: duplicate vault id");
104
141
  if (new Set(vaults.map(v => v.path)).size !== vaults.length) throw new Error("config: duplicate vault path");
105
142
  const denyPaths = strings(raw.denyPaths, "denyPaths"), piiPatterns = strings(raw.piiPatterns, "piiPatterns");
106
143
  for (const pattern of piiPatterns ?? []) {
107
144
  try { new RegExp(pattern); } catch { throw new Error("piiPatterns: invalid regular expression"); }
108
145
  }
109
- return { vaults, ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
146
+ return { ...unknown(raw, keys, tolerant), ...stamp, vaults, ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
110
147
  }
111
148
  /** Bounded descriptor read: reject links, devices and oversized/growing files. */
112
149
  export function readConfig(home: string): VmemConfig {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The local calendar date as YYYY-MM-DD, from the same local clock the vault log lines use (`log-format.ts`).
3
+ * Every user-facing calendar date (report names, trend rows, L11 staleness, `promoted_at`, skill reports) comes from here,
4
+ * so an early-morning run in a timezone ahead of UTC is not dated the previous day.
5
+ */
6
+ export function localDate(now: Date = new Date()): string {
7
+ const pad = (value: number, size = 2) => String(value).padStart(size, "0");
8
+ return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
9
+ }
package/src/log-format.ts CHANGED
@@ -1,6 +1,8 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { screenContent } from "./write-validation.ts";
2
- import { parsePii } from "./config.ts";
3
- function validPii(value: unknown): boolean { try { parsePii(value); return true; } catch { return false; } }
3
+ import { newerFormat, parsePii } from "./config.ts";
4
+ /** Known fields are always validated; members unknown to this build are accepted only from a newer-format config. */
5
+ function validPii(value: unknown, config: unknown): boolean { try { parsePii(value, newerFormat(config)); return true; } catch { return false; } }
4
6
  import type { LogContext, LogEvent } from "./log-types.ts";
5
7
  import type { WriteIssue } from "./write-types.ts";
6
8
 
@@ -33,7 +35,7 @@ export function formatLogEvent(event: LogEvent, context: LogContext): { line?: s
33
35
  }
34
36
  if ((context.channel !== undefined && context.channel !== "cli" && context.channel !== "mcp") ||
35
37
  !record(context.vault) || !["personal", "org"].includes(context.vault.kind) ||
36
- (context.vault.pii !== undefined && !validPii(context.vault.pii)) ||
38
+ (context.vault.pii !== undefined && !validPii(context.vault.pii, context.config)) ||
37
39
  !record(context.config) || !Array.isArray(context.config.vaults) ||
38
40
  (context.config.piiPatterns !== undefined && (!Array.isArray(context.config.piiPatterns) || context.config.piiPatterns.some(value => typeof value !== "string"))) ||
39
41
  (context.now !== undefined && !(context.now instanceof Date)) ||
@@ -49,7 +51,7 @@ export function formatLogEvent(event: LogEvent, context: LogContext): { line?: s
49
51
  return { issues: [error("L09", "invalid log timestamp")] };
50
52
  }
51
53
  const pad = (value: number, size = 2) => String(value).padStart(size, "0");
52
- const timestamp = `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
54
+ const timestamp = `${localDate(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
53
55
  const env = context.env ?? process.env;
54
56
  const agent = context.channel === "mcp" || AGENT_MARKERS.some(marker => typeof env[marker] === "string" && env[marker]!.length > 0);
55
57
  const line = `${timestamp} ${event.type} ${event.project} ${event.source}${event.dest === undefined ? "" : ` → ${event.dest}`} — ${event.note}${agent ? " (vmem:agent)" : ""}\n`;
@@ -1,3 +1,4 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { createHash } from "node:crypto";
2
3
  import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -225,7 +226,7 @@ export function promoteToOrg(home: string, request: OrgPromoteRequest, options:
225
226
  const transformed = dropUnresolvedLinks(parsed.body, value => targetNotes.resolved.has(linkKey(value)));
226
227
  // Body Topics section follows the target topics.
227
228
  const body = replaceTopicsSection(transformed.body, topics);
228
- const day = (options.now ?? new Date()).toISOString().slice(0, 10);
229
+ const day = localDate(options.now ?? new Date());
229
230
  const frontmatter: Record<string, unknown> = { topics, promoted_from: result.source, promoted_at: day, promoted_to: undefined };
230
231
  // Wiki-valued fields that point outside the target vault are dropped as well.
231
232
  for (const key of ["project", "superseded_by", "supersedes", "source_ref"]) {
package/src/promote.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { localDate } from "./local-date.ts";
1
2
  import { createHash } from "node:crypto";
2
3
  import type { VaultSpec } from "./config.ts";
3
4
  import { emitFrontmatter, parseFrontmatter } from "./frontmatter.ts";
@@ -63,7 +64,7 @@ export function draftNote(entry: AmemCandidate, meta: PromoteMetadata, now = new
63
64
  const topics = (meta.topics ?? []).map(value => value.startsWith("[[") ? value : `[[${value}]]`);
64
65
  const frontmatter: Record<string, unknown> = {
65
66
  title, description: meta.description ?? firstSentence(entry.text), type: meta.type ?? (entry.kind === "decision" ? "decision" : entry.kind === "fail" ? "method" : "insight"),
66
- ...(meta.domain ? { domain: meta.domain } : {}), topics, promoted_from: `mem:${entry.id}`, promoted_at: now.toISOString().slice(0, 10),
67
+ ...(meta.domain ? { domain: meta.domain } : {}), topics, promoted_from: `mem:${entry.id}`, promoted_at: localDate(now),
67
68
  };
68
69
  const hints = entry.about.length ? `${entry.about.slice(0, 20).map(node => `- ${node.replace(/[\r\n[\]]/g, " ")}`).join("\n")}\n` : "";
69
70
  const body = meta.body ?? `# ${title}\n\n${entry.text.trim()}\n\n## Relevant Notes\n\n${hints}\n## Topics\n\n${topics.map(topic => `- ${topic}`).join("\n")}\n`;
package/src/registry.ts CHANGED
@@ -2,7 +2,7 @@ import { closeSync, constants, fsyncSync, mkdirSync, openSync, readdirSync, read
2
2
  import { hostname } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
- import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parsePii, parseVault, readConfig, type VaultSpec, type VmemConfig } from "./config.ts";
5
+ import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parsePii, parseVault, piiExtensions, readConfig, type VaultSpec, type VmemConfig } from "./config.ts";
6
6
  import { loosenedCells, piiPolicy, piiShorthand } from "./pii.ts";
7
7
  export const LOCK_FILE = "config.lock";
8
8
  const TEMP = /^\.config-[0-9a-f-]{36}\.tmp$/;
@@ -178,6 +178,9 @@ export function setVaultPii(home: string, id: string, value: unknown, confirm =
178
178
  if (!vault) throw new Error(`${join(home, CONFIG_FILE)}: unknown vault id ${id}`);
179
179
  const before = piiPolicy(vault);
180
180
  if (next === undefined) return { vault: id, pii: piiShorthand(vault), piiPolicy: before };
181
+ // A replacement would drop policy members a newer vmem wrote and this build cannot carry over.
182
+ const extensions = piiExtensions(vault.pii);
183
+ if (extensions.length) throw new Error(`vault ${id}: PII policy has members this vmem does not know (${extensions.slice(0, 8).join(", ")}); change it with the newer vmem that wrote them`);
181
184
  const updated: VaultSpec = { ...vault, pii: next }, after = piiPolicy(updated);
182
185
  const loosened = loosenedCells(before, after), changed = JSON.stringify(before) !== JSON.stringify(after);
183
186
  if (confirm && changed) vault.pii = next;