@staix/vault-mem 0.0.0-bootstrap.0 → 0.1.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 ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-10-30
4
+
5
+ - Add vault registration, bounded configuration, home resolution, diagnostics and explicit unregister confirmation.
6
+ - Add lossless frontmatter, local wiki-link resolution and incremental SQLite FTS indexing.
7
+ - Add validated atomic note mutations, preview fingerprints and append-only attributed vault events.
8
+ - Add bounded CLI/MCP providers, reversible host setup and agent-mem promotion with provenance.
9
+ - Add indexed lint, deterministic graph reports, bounded neighborhoods and synthetic reference-parity checks.
10
+ - Reuse unchanged parsed notes and link analysis during write validation, refresh derived links only for affected sources, and recheck the staging snapshot by direct content comparison instead of rehashing.
11
+ - Resolve wiki links by note path only, matching Obsidian and lint L01; aliases remain lookup metadata for read commands, and new alias-only links are refused.
12
+ - Prepare release validation, clean-install package smoke and an opt-in npm OIDC workflow. Publication, private evidence and live cutover remain separate gates.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 STAIx Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,252 @@
1
- # @staix/vault-mem
1
+ # vault-mem
2
2
 
3
- Placeholder created so npm trusted publishing can be configured. Install a released version instead.
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
+
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.
6
+
7
+ ```sh
8
+ bun install
9
+ bun bin/vmem.js --help
10
+ bun bin/vmem.js vaults add personal /absolute/vault --kind personal --write mcp
11
+ bun bin/vmem.js status --json
12
+ bun bin/vmem.js doctor
13
+ bun bin/vmem.js vaults remove personal
14
+ bun bin/vmem.js vaults remove personal --confirm
15
+ bun bin/vmem.js recover # preview crash leftovers
16
+ bun bin/vmem.js recover --confirm
17
+ bun run check
18
+ ```
19
+
20
+ The first remove prints a preview; `--confirm` unregisters the vault. It never deletes vault files. Commands work without a TTY and in agent environments. Add requires an existing `notes/` directory, stores the canonical vault path, and rejects duplicate ids and paths. Doctor returns a nonzero status when a registered vault becomes unavailable or its notes directory is replaced by a symlink.
21
+
22
+ Home precedence: `VMEM_HOME` (the pointer is not consulted), else a `home` pointer in the default home's `vmem.config.json`, else the default home itself. The default home is `$AGENTHUB_HOME/vmem` when `AGENTHUB_HOME` is set, otherwise `~/.agenthub/vmem`. Home paths are reported absolute and lexically normalized (`.`, `..` and trailing slashes removed, symlinks not resolved); registered vault paths are stored and reported canonical (symlinks resolved). Environment paths and configured paths must be absolute. A redirect file contains only `home`; the target contains the registry and policy, and cannot redirect again. Missing config means an empty registry. Malformed, oversized, linked or nonregular config files fail closed.
23
+
24
+ ```json
25
+ {"vaults":[{"id":"personal","path":"/absolute/vault","kind":"personal","write":"mcp"}],"denyPaths":[],"piiPatterns":[]}
26
+ ```
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.
29
+
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
+
32
+ Crash recovery: the lock file records the writer's pid, host and start time. Nothing is cleaned up automatically. `vmem recover` previews the state; `vmem recover --confirm` removes the lock only when it names this host and a pid that no longer runs, and then removes leftover `.config-<uuid>.tmp` files. A live lock, a lock from another host, or an unreadable (older-format) lock is never removed and the command exits 1; confirm no writer runs, then delete `config.lock` by hand. No other file in the home or any vault is touched. If cleanup fails after an operation (for example the lock cannot be removed), the original error is kept and the cleanup failure, including a retained lock, is appended to it.
33
+
34
+ Vault fields: `id`, absolute `path` (must contain a real `notes/` directory), `kind` (`personal|org`), `write` (`mcp|cli|none`), optional `pii` (`allow|deny`; default `allow` for personal and `deny` for org, and `status`/`vaults list` always report the effective value, including for unhealthy entries), optional `remote` (any nonempty string such as an `https://` or `git@host:path` URL; stored only) and optional `registry` (absolute path; stored only). No sync exists yet, so `remote` and `registry` are not validated against a server and doctor does not check them; reachability checks apply once sync is implemented. 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
+
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.
37
+
38
+ # Indexed reads
39
+
40
+ ```sh
41
+ bun bin/vmem.js rebuild personal
42
+ bun bin/vmem.js search personal "project discovery" --type concept --domain research --limit 20
43
+ bun bin/vmem.js show personal "Note or alias" --max-bytes 16384
44
+ bun bin/vmem.js links personal "Note or alias" --limit 50 --json
45
+ ```
46
+
47
+ 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.
48
+
49
+ 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.
50
+
51
+ 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.
52
+
53
+ Link resolution uses NFC and case-insensitive filename stems only, as Obsidian and `vmem lint` L01 do. Frontmatter `aliases` (string or string array; legacy `alias` accepted) are not link targets; `show`, `links <target>` and `vmem_read` still find a note by alias. A link that only an alias matches is a missing-note link, so the writer refuses a new one and org promotion drops it to text. An optional `notes/` prefix and `.md` suffix are accepted. `[[target|label]]`, `[[target#heading]]`, `[[target#^block]]`, `![[target]]` and same-note anchors work. ATX/Setext headings and end-of-line block ids are checked; fenced code is skipped. Missing/ambiguous targets and missing anchors remain explicit with a reason. Stems that differ only by case or Unicode normalization are ambiguous. Links returns outgoing links in source order, with one-based body-relative `line` values (frontmatter excluded); it does not write backlinks or repair dead links.
54
+
55
+ `parseFrontmatter` retains source bytes alongside semantic YAML data using Bun's built-in YAML parser. `emitFrontmatter(document, changes)` returns the entire note, preserving omitted and semantically unchanged keys byte-identically (comments, quoting, sequences, one-level mappings, folded/literal/chomped scalars, BOM and line endings). Changed keys use canonical JSON-compatible YAML; `undefined` removes a key. Editing requires a block mapping, while an unchanged flow mapping round-trips. This is a library transformation only, not a note-write command. Synthetic fixtures and create/edit/delete/rename parity tests cover this increment; Linux/macOS CI remains the platform verification gate.
56
+
57
+
58
+ # Validated writes
59
+
60
+ All mutations use `applyWrite(home, request)`: per-vault `mutation.lock`, current-SHA checks, in-memory planning, final-snapshot validation/screening, same-directory flushed temporary files and atomic renames, directory flushes, derived-index refresh and a structured receipt. `write: mcp` allows CLI and MCP writes; `cli` allows CLI only; `none` refuses mutations. `check` is read-only and works with `none`. No command requires a TTY or rejects agent environments. This increment runs on fixtures; it does not authorize a live vault cutover.
61
+
62
+ ```sh
63
+ vmem new personal example-finding --type insight --domain research --topics research --description "A distinct supporting observation."
64
+ vmem new personal another-finding --type insight --domain research --topics research --description "An independently sourced result." --body body.md
65
+ vmem patch personal example-finding --old "This note argues that" --new "This finding states that" --expected-sha <sha256>
66
+ vmem append personal example-finding --section Evidence --markdown "Additional evidence."
67
+ vmem frontmatter personal example-finding --set '{"confidence":"likely"}'
68
+ vmem link personal example-finding another-finding --relation supports
69
+ vmem supersede personal example-finding another-finding
70
+ vmem check personal candidate.md --path candidate
71
+ vmem report personal reports/result.md --body report.md
72
+ vmem move personal example-finding renamed-finding
73
+ vmem move personal example-finding renamed-finding --confirm --fingerprint <preview-fingerprint>
74
+ vmem delete personal renamed-finding
75
+ vmem tidy personal --connections
76
+ vmem tidy personal --all --json # migration sanitize preview (see Migration tidy)
77
+ ```
78
+
79
+ Syntax follows indexed reads: the vault id comes first. `new` supports `--title`, `--source`, `--confidence`, `--status`, `--extra JSON` and `--body file|-`; omitted body gets an H1/Relevant Notes/Topics skeleton. Patch accepts nonempty old/new strings up to 8192 bytes and requires exactly one occurrence (including overlapping occurrences). Append uses an existing section or inserts it before Topics; it refuses targeting or introducing a Topics H2. `frontmatter` supports `--set JSON`, `--unset key,key` and `--expected-sha`; append also supports `--expected-sha`. Link relations are `supports`, `contradicts`, `extends`, `applies-to`, `context-for`; links are bidirectional unless `--one-way`. Supersede sets `status`/`superseded_by` on the old note and adds a reverse link in the next note. Report permits plain paths below `reports/` or `ops/`, is schema-exempt and still screened. Inputs are bounded to 256 KiB, follow no file symlinks and accept stdin with `-`.
80
+
81
+ `--dry-run` plans and validates any writer without creating locks, temporary files, directories or caches. `check` always takes this path, even without `--dry-run`. Move, delete and tidy first return a preview with planned path/hash changes and a fingerprint; confirmation requires that fingerprint. It covers canonical operation input, the canonical vault path/identity and policy, expected SHAs, every scanned note and all touched files, so even a new inbound reference after preview forces another preview. Move rewrites visible wiki/heading/block/embed targets and wiki references in frontmatter across the vault, preserving explicit `|label` text, anchors and code. Alias-only links resolve to no note and are left as written. Tidy folds Connections sections into Relevant Notes. Delete refuses when it would newly break inbound links or MOC topics; fix those references first.
82
+
83
+ Validation returns named issues with severity and no matched secret text: V01 schema/enums/description; V02 existing MOC topics; V03 newly unresolved links; V04 flat ASCII note paths (maximum 200 bytes) and safe report paths; V05 final Topics placement/content (warning), with explicit append-to-Topics refusal; V06 pinned agent-mem secret regex parity and denied sources/input/snapshot paths; V07 email/phone/configured PII patterns when denied; V08 create duplicates (stem/NFKC H1); V09 256 KiB prospective size, exact patch and expected-SHA/drift; V10 supersession target integrity. Unchanged pre-existing broken links warn, including after a move. `possibleDuplicates` lists candidate paths for warnings from a disposable in-memory FTS5 query of title terms, with BM25 <= -0.000001, up to three paths; no disk cache is written for that check. `LOCKED`, `IO`, `ROLLBACK`, `INDEX` and `CLEANUP` identify operational failures. CLI returns exit 1 when `ok` is false.
84
+
85
+ A successful receipt reports `applied`, planned path/hash/byte changes, issues and `indexUpdated`. Unrelated malformed legacy YAML is skipped by duplicate hints and by writer index refresh, with a V01 warning naming the unchanged note; normal read/rebuild commands retain strict parsing. Move/tidy still require parseable inputs and name the offending path on refusal. Reads allocate each note's stat size plus one growth-probe byte, capped at the 2 MiB read limit plus one. A failed index refresh rolls back the file changes. Rollback first verifies each file still equals this operation's committed result; external edits are preserved. Failed/refused rollback retains the original backup and reports its relative recovery path. All files are staged before replacement and backups permit rollback of caught multi-file failures. Each individual rename is atomic; a machine/process crash between multiple renames can leave a partially completed batch. The retained lock and `.vmem-<uuid>.tmp` files require inspection and manual recovery after confirming the writer stopped. They are never automatically removed by registry recovery. Uncoordinated editors do not honor the lock; the pipeline rechecks SHAs before replacement, but this does not provide a filesystem transaction against concurrent external edits. Existing notes may be read up to the index's 2 MiB limit; prospective files are limited to 256 KiB, with at most 10000 flat notes per mutation snapshot. File/directory fsync has the platform guarantees described above. Successful changed mutations now append an event as described below; log failure is reported separately from note success.
86
+
87
+
88
+ # Append-only events
89
+
90
+ ```sh
91
+ vmem log append personal EXTRACT - synthetic:source "A synthetic event" --dest notes/example.md
92
+ vmem log tail personal --limit 50 --json
93
+ vmem log grep personal "EXTRACT|CONNECT" --type EXTRACT --limit 20
94
+ ```
95
+
96
+ Events keep the canonical local-time format `YYYY-MM-DD HH:MM TYPE project source → dest — note` (two spaces between fields, one LF at the end). Destination is optional. Allowed new types: `INGEST ROUTE EXTRACT CONNECT DIGEST LEARN LINT TASK GRAPH SYNC RETHINK SOURCE`. Project must be `-` or a lowercase alphanumeric/hyphen id; when `registry` is configured, ids must exist in its bounded YAML `projects`/nested `sub_projects` arrays. Registry files are limited to 1 MiB, eight nesting levels and 10000 unique ids; invalid or unavailable configuration fails closed. Invalid types/fields/projects use L09 issues, the 1000-byte line bound uses V09, and secret/PII refusals use V06/V07. Fields cannot inject pipes, control characters or two-space/column separators. Validation and screening run before a file or lock is created. The 1000 UTF-8-byte bound includes the LF and attribution.
97
+
98
+ Every MCP log append and CLI call with a nonempty `AGENTHUB_PEER_ID`, `CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`, `CODEX_THREAD_ID` or `PI_SESSION_ID` adds ` (vmem:agent)` to the note. Ordinary CLI events have no automatic suffix. Being an agent never causes refusal. Complete ISO timestamps are excluded from phone-number candidates, while real email/phone/configured PII matches remain refused when the vault denies PII.
99
+
100
+ `appendLog` validates the registered canonical vault and write/channel policy, acquires a separate `log.lock` (bounded two-second contention wait), then makes one O_APPEND write and fsyncs the log. New log files are mode 0600 and their parent directory is flushed. Linked/nonregular files and an incomplete existing final line are refused. Cooperating appenders share the lock; a short write is truncated back only when the same file still has the expected size, and its tail still equals our exact prefix, then flushed. Error-after-write cases inspect at most one event's bytes. Failed/ambiguous writes or cleanup are explicit; the receipt reports `appended` (possibly null when unknown), `synced`, `bytesWritten` (possibly null) and `rolledBack` when a partial append was removed. Before reporting sync, the current log and vault identities are rechecked; rotation produces LOG_DRIFT and an unknown current-path append state. A synced event can coexist with a cleanup error. No existing event is rewritten. A crash can retain `log.lock`, and failed short-write recovery can retain an incomplete line; inspect the file and stopped writer before manual recovery. There is no automatic cleanup or new TTY requirement.
101
+
102
+ Successful changed mutations default to EXTRACT, link to CONNECT, move to ROUTE, and supersede/tidy to RETHINK. Default events use project `-`, affected paths and the operation name rather than note text. Library callers can supply `WriteRequest.log` to override the event or `false` to disable it; CLI writers accept `--log JSON` for overrides. Preview/check/no-op/refused mutations never append. The append is bound to the mutation's resolved vault identity/policy, so re-registration cannot route its event to another vault. After a committed mutation, log append is independent: `ok`/`applied` describe the note operation, `log` holds its append receipt, and `logged` is true only when the event was appended and synced. A log error does not roll back a successfully saved note. CLI exits 1 for a log failure even when the note receipt says `ok: true`; inspect the receipt before retrying to avoid duplicate mutations/events. An override is validated at append time, not during a note-only dry run.
103
+
104
+ Readers never create a home, log or lock, and work with `write: none`. They skip blank/comment lines, return the last matching 1..200 records (default 50), accept an optional canonical TYPE and non-backtracking regex subset up to 256 characters (`|`, `^`, `$`, `.`, character classes, `\d`/`\s`/`\w` and their inverses, escaped literals, and atom `*`/`+`/`?`; groups, backreferences and brace quantifiers are refused), and scan a fixed descriptor-size snapshot. Streaming is capped at 32 MiB; individual historical lines over 4096 bytes and incomplete final lines are omitted with warnings, and the serialized line array stays within 128 KiB. Above 20000 data lines, the manual-archival warning is preserved. Read failure returns `dataLines: null` rather than claiming a count. Golden synthetic fixtures and concurrent/fault persistence tests cover the format and actual append state; no live vault log was read or modified for this increment.
105
+
106
+ # MCP server
107
+
108
+ `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.
109
+
110
+ 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.
111
+
112
+ | tool | arguments (caps) |
113
+ |---|---|
114
+ | `vmem_status` | none; vaults with health, write policy and effective pii |
115
+ | `vmem_search` | `query`<=1000, `vault`, `limit`<=50, `type`, `domain` |
116
+ | `vmem_read` | `path` (stem, alias or `notes/` path), `vault`, `maxBytes`<=65536; returns `{path, frontmatter, body, sha256, truncated}` |
117
+ | `vmem_read_many` | `paths`<=20 (8 KiB each), `vault` |
118
+ | `vmem_list` | `vault`, `prefix`, `limit`<=500 |
119
+ | `vmem_links` | `path`, `vault`, `direction` out/in/both, `limit`<=200; returns `{out, dead, in}` |
120
+ | `vmem_mocs` | `vault`; MOC notes with incoming link counts |
121
+ | `vmem_check` | `vault`, `path`, `text`<=256 KiB; validation only |
122
+ | `vmem_log_tail` | `vault`, `n`<=200, `type`, `grep`<=256 |
123
+ | `vmem_create` | `stem`, `type`, `domain`, `description`, `topics[]`<=32, `title`, `body`<=65536, `source`, `confidence`, `status`, `extra`, `log` |
124
+ | `vmem_patch` | `path`, `oldString`<=8192, `newString`<=8192, `expectedSha` |
125
+ | `vmem_append` | `path`, `section`<=120, `markdown`<=16384, `expectedSha` |
126
+ | `vmem_set_frontmatter` | `path`, `set`, `unset[]`, `expectedSha` |
127
+ | `vmem_link` | `from`, `to`, `relation`, `context`, `bidirectional` (default true), `log` |
128
+ | `vmem_supersede` | `old`, `next`, `log` |
129
+ | `vmem_log_append` | `type`, `project`, `source`, `dest`, `note`<=500 |
130
+ | `vmem_move` | `from`, `to`, `confirm`, `fingerprint` (two-step) |
131
+ | `vmem_delete` | `path`, `confirm`, `fingerprint` (two-step; the preview lists backlinks) |
132
+ | `vmem_promote` | `path` (in the personal vault), `to` (enum of org vaults), `topics[]`, `again`, `confirm`, `fingerprint` (two-step; never pushes) |
133
+ | `vmem_promote_from_amem` | `id`, `project`, `stem`, `type`, `domain`, `topics[]`, `description`, `title`, `body`<=65536, `linkTo`, `confirm`, `fingerprint` (two-step) |
134
+
135
+ Move and delete without `confirm` write nothing and return the preview and `fingerprint`; `confirm: true` requires the fingerprint recomputed on the current state, otherwise the call is refused with `V09`. `vmem_promote_from_amem` is described under Promotion from agent-mem. `vmem_promote` is described under Org promotion and sync. Graph/lint tools are listed below. Note paths in read results are relative to `notes/`.
136
+
137
+ # Host setup
138
+
139
+ ```sh
140
+ vmem setup claude # dry-run: lists the file and change per host, writes nothing
141
+ vmem setup all --apply # claude codex kimi pi qwen jcode
142
+ vmem setup agy --apply # run inside the workspace; writes ./.agents/mcp_config.json
143
+ vmem setup all --undo
144
+ ```
145
+
146
+ 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.
147
+
148
+ 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.
149
+
150
+ `vmem completion bash|zsh|fish` prints a completion script generated from the same command table as `--help`.
151
+
152
+ # Promotion from agent-mem
153
+
154
+ `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`.
155
+
156
+ ```sh
157
+ vmem promote-from-amem personal # candidates of the current project (--project KEY | --all-projects, --limit 1..200)
158
+ vmem promote-from-amem personal mem:<id> --type decision --domain research --topics storage # preview: full note text + fingerprint
159
+ vmem promote-from-amem personal mem:<id> ... --confirm --fingerprint <sha>
160
+ vmem promote-from-amem personal mem:<id> --link-to existing-note # link an existing note instead of creating one
161
+ ```
162
+
163
+ - `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.
164
+ - 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.
165
+ - 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`.
166
+ - 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.
167
+ - 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.
168
+ - `vmem_promote_from_amem` is the same two steps over MCP and needs a vault with `write: mcp`.
169
+
170
+ ## Lint and graph analysis
171
+
172
+ `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.
173
+
174
+ `vmem graph build <vault> [--dry-run]` writes `reports/vault-graph.json`, a dated graph report, graph trend history and GRAPH events through `applyWrite`. Old dated reports are preserved. Trend history appends one row per build, including repeated builds on the same date. Output receipts expose partial report/log failure; these separate writes are not a single transaction. `vmem graph neighbors <vault> <stem> [--hops 1..2] [--limit 1..500]` reads a bounded neighborhood. Matching MCP tools are `vmem_lint`, `vmem_graph_build`, and `vmem_graph_neighbors`; MCP report writes require `write=mcp`.
175
+
176
+ Analysis consumes note sources from the derived SQLite index, including invalid-frontmatter rows retained separately from searchable notes. Normal scans also retain note sources in `analysis_notes`, roughly doubling cached source storage outside the vault; the cache remains derived and can be rebuilt. Read-only analysis and dry runs clone any existing derived cache through a readonly connection, then refresh changed notes in memory. A missing cache is built in memory. They create no cache, locks, reports or events. Auxiliary log, registry and report inputs use bounded plain-file reads. Snapshot limits are 10,000 notes / 64 MiB source, 1,000 reports / 16 MiB combined, and 32 MiB log; oversized inputs fail explicitly. Graph limits are 100,000 edges and 32 deterministic label-propagation rounds. MOCs are excluded from clustering; ties sort by node id. Graph extraction intentionally retains the reference's body-only legacy wiki-link semantics, distinct from the richer navigation resolver.
177
+
178
+ `bun scripts/lint-parity.ts` and `bun scripts/graph-parity.ts` compare the synthetic corpus against frozen Python reference oracles. Use `--reference /absolute/path/to/lint.py` or `--reference /absolute/path/to/build-graph.py` to run the unchanged Python tools directly (`PYTHON` selects an interpreter with PyYAML/networkx). Keep those references until a separate retirement decision after two consecutive zero-diff runs. Fixture parity does not establish private-corpus parity or authorize a live vault cutover. Private corpus evidence stays private; no note bodies belong in public fixtures.
179
+
180
+ Analysis uses YAML 1.1 scalar compatibility for Python parity, including implicit booleans, octal/sexagesimal numbers, floats and timestamps, while preserving quoted/block strings and merge keys. Explicit tags, complex/non-string mapping keys, recursive aliases and nesting beyond 64 levels are refused explicitly instead of reporting misleading findings. These bounded-parser refusals require manual resolution before parity can be established for an affected corpus.
181
+ # Org promotion and sync
182
+
183
+ Each vault is its own git repository. There is no cross-vault link syntax: `[[x]]` always resolves inside its own vault, so an org note cannot link back into a personal vault. Nothing is scheduled and nothing pushes on its own.
184
+
185
+ ```sh
186
+ vmem promote personal shared-method --to team # preview: full org text, dropped links, screen result, fingerprint
187
+ vmem promote personal shared-method --to team --confirm --fingerprint <sha>
188
+ vmem sync team # preview: remote check, branch, ahead/behind, paths to stage
189
+ vmem sync team --confirm --fingerprint <sha>
190
+ ```
191
+
192
+ `vmem promote` copies one note from a personal vault into the local clone of a writable org vault (`kind: org`, `write` not `none`):
193
+
194
+ - **Transform.** The last prose `## Topics` section (never one inside a code fence) is replaced with the target topics, which must name MOCs that already exist in the org vault (source topics by default, `--topics` to override); a missing section is appended. Body wiki links that do not resolve in the org vault become plain text (their label, if any); wiki-valued frontmatter (`project`, `supersedes`, `superseded_by`, `source_ref`) pointing outside it is dropped. All of this is listed in `droppedLinks`. The org note gets `promoted_from: "<personal>:notes/x.md"` and `promoted_at`.
195
+ - **Screen (fail closed).** It refuses: secrets; PII under the org vault's policy (org defaults to `deny`, plus configured `piiPatterns`); `type: person` or `domain: people` records; private correspondence (`source`/`source_ref` from `inbox/`, `outlook:`, `telegram(`, `kakao`); and internal meeting notes (`source` under `meetings/`). These are keyword heuristics; the org clone's `git diff` before `vmem sync` is the review surface. Body links that are dropped are also resolved in the personal vault: a link to a person/people, meeting or correspondence note refuses the promotion (reporting only the category and count), so their names never reach the org vault. A refusal or a preview writes nothing in either vault.
196
+ - **Confirm.** It requires the current fingerprint. Then it creates the org note through `applyWrite`, records `promoted_to: "<org>:notes/x.md"` on the source, and appends `SYNC <project> personal:notes/x.md → <org>:notes/x.md <em-dash> promoted: <type>` to both vault logs (`--log-project` sets the project, default `-`). The note is also linted against the org vault (`lintVault` over the org snapshot with the note overlaid, scoped to that note): any L01/L02/L13 error refuses the preview, and the lint result is returned again after the write. Vault-wide warnings do not block. A second promotion of the same note needs `--again`, and an existing org note is never overwritten. If the source provenance write failed after the org note was created, a rerun detects the org note's matching `promoted_from` and offers a provenance-only preview and confirm.
197
+ - **MCP.** `vmem_promote` does the same over MCP and needs `write: mcp` on both vaults.
198
+
199
+ `vmem sync <vault>` is the only command that commits or pushes:
200
+
201
+ - **Preview.** It requires a configured `remote` that every effective `origin` fetch and push URL matches (`remote.origin.pushurl` included), after normalising scheme, credentials, host case and, for host remotes only, a trailing `.git` (local and `file:` paths keep their case and their `.git` suffix, because `x` and `x.git` are different repositories). Any `url.*.insteadOf` or `pushInsteadOf` rewrite is refused, because the real push destination cannot be verified. A mismatch fetches and pushes nothing; the push URLs are part of the preview fingerprint and are checked again just before pushing. It refuses a detached HEAD or an in-progress rebase or merge, then fetches and previews the branch, ahead/behind counts, and the changed paths it will stage: only `notes/`, `reports/` and `log`. Anything else is listed under `ignored` and never staged. A changed file that matches the configured `denyPaths` or the default deny names refuses the whole sync (before hashing, staging, committing or pushing) and is reported only as a count; a denied vault root is never synced.
202
+ - **Confirm.** With the current fingerprint it commits those paths, rebases onto `origin/<branch>` when behind, pushes, then re-fetches and reports success only when the remote head equals the local head. Conflicting history aborts the rebase and keeps the local commit (`DIVERGED`). A refused push (`PUSH`) or a failed fetch (`FETCH`, for example missing credentials) reports `ok: false`.
203
+ - **Git hardening.** Git runs through agent-mem's hardened argv: hooks, fsmonitor, external diff, pager, credential helper and askpass are disabled, `GIT_TERMINAL_PROMPT=0` is set, and repository-redirecting `GIT_*` variables are removed. So sync uses ssh keys or a deploy key and never prompts.
204
+ - **Not decided by the tool.** Which org, visibility, remote and deploy key a vault uses are deployment decisions, and whether an agent may push a given org vault is the workspace's external-sending rule.
205
+
206
+
207
+
208
+ ## Release readiness
209
+
210
+ `bun run check` includes strict types, import boundaries, tests, package dry-run and a clean tarball installation with CLI/MCP reads, non-TTY mutation and stale-preview refusal. Package files are limited to runtime source/bootstrap, manifest, license, README and changelog. Tag validation additionally runs npm package dry-run and tests the exact npm tarball later used by the gated OIDC job. No release tag or publication is created by this readiness change. The tarball install resolves the pinned first-party dependency from the configured registry, so the full check requires network access.
211
+
212
+ - `bun run parity:release` checks same-snapshot frontmatter bytes, incremental notes/FTS/links against clean rebuild, and two consecutive synthetic Python-oracle lint/graph comparisons. External Python reference paths can be supplied with `--lint-reference` and `--graph-reference`; `--output` records aggregate evidence.
213
+ - `bun run release:verify` enforces committed runtime/method/external-parity evidence freshness and the declared synthetic thresholds at the explicit release gate. Ordinary tests validate evidence shape and internal sample consistency; routine source edits and version bumps do not require fresh benchmark evidence until release validation.
214
+ - `bun run bench:release --output <file.json>` measures a generated 5000-note corpus in the authorized Linux dgx02 container. It records actual MCP search round-trip timings (including metadata refresh), kernel RSS high-water, raw samples and environment; it exits nonzero when a synthetic threshold fails. Synthetic write timings are diagnostic and do not establish representative private-vault acceptance.
215
+ - [Readiness evidence](docs/release-readiness.md), [release workflow](docs/release-workflow.md) and [cutover checklist](docs/cutover-checklist.md) distinguish verified results from outstanding private/native/integration/deployment gates. Live cutover remains blocked.
216
+
217
+
218
+ ## Core operating compatibility
219
+
220
+ - Frontmatter accepts YAML double-quoted Unicode/control escapes, quoted keys and escaped line continuations while preserving unchanged source bytes. New non-ASCII values are emitted as literal characters. A count-only local private snapshot probe verified 717/717 parses and 0 round-trip differences; the private result contains no note names or bodies and is not included here.
221
+ - Existing unchanged V01 schema debt (unknown keys, off-enum values and other retained field violations) warns on unrelated edits and moves. Added/changed violations and explicitly assigned invalid fields error; create remains strict. Secret/PII/path safety checks stay enforced, including bounded screening of decoded metadata scalars and key/value context, so quoted escapes cannot hide prohibited values. No extra metadata keys are adopted or automatically migrated.
222
+ - `link` skips any requested side already linking the target in visible body content, including embeds and dated Connections sections. Topics and code references do not count. A missing reverse side is still added. A repeated request returns `unchanged: true`, no file changes and no event.
223
+ - `patch` refuses any match beginning inside frontmatter with a hint to use `vmem_set_frontmatter` or `frontmatter`; patching body text retains exact-one-match and SHA checks.
224
+ - `source` accepts one string or a nonempty list of up to 32 strings (512 UTF-8 bytes each). Each reference is a workspace-relative path, `mem:<id>` or an absolute URL. Explicit source assignments strip a leading `work/`; ordinary edits do not rewrite legacy values. CLI `new --source` accepts either a string or a JSON string array; MCP create and frontmatter updates accept arrays directly.
225
+ - With `registry` configured, source existence uses the nearest ancestor containing `workspace.config.yaml`, falling back to the registry directory for standalone fixtures. Missing relative paths warn as V11 on writes and L14 in lint; no referenced file content is read. Denied source references are refused/reported generically before existence warnings. URLs and memory ids are not fetched.
226
+ - MCP `vmem_read` supports `section`, `offset`, and `maxBytes`; section/page responses return raw `body` with `frontmatter: null`. CLI `show` uses `--section`, `--offset`, and `--max-bytes`. Responses retain the whole-note SHA; each payload stays bounded while sections beyond the first 64 KiB remain accessible from the derived index.
227
+ - `bun scripts/frontmatter-probe.ts --vault <vault> --output <private-counts.json>` is a read-only bounded local diagnostic. It stores aggregate counters/hash only, rejects unstable reads, and writes its private result outside note directories. Real note bodies must never be copied into fixtures or public evidence.
228
+
229
+ Runtime changes intentionally make `release:verify` report stale historical release evidence until the separately authorized release-time refresh. This core fix does not rerun a benchmark or authorize live cutover.
230
+
231
+ # Migration tidy
232
+
233
+ `vmem tidy <vault> [--connections --escapes --enums --keys --sources --topics --secrets --log | --all] [--map <file>] [--json] [--confirm --fingerprint SHA]` sanitizes debt from older, unvalidated writers. It is meant for the one-time V2 cutover and later imports.
234
+
235
+ - **Two steps.** Without `--confirm` it prints per-rule `counts`, a per-note change list (`key: old -> new`, dropped keys with their values, sections added) and `reported` items that were left unchanged; nothing is written. With `--confirm` the fingerprint must match: it covers the rules, the map file, and the before/after sha of every touched file (and of the log). Each note is then written through `applyWrite` (atomic per note, under the vault lock) and one `LINT` event is appended, for example `tidy: escapes 1, enums 13, keys 78, ...`.
236
+ - **Never blocked by one note.** A sanitized note that the writer would still refuse (for example an unknown key) is left unchanged, listed under `skipped` and `reported`, and the rest of the run proceeds. Counts and the `LINT` event cover only notes that are written.
237
+ - **No value leaks.** Every value echoed into a change line or report is screened: secret-looking values, and email/phone values in `pii: deny` vaults, print as `[redacted]`.
238
+ - **Restart-safe.** If a run is interrupted, every note is either fully old or fully new; a rerun previews only the rest. A second `--all` run previews zero changes.
239
+ - **Scope.** Only flat `notes/*.md` and, for `--log`, the vault `log` are touched; `.obsidian/`, `templates/` and `self/` never are. Nothing is committed; commit the receipt paths yourself.
240
+
241
+ | rule | default disposition |
242
+ |---|---|
243
+ | `--escapes` | values written with YAML escapes (`\uXXXX`, ...) or the old writer's trailing-backslash wraps are re-emitted as canonical single-line scalars with literal characters |
244
+ | `--enums` | `confidence`: `confirmed`/`verified` -> `proven`, `observed` -> `likely`, `possible`/`speculative`/`web_unverified` -> `experimental`, `a \| b` -> the lower one. Other `confidence`, `status` and `type` values are reported, not guessed |
245
+ | `--keys` | `date`/`record_date` -> `created` (dropped when equal, reported when different). `decided_date`/`decision_date`/`decided` -> `decided_at` on `type: decision`. `decided_at`/`decided_by` are schema keys for decisions, and `email`/`phone` for `type: person` in personal vaults (org promotion still refuses them). `related`/`relatedMeetings`/`github`: resolvable wiki targets become `## Relevant Notes` links (deduplicated), paths and URLs join `source`. `origin`, `ideation_stage`, `keywords`, `priority`, `deadline`, `completedDate`, `taskSourceType`, `submission_status` are dropped (values listed in the preview). Any other key is reported and kept |
246
+ | `--sources` | strip `work/`, split joined paths into a list (only when every part is a workspace path; URLs and `mem:` ids are never split), report paths that do not resolve |
247
+ | `--topics` | append `## Topics` listing the frontmatter topics where no such heading exists; mismatched sections are reported |
248
+ | `--connections` | fold `## Connections (...)` headings into `## Relevant Notes` |
249
+ | `--secrets` | replace V06 secret matches with `[redacted]`: in the body the match itself, in frontmatter the whole field value (the key stays, so the YAML stays valid). A match outside a field value, such as a YAML comment, is reported for manual repair. The preview names note and line, never the value. PII in personal vaults is left alone |
250
+ | `--log` | replace secret-looking substrings and email addresses in existing log lines with `[redacted]`, under the log lock (refused with V06 when `denyPaths` covers the log); legacy lines are otherwise kept byte for byte. Git history is not rewritten: rotate exposed credentials |
251
+
252
+ `--map <file>` overrides defaults with strict JSON `{"enums": {"confidence|status|type": {"from": "to"}}, "keys": {"key": "drop" | "keep" | {"rename": "key"}}}` (at most 64 KiB and 1000 entries). For `tidy`, `--log` selects the log rule; tidy always writes its own `LINT` event.
package/bin/vmem.js ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env -S bun --smol
2
+ const version = /^(\d+)\.(\d+)\.(\d+)/.exec(process.versions.bun ?? "");
3
+ if (!version || Number(version[1]) < 1 || (Number(version[1]) === 1 && (Number(version[2]) < 4 || (Number(version[2]) === 4 && Number(version[3]) < 2)))) {
4
+ console.error("vmem: Bun >=1.4.2 is required. Install Bun: https://bun.sh");
5
+ process.exit(1);
6
+ }
7
+ const { main, mainAsync, runMcp } = await import("../src/cli.ts");
8
+ if (process.argv.length === 3 && process.argv[2] === "mcp") {
9
+ try { await runMcp(); }
10
+ catch (error) { console.error(`vmem mcp: ${error instanceof Error ? error.message : "failed"}`); process.exitCode = 1; }
11
+ } else if (process.argv[2] === "promote-from-amem") process.exitCode = await mainAsync(process.argv.slice(2));
12
+ else process.exitCode = main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,10 +1,41 @@
1
1
  {
2
2
  "name": "@staix/vault-mem",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Placeholder that creates the package so trusted publishing can be configured; install 0.1.0 or later",
3
+ "version": "0.1.0",
4
+ "description": "Validated Obsidian-compatible knowledge vaults for agents and people",
5
5
  "license": "MIT",
6
+ "type": "module",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/STAIxBWLB/vault-mem.git"
10
+ },
11
+ "engines": {
12
+ "bun": ">=1.4.2"
13
+ },
14
+ "dependencies": {
15
+ "@staix/agent-mem": "0.11.0"
16
+ },
17
+ "devDependencies": {
18
+ "@types/bun": "^1.3.0",
19
+ "typescript": "^5.8.0"
20
+ },
21
+ "exports": {
22
+ ".": "./src/index.ts"
23
+ },
24
+ "bin": {
25
+ "vmem": "bin/vmem.js"
26
+ },
27
+ "files": [
28
+ "src/",
29
+ "bin/",
30
+ "README.md",
31
+ "CHANGELOG.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "check": "bun x tsc --noEmit && bun scripts/check-boundary.ts && bun test && bun pm pack --dry-run && bun scripts/package-smoke.ts",
36
+ "smoke:package": "bun scripts/package-smoke.ts",
37
+ "bench:release": "bun scripts/release-performance.ts",
38
+ "parity:release": "bun scripts/release-parity.ts",
39
+ "release:verify": "bun scripts/release-evidence.ts"
9
40
  }
10
41
  }
@@ -0,0 +1,56 @@
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.
5
+ */
6
+ import type { MemoryStore, View } from "@staix/agent-mem";
7
+
8
+ export interface AmemCandidate { id: string; project: string; kind: string; title: string | null; text: string; about: string[]; at: string }
9
+ export interface AmemSeam {
10
+ readonly project: string;
11
+ candidates(limit: number): Promise<AmemCandidate[]>;
12
+ /** The durable entry when it is still selectable by policy, linked or not. */
13
+ entry(id: string): Promise<AmemCandidate | undefined>;
14
+ linkedPath(id: string): string | undefined;
15
+ recordLink(id: string, path: string): Promise<string>;
16
+ close(): void;
17
+ }
18
+ export interface AmemOptions { env?: NodeJS.ProcessEnv; project?: string; cwd?: string; piiPatterns?: string[] }
19
+ const ID = /^[a-f0-9]{32}$/;
20
+ function view(value: View): AmemCandidate {
21
+ 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
+ }
23
+ /** Open the curated store of one project. Listing and linking both require the generation profile. */
24
+ export async function openAmem(options: AmemOptions = {}): Promise<AmemSeam> {
25
+ const amem = await import("@staix/agent-mem");
26
+ const env = options.env ?? process.env, base = amem.memoryHome(env);
27
+ const project = options.project ?? amem.memoryProjectKey(options.cwd ?? process.cwd());
28
+ if (!project.trim() || project.length > 400) throw new Error("amem: invalid project key");
29
+ const policy = amem.createStandalonePolicy({ ...(options.piiPatterns?.length ? { piiPatterns: [...options.piiPatterns] } : {}) });
30
+ const { existsSync } = await import("node:fs"), { join } = await import("node:path");
31
+ const store: MemoryStore = existsSync(join(base, "shards.json"))
32
+ ? amem.openShardStore({ memoryDir: base, project, deferPromotions: true })
33
+ : new amem.MemoryStore({ memoryDir: base, deferPromotions: true });
34
+ if (store.profile.kind !== "generation") { store.close(); throw new Error("amem: promotion requires a generation memory profile; run amem's migration first"); }
35
+ const selection = (limit?: number) => ({ project, policy, ...(limit !== undefined ? { limit } : {}) });
36
+ return {
37
+ project,
38
+ async candidates(limit) { return (await amem.vaultCandidates(store, selection(limit))).map(view); },
39
+ async entry(id) {
40
+ if (!ID.test(id)) return undefined;
41
+ const found = store.vaultEntries(project).find(row => row.view.id === id);
42
+ // The same policy screen as candidate listing runs before any entry text reaches a preview.
43
+ return found && await policy.screen(found.view) ? view(found.view) : undefined;
44
+ },
45
+ linkedPath(id) { return store.vaultLinks(project).find(row => row.target === id)?.path; },
46
+ recordLink(id, path) { return amem.recordVaultLink(store, id, path, selection()); },
47
+ close() { store.close(); },
48
+ };
49
+ }
50
+ /** Cross-project candidate table from amem's own selection, read-only. */
51
+ export async function allProjectCandidates(options: { env?: NodeJS.ProcessEnv; limit?: number; piiPatterns?: string[] }): Promise<{ project: string; candidates: AmemCandidate[] }[]> {
52
+ const amem = await import("@staix/agent-mem");
53
+ const policy = amem.createStandalonePolicy({ ...(options.piiPatterns?.length ? { piiPatterns: [...options.piiPatterns] } : {}) });
54
+ const result = await amem.vaultCandidatesAll(amem.memoryHome(options.env ?? process.env), { policy, ...(options.limit !== undefined ? { limit: options.limit } : {}) });
55
+ return result.projects.map(entry => ({ project: entry.project, candidates: entry.candidates.map(view) }));
56
+ }
@@ -0,0 +1,36 @@
1
+ import { analysisInput } from "./analysis-input.ts";
2
+ import { lintVault } from "./vault-lint.ts";
3
+ import { buildGraph, graphNeighbors } from "./vault-graph.ts";
4
+ import { writeAnalysisReports } from "./analysis-reports.ts";
5
+
6
+ export function runAnalysisCommand(command: string, args: string[], home: string, env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
7
+ const values: Record<string,string> = {}, flags = new Set<string>(), positional: string[] = [];
8
+ for (let i=0;i<args.length;i++) {
9
+ const word = args[i]!;
10
+ if (!word.startsWith("-")) { positional.push(word); continue; }
11
+ if (word in values || flags.has(word)) throw new Error("analysis: repeated option");
12
+ if (["--json","--dry-run","--write"].includes(word)) flags.add(word);
13
+ else if (["--note","--hops","--limit"].includes(word)) { const value = args[++i]; if (!value || value.startsWith("--")) throw new Error(`${word}: requires value`); values[word]=value; }
14
+ else throw new Error(`analysis: unknown option ${word}`);
15
+ }
16
+ if (command === "lint") {
17
+ if (positional.length !== 1 || values["--hops"] || values["--limit"]) throw new Error("lint: requires one vault id");
18
+ if (flags.has("--write")) { const result = writeAnalysisReports(home,{vault:positional[0]!,kind:"lint",note:values["--note"],dryRun:flags.has("--dry-run")},env); return {text:JSON.stringify(result),code:result.ok && (result.summary as {errors?:number}).errors === 0 ? 0 : 1}; }
19
+ const {snapshot} = analysisInput(home,positional[0]!);
20
+ const report = lintVault(snapshot,values["--note"]);
21
+ return {text:flags.has("--json") ? JSON.stringify(report) : report.markdown+"\n"+report.summary,code:report.errors ? 1 : 0};
22
+ }
23
+ const action = positional.shift();
24
+ if (values["--note"] || flags.has("--write")) throw new Error("graph: unsupported option");
25
+ if (action === "build") {
26
+ if (positional.length !== 1 || values["--hops"] || values["--limit"]) throw new Error("graph build: requires one vault id");
27
+ const result = writeAnalysisReports(home,{vault:positional[0]!,kind:"graph",dryRun:flags.has("--dry-run")},env);
28
+ return {text:JSON.stringify(result),code:result.ok ? 0 : 1};
29
+ }
30
+ if (action !== "neighbors" || positional.length !== 2) throw new Error("graph: use build <vault> or neighbors <vault> <stem>");
31
+ const hops = Number(values["--hops"] ?? 1), limit = Number(values["--limit"] ?? 50);
32
+ if ((hops !== 1 && hops !== 2) || !Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("graph neighbors: hops 1..2, limit 1..500");
33
+ const {snapshot} = analysisInput(home,positional[0]!);
34
+ const result = graphNeighbors(buildGraph(snapshot),positional[1]!,hops,limit);
35
+ return {text:JSON.stringify(result),code:0};
36
+ }
@@ -0,0 +1,82 @@
1
+ import { parseFrontmatter } from "./frontmatter.ts";
2
+ import { sourceWorkspaceRoot, missingSourceWarnings, sourceReferences } from "./source.ts";
3
+ import { parseAnalysisYaml } from "./analysis-yaml.ts";
4
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import type { VaultSpec, VmemConfig } from "./config.ts";
7
+ import type { AnalysisSnapshot } from "./analysis-types.ts";
8
+ import { canonicalPath, isDenied } from "./platform.ts";
9
+ import { loadRegistry } from "./registry.ts";
10
+ import { VaultIndex } from "./vault-index.ts";
11
+ /** Read bounded auxiliary inputs without following links. Never read note files here. */
12
+ export function analysisRead(file: string, maximum = 2 * 1024 * 1024): string | null {
13
+ let fd: number;
14
+ try {
15
+ if (realpathSync(dirname(file)) !== dirname(file)) throw new Error("analysis: linked input directory");
16
+ fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
17
+ } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
18
+ try {
19
+ const before = fstatSync(fd);
20
+ if (!before.isFile() || before.size > maximum) throw new Error("analysis: auxiliary input exceeds bound or is not plain");
21
+ const bytes = Buffer.alloc(before.size + 1);
22
+ let size = 0;
23
+ while (size < bytes.length) { const n = readSync(fd, bytes, size, bytes.length-size, null); size += n; if (!n) break; }
24
+ const after = fstatSync(fd);
25
+ if (size !== before.size || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error("analysis: input changed during read");
26
+ return bytes.subarray(0,size).toString("utf8");
27
+ } finally { closeSync(fd); }
28
+ }
29
+ function permitted(file: string, config: VmemConfig): void {
30
+ if (isDenied(canonicalPath(file), config.denyPaths ?? [])) throw new Error("analysis: input path is denied");
31
+ }
32
+ export function analysisInput(home: string, id: string, options: { today?: string; memory?: boolean; expectedVault?: VaultSpec } = {}): { snapshot: AnalysisSnapshot; vault: VaultSpec; config: VmemConfig; identity: {dev:number;ino:number} } {
33
+ const config = loadRegistry(home), vault = config.vaults.find(v => v.id === id);
34
+ if (!vault) throw new Error("analysis: unknown vault id");
35
+ if (options.expectedVault && JSON.stringify(options.expectedVault) !== JSON.stringify(vault)) throw new Error("analysis: startup vault binding changed; restart MCP");
36
+ const identity = lstatSync(vault.path);
37
+ const today = options.today ?? new Date().toISOString().slice(0,10);
38
+ 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");
39
+ permitted(vault.path, config);
40
+ // Check denied paths before opening the index or reading note sources.
41
+ for (const entry of readdirSync(join(vault.path,"notes"), { withFileTypes:true })) if (entry.isFile() && entry.name.endsWith(".md")) permitted(join(vault.path,"notes",entry.name), config);
42
+ const logPath = join(vault.path,"log"), reportsDir = join(vault.path,"reports"), reports: Record<string,string> = {};
43
+ permitted(logPath,config); permitted(reportsDir,config);
44
+ let entries: string[] = [];
45
+ try {
46
+ if (lstatSync(reportsDir).isSymbolicLink() || realpathSync(reportsDir) !== reportsDir) throw new Error("analysis: reports directory must be plain");
47
+ entries = readdirSync(reportsDir).filter(s => s === "vault-graph.json" || s === "graph-trend.md" || /^graph-report-.*\.md$/.test(s)).sort();
48
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
49
+ if (entries.length > 1000) throw new Error("analysis: report count exceeds bound");
50
+ let bytes = 0;
51
+ for (const name of entries) {
52
+ const file = join(reportsDir,name); permitted(file,config);
53
+ const source = analysisRead(file); if (source !== null) { bytes += Buffer.byteLength(source); if (bytes > 16 * 1024 * 1024) throw new Error("analysis: reports exceed aggregate bound"); reports[name] = source; }
54
+ }
55
+ let registryIds: string[] | null = null;
56
+ if (vault.registry) {
57
+ permitted(vault.registry,config);
58
+ const source = analysisRead(vault.registry);
59
+ if (source !== null) {
60
+ const registry = parseAnalysisYaml(source) as { projects?: { id?: unknown }[] } | null;
61
+ if (registry?.projects !== undefined && !Array.isArray(registry.projects)) throw new Error("analysis: registry projects must be array");
62
+ registryIds = (registry?.projects ?? []).filter(v => v && typeof v === "object").map(v => v.id).filter((v): v is string => typeof v === "string");
63
+ }
64
+ }
65
+ const inputs = { today, logPath, log: analysisRead(logPath, 32 * 1024 * 1024), registryIds, reports };
66
+ const index = new VaultIndex(home,vault,config.vaults.map(v => v.path),{ memory: options.memory ?? true });
67
+ try {
68
+ index.scan(false,{captureInvalidFrontmatter:true});
69
+ const after = lstatSync(vault.path);
70
+ if (after.dev !== identity.dev || after.ino !== identity.ino || after.isSymbolicLink()) throw new Error("analysis: vault changed during snapshot");
71
+ const snapshot=index.analysisSnapshot(inputs),sourceRoot=sourceWorkspaceRoot(vault),warnings:Record<string,string[]>={};
72
+ if(sourceRoot)for(const note of snapshot.notes)try{
73
+ const value=parseFrontmatter(note.source).data.source;
74
+ const denied=value!==undefined&&sourceReferences(value).some(ref=>isDenied(ref.value,config.denyPaths??[])||(ref.kind==="path"&&isDenied(canonicalPath(resolve(sourceRoot,ref.value)),config.denyPaths??[])));
75
+ const found=denied?["source is denied by the path policy"]:missingSourceWarnings(value,sourceRoot);
76
+ if(found.length)warnings[note.path]=found;
77
+ }catch{ /* Invalid frontmatter remains visible to L02; do not infer missing source paths. */ }
78
+ if(Object.keys(warnings).length)snapshot.sourceWarnings=warnings;
79
+ return {vault,config,identity:{dev:Number(identity.dev),ino:Number(identity.ino)},snapshot};
80
+ }
81
+ finally { index.close(); }
82
+ }