@staix/vault-mem 0.3.0 → 0.3.2
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 +17 -1
- package/README.md +23 -5
- package/package.json +1 -1
- package/src/cli.ts +52 -11
- package/src/commands-table.ts +5 -2
- package/src/config.ts +28 -8
- package/src/index.ts +8 -3
- package/src/log-commands.ts +1 -1
- package/src/mcp-vault.ts +6 -2
- package/src/mcp.ts +44 -19
- package/src/org-promote.ts +1 -14
- package/src/registry.ts +63 -2
- package/src/spec-graph.ts +117 -0
- package/src/spec-source.ts +204 -0
- package/src/sync.ts +17 -5
- package/src/tidy.ts +7 -1
- package/src/vault-index.ts +159 -97
- package/src/write-commands.ts +8 -3
- package/src/write-operations.ts +46 -1
- package/src/write-validation.ts +20 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.2] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
- Add library-only spec discovery, repository/worktree identity checks, typed id-based graphs and bounded directional slices (#91 T2). `SpecIndex` reuses the vault scan/metadata/hash/BM25 engine with a separate `spec-` cache per canonical worktree; vault cache names remain unchanged. Spec searches exclude shared-cache rows absent from the instance snapshot or replaced since its scan, and discovery errors name the relative file. Filesystem normalization/case aliases are accepted with root-identity and physical-containment checks, skipped listed paths have diagnostics, and error advice preserves the original cause and names `.gitignore` for discovery bounds. No CLI/MCP read surface or spec content write tools are added.
|
|
8
|
+
|
|
9
|
+
- Register local git roots separately with `specs add|list|remove`; removal previews and unregisters only. Spec root diagnostics distinguish a missing git executable, timeout, git refusal and non-root path, and support repository names containing backslashes. Spec sources default to `write: none` and `pii: deny`, share IDs and the atomic registry lock with vaults, and get root diagnostics in `doctor`. Config writes use format 2 only when `specs` is present; vault-only settings and output stay compatible. Spec graph/read/validation tools and spec content writes are not part of this increment (#91 T1).
|
|
10
|
+
|
|
11
|
+
- MCP argument errors name the first failing path and reason without echoing values; unknown tools have a separate diagnostic. `vmem_create` accepts top-level `created` and checks a real YYYY-MM-DD calendar date. Writer/log CLI errors name rejected options, writer arity errors show the expected form, and per-command `--help`/`-h` prints usage (#94).
|
|
12
|
+
|
|
13
|
+
- V05 refuses newly introduced Topics placement or content problems while retaining existing problems as warnings. `frontmatter --set topics` updates only the body Topics wiki-list items in the same atomic write, preserving fenced blocks, H3/prose and line endings; an unchanged topics value leaves the body unchanged. All create paths, including org `promote` and `vmem_create` with a custom body, now refuse a note whose Topics is not the final H2 or does not match its frontmatter topics. Existing vaults must run `vmem tidy <vault> --all` once after upgrading to the validating writer, previewing and then confirming its fingerprint; `doctor` reports pending migration counts and unparsable paths without writing, using only the planning pass, and reports thrown or returned planning failures as per-vault diagnostics (#92).
|
|
14
|
+
|
|
15
|
+
- Releases publish a GitHub release next to the npm package, with the validated tarball and manifest attached and the CHANGELOG section as notes (#89).
|
|
16
|
+
|
|
17
|
+
## [0.3.1] - 2026-11-01
|
|
18
|
+
|
|
19
|
+
- `sync` and `doctor` refuse a git URL rewrite (`insteadOf`/`pushInsteadOf`) only when its prefix matches the configured remote or one of origin's configured or effective URLs, explicit `pushurl` included, and name that rule (#86). An unrelated rule, such as a global `https://github.com/` to SSH rewrite with an SSH vault remote, no longer blocks sync.
|
|
20
|
+
|
|
5
21
|
## [0.3.0] - 2026-11-01
|
|
6
22
|
|
|
7
23
|
- 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.
|
|
@@ -61,7 +77,7 @@
|
|
|
61
77
|
|
|
62
78
|
- Add vault registration, bounded configuration, home resolution, diagnostics and explicit unregister confirmation.
|
|
63
79
|
- Add lossless frontmatter, local wiki-link resolution and incremental SQLite FTS indexing.
|
|
64
|
-
- Add validated atomic note mutations, preview fingerprints and append-only attributed vault events.
|
|
80
|
+
- Add validated atomic note mutations, preview fingerprints and append-only attributed vault events. Existing vaults must run `vmem tidy <vault> --all` once after upgrading, previewing first and then confirming its fingerprint.
|
|
65
81
|
- Add bounded CLI/MCP providers, reversible host setup and agent-mem promotion with provenance.
|
|
66
82
|
- Add indexed lint, deterministic graph reports, bounded neighborhoods and synthetic reference-parity checks.
|
|
67
83
|
- 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.
|
package/README.md
CHANGED
|
@@ -25,13 +25,13 @@ 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. An optional top-level `version` (positive integer, absent means 1) names the config format; this build
|
|
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 supports format 2 and adds `version: 2` when `specs` is present. Vault-only configurations retain their existing version field (or its absence), so 0.2.4 and older keep reading those configurations. 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
|
|
|
32
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
33
|
|
|
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.
|
|
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`. Doctor also runs the tidy planning pass for writable vaults and reports pending counts and unparsable paths, without per-note writer validation or writes. Planning failures appear as `checks.tidy`; a failed check exits 1. Actual tidy previews validate planned notes and identify skipped notes. 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
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
|
|
|
@@ -117,11 +117,11 @@ vmem tidy personal --connections
|
|
|
117
117
|
vmem tidy personal --all --json # migration sanitize preview (see Migration tidy)
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
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 `-`.
|
|
120
|
+
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`; changing `topics` also replaces only the body Topics wiki-list items in the same write, preserving other content and line endings; setting an equal topics value leaves the body unchanged; 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 `-`.
|
|
121
121
|
|
|
122
122
|
`--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. The move source may be an existing note with a legacy file name (lint L13, for example a dot in the stem); the destination must be a valid stem. Tidy folds Connections sections into Relevant Notes. Delete refuses when it would newly break inbound links or MOC topics; fix those references first.
|
|
123
123
|
|
|
124
|
-
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 note size / 16 MiB report 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.
|
|
124
|
+
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 (error when the write introduces a placement or content problem the note did not already have, or on create; warning when every problem was already present), 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 note size / 16 MiB report 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.
|
|
125
125
|
|
|
126
126
|
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 notes are limited to 256 KiB; report operations under `reports/` and `ops/` allow up to 16 MiB for both prospective content and existing-file reads (including staging and rollback rechecks), 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.
|
|
127
127
|
|
|
@@ -306,7 +306,7 @@ Runtime changes intentionally make `release:verify` report stale historical rele
|
|
|
306
306
|
|
|
307
307
|
# Migration tidy
|
|
308
308
|
|
|
309
|
-
`vmem tidy <vault> [--connections --escapes --enums --keys --sources --topics --secrets --log | --all] [--map <file>] [--json] [--confirm --fingerprint SHA]` sanitizes debt from older, unvalidated writers.
|
|
309
|
+
`vmem tidy <vault> [--connections --escapes --enums --keys --sources --topics --secrets --log | --all] [--map <file>] [--json] [--confirm --fingerprint SHA]` sanitizes debt from older, unvalidated writers. Existing vaults must run `vmem tidy <vault> --all` once after upgrading to the validating writer, previewing first and then confirming the returned fingerprint. `vmem doctor` reports pending migration planning counts and unparsable paths for writable vaults; it writes nothing. Doctor does not run the per-note writer validation, so its planning counts may include notes the actual tidy preview will skip. Tidy also handles later imports.
|
|
310
310
|
|
|
311
311
|
- **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, ...`.
|
|
312
312
|
- **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.
|
|
@@ -326,3 +326,21 @@ Runtime changes intentionally make `release:verify` report stale historical rele
|
|
|
326
326
|
| `--log` | replace secret-looking substrings in existing log lines with `[redacted]`, and PII values that the vault policy denies on the `log` surface (allow-list entries excepted), under the log lock (refused with V06 when `denyPaths` covers the log). A line that fails the lint L09 structure check loses a trailing seconds field when that makes it valid, and is otherwise kept verbatim behind `# legacy: ` (readers skip `#` lines); no time, zone or TYPE is guessed. Other lines are kept byte for byte. Git history is not rewritten: rotate exposed credentials |
|
|
327
327
|
|
|
328
328
|
`--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.
|
|
329
|
+
|
|
330
|
+
# Spec source registry
|
|
331
|
+
|
|
332
|
+
Register a local repository as a separate spec source:
|
|
333
|
+
|
|
334
|
+
```sh
|
|
335
|
+
vmem specs add project-specs /absolute/repository --write none --pii deny
|
|
336
|
+
vmem specs list --json
|
|
337
|
+
vmem doctor --json
|
|
338
|
+
vmem specs remove project-specs
|
|
339
|
+
vmem specs remove project-specs --confirm
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
The config `specs` array holds `id`, canonical git working-tree root `path`, `write` (`mcp|cli|none`, default `none`) and `pii` (`allow|deny`, default `deny`). IDs are unique across vaults and spec sources. A spec source does not need a `notes/` directory. Registration and removal reuse the vault registry lock and atomic config write. Removal previews first, then `--confirm` unregisters only; repository files remain in place.
|
|
343
|
+
|
|
344
|
+
Config writes containing `specs` use format 2; a config without `specs` keeps its existing serialization and version field. `status` lists spec entries under a separate `specs` key, and `doctor` checks that every registered path is still its canonical git working-tree root. An unavailable or changed root makes doctor exit 1 with a per-source issue. Existing vault entries and `vaults list` keep their output shape.
|
|
345
|
+
|
|
346
|
+
The registry and a library-only read engine are implemented. `resolveSpecWorktree` checks the registered git common directory; `discoverSpecs` discovers nested Markdown specs in memory; `SpecIndex` reuses the vault scanner and BM25 with a separate `spec-` cache prefix per canonical worktree. Call `scan()` to refresh a `SpecIndex`; its `snapshot()` uses the last completed scan and `search()` admits only shared-cache rows matching that snapshot's paths and hashes. Call `scan()` again to see documents another index instance inserted or replaced. `buildSpecGraph` resolves typed links by frontmatter id, and `specSlice` returns bounded subtree/ancestor/neighbor slices (depth 0..3, at most 500 nodes). Discovery uses Git's tracked/untracked non-ignored Markdown list, reports skipped symlinks/missing/nonregular paths, accepts NFC/NFD and filesystem case aliases, and keeps the first duplicate id in NFC path order with a diagnostic. Root device/inode identity and per-file physical containment remain checked while accepting these spelling aliases. Unknown type/status strings remain readable. To exclude large or vendored untracked Markdown, add it to `.gitignore`; already-tracked ignored files must also be untracked with `git rm --cached`. Discovery/indexing is bounded to 10000 Markdown inputs, 2 MiB each and 64 MiB total; graphs allow 10000 inputs and 100000 unique edges. Graph results omit raw note sources. Spec validation, freshness, path filters, MCP tools and CLI graph/search commands are not yet implemented. Spec create/update/delete tools remain deferred; `write` stores a policy for later capabilities. Commands do not edit repository content or run git commit, branch or push.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tidyVault, TIDY_RULES } from "./tidy.ts";
|
|
1
2
|
import { originCheck } from "./sync.ts";
|
|
2
3
|
import { registryIds } from "./vault-log.ts";
|
|
3
4
|
import { runAnalysisCommand } from "./analysis-commands.ts";
|
|
@@ -11,7 +12,7 @@ import { VMEM_TOOL_NAMES } from "./mcp-vault.ts";
|
|
|
11
12
|
import { CONFIG_VERSION } from "./config.ts";
|
|
12
13
|
import { runSetup, type SetupMode } from "./setup.ts";
|
|
13
14
|
import { resolveHome } from "./home.ts";
|
|
14
|
-
import { addVault, effectivePii, loadRegistry, recoverRegistry, registryStatus, removeVault, setVaultPii } from "./registry.ts";
|
|
15
|
+
import { addVault, addSpec, removeSpec, specStatus, effectivePii, loadRegistry, recoverRegistry, registryStatus, removeVault, setVaultPii } from "./registry.ts";
|
|
15
16
|
import { piiPolicy } from "./pii.ts";
|
|
16
17
|
function parse(args: string[], allowed: string[], values: string[]) {
|
|
17
18
|
const flags: Record<string, string | boolean> = {}, positional: string[] = [];
|
|
@@ -27,8 +28,17 @@ function parse(args: string[], allowed: string[], values: string[]) {
|
|
|
27
28
|
}
|
|
28
29
|
return { flags, positional };
|
|
29
30
|
}
|
|
31
|
+
function commandHelp(args: string[]): string | undefined {
|
|
32
|
+
if (!["--help", "-h"].includes(args.at(-1) ?? "")) return undefined;
|
|
33
|
+
const name = args.slice(0, -1).join(" ");
|
|
34
|
+
if (!name) return undefined;
|
|
35
|
+
const commands = COMMANDS.filter(command => command.name === name || command.name.startsWith(name + " "));
|
|
36
|
+
return commands.length ? commands.map(command => `Usage: vmem ${command.usage}`).join("\n") : undefined;
|
|
37
|
+
}
|
|
30
38
|
export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
|
|
31
39
|
if (!args.length || (args.length === 1 && ["--help", "-h", "help"].includes(args[0]!))) return { text: help(), code: 0 };
|
|
40
|
+
const usage = commandHelp(args);
|
|
41
|
+
if (usage) return { text: usage, code: 0 };
|
|
32
42
|
if (args.length === 1 && args[0] === "--version") return { text: version, code: 0 };
|
|
33
43
|
if (args[0] === "manifest") {
|
|
34
44
|
const { flags, positional } = parse(args.slice(1), ["--json"], []);
|
|
@@ -52,18 +62,19 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
|
|
|
52
62
|
if (args[0] === "log") return runLogCommand(args.slice(1), resolveHome(env).home, env);
|
|
53
63
|
if (isWriteCommand(args[0]!)) return runWriteCommand(args[0]!, args.slice(1), resolveHome(env).home, env);
|
|
54
64
|
if (isReadCommand(args[0]!)) return runReadCommand(args[0]!, args.slice(1), resolveHome(env).home);
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
65
|
+
const group = args[0] === "vaults" || args[0] === "specs";
|
|
66
|
+
const command = group ? `${args[0]} ${args[1] ?? ""}` : args[0];
|
|
67
|
+
const tail = args.slice(group ? 2 : 1);
|
|
68
|
+
const options = command === "vaults add" ? ["--kind", "--write", "--pii", "--remote", "--registry"] : command === "vaults pii" ? ["--set"] : command === "specs add" ? ["--write", "--pii"] : [];
|
|
69
|
+
const { flags, positional } = parse(tail, ["--json", ...options, ...(command === "vaults remove" || command === "specs remove" || command === "vaults pii" || command === "recover" ? ["--confirm"] : [])], options);
|
|
59
70
|
const home = resolveHome(env);
|
|
60
71
|
let result: unknown, code = 0;
|
|
61
72
|
switch (command) {
|
|
62
73
|
case "status": case "doctor": {
|
|
63
74
|
if (positional.length) throw new Error(`${command}: unexpected arguments`);
|
|
64
|
-
const config =
|
|
75
|
+
const config = loadRegistry(home.home);
|
|
65
76
|
const vaults = registryStatus(home.home).map(vault => {
|
|
66
|
-
if (command !== "doctor" || !vault.healthy
|
|
77
|
+
if (command !== "doctor" || !vault.healthy) return { ...vault, checks: undefined };
|
|
67
78
|
const checks: Record<string, { ok: boolean; issue?: string }> = {};
|
|
68
79
|
if (vault.remote) {
|
|
69
80
|
try {
|
|
@@ -72,13 +83,26 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
|
|
|
72
83
|
} catch (error) { checks.remote = { ok: false, issue: error instanceof Error ? error.message : "git failed" }; }
|
|
73
84
|
}
|
|
74
85
|
if (vault.registry) {
|
|
75
|
-
try { registryIds(vault.registry, config
|
|
86
|
+
try { registryIds(vault.registry, config.denyPaths); checks.registry = { ok: true }; }
|
|
76
87
|
catch (error) { checks.registry = { ok: false, issue: error instanceof Error ? error.message : "cannot read project registry" }; }
|
|
77
88
|
}
|
|
78
|
-
|
|
89
|
+
let preview: ReturnType<typeof tidyVault> | undefined;
|
|
90
|
+
try {
|
|
91
|
+
if (vault.write !== "none") preview = tidyVault(home.home, { vault: vault.id, rules: [...TIDY_RULES] }, { env, validate: false });
|
|
92
|
+
} catch (error) { checks.tidy = { ok: false, issue: error instanceof Error ? error.message : "cannot preview migration tidy" }; }
|
|
93
|
+
if (preview && !preview.ok) {
|
|
94
|
+
const failures = preview.issues.filter(issue => issue.code !== "POLICY" && issue.code !== "PII_POLICY");
|
|
95
|
+
if (failures.length) checks.tidy = { ok: false, issue: failures.map(issue => `${issue.code}: ${issue.message}`).join("; ") };
|
|
96
|
+
}
|
|
97
|
+
const unparsable = preview?.reported.filter(item => item.message.startsWith("unparsable note skipped")).map(item => item.path) ?? [];
|
|
98
|
+
const tidy = preview?.ok && (preview.notes.length || unparsable.length || Object.values(preview.counts).some(count => count > 0))
|
|
99
|
+
? { pending: preview.counts, notes: preview.notes.length, unparsable, hint: `Run vmem tidy ${vault.id} --all, then confirm its preview fingerprint to apply the migration.` }
|
|
100
|
+
: undefined;
|
|
101
|
+
return { ...vault, checks: Object.keys(checks).length ? checks : undefined, ...(tidy ? { tidy } : {}) };
|
|
79
102
|
});
|
|
80
|
-
const
|
|
81
|
-
|
|
103
|
+
const sources = command === "doctor" && config.specs !== undefined ? specStatus(home.home, env) : undefined;
|
|
104
|
+
const healthy = vaults.every(v => v.healthy && (!v.checks || Object.values(v.checks).every(check => check.ok))) && (!sources || sources.every(source => source.healthy));
|
|
105
|
+
result = { ...home, version, stage: "foundation", healthy, vaults, ...(config.specs !== undefined ? { specs: sources ?? config.specs } : {}) };
|
|
82
106
|
if (command === "doctor" && !healthy) code = 1;
|
|
83
107
|
break;
|
|
84
108
|
}
|
|
@@ -87,6 +111,21 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
|
|
|
87
111
|
result = recoverRegistry(home.home, flags["--confirm"] === true);
|
|
88
112
|
if ((result as { blocked?: string }).blocked) code = 1;
|
|
89
113
|
break;
|
|
114
|
+
case "specs list":
|
|
115
|
+
if (positional.length) throw new Error("specs list: unexpected targets; use vmem specs list [--json]");
|
|
116
|
+
result = loadRegistry(home.home).specs ?? [];
|
|
117
|
+
break;
|
|
118
|
+
case "specs add": {
|
|
119
|
+
if (positional.length !== 2) throw new Error("specs add: requires id and absolute repository root; use vmem specs add <id> <absolute-path> [--write mcp|cli|none] [--pii allow|deny]");
|
|
120
|
+
const value: Record<string, unknown> = { id: positional[0], path: positional[1] };
|
|
121
|
+
for (const option of options) if (flags[option] !== undefined) value[option.slice(2)] = flags[option];
|
|
122
|
+
result = { applied: true, spec: addSpec(home.home, value, env) };
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "specs remove":
|
|
126
|
+
if (positional.length !== 1) throw new Error("specs remove: requires one id; use vmem specs list, then vmem specs remove <id> [--confirm]");
|
|
127
|
+
result = removeSpec(home.home, positional[0]!, flags["--confirm"] === true);
|
|
128
|
+
break;
|
|
90
129
|
case "vaults list":
|
|
91
130
|
if (positional.length) throw new Error("vaults list: unexpected arguments");
|
|
92
131
|
result = loadRegistry(home.home).vaults.map(v => ({ ...v, pii: effectivePii(v), piiPolicy: piiPolicy(v) }));
|
|
@@ -129,6 +168,8 @@ export async function runMcp(env: NodeJS.ProcessEnv = process.env): Promise<void
|
|
|
129
168
|
}
|
|
130
169
|
/** Async commands. Only `promote-from-amem` and `skills mine` load the agent-mem seam, and only when they run. */
|
|
131
170
|
export async function runAsync(args: string[], env: NodeJS.ProcessEnv = process.env, io: { stdinIsTTY?: boolean } = {}): Promise<{ text: string; code: number }> {
|
|
171
|
+
const usage = commandHelp(args);
|
|
172
|
+
if (usage) return { text: usage, code: 0 };
|
|
132
173
|
if (args[0] === "skills") {
|
|
133
174
|
const { runSkillsCommand } = await import("./skills-mine.ts");
|
|
134
175
|
return runSkillsCommand(args.slice(1), resolveHome(env).home, env);
|
package/src/commands-table.ts
CHANGED
|
@@ -14,6 +14,9 @@ export const COMMANDS = [
|
|
|
14
14
|
{ name: "vaults add", usage: "vaults add <id> <absolute-path> --kind personal|org --write mcp|cli|none [--pii allow|deny] [--remote URL] [--registry PATH] [--json]", mutates: true },
|
|
15
15
|
{ name: "vaults remove", usage: "vaults remove <id> [--confirm] [--json]", mutates: "confirm" },
|
|
16
16
|
{ name: "vaults pii", usage: "vaults pii <id> [--set allow|deny|JSON] [--confirm] [--json]", mutates: "confirm" },
|
|
17
|
+
{ name: "specs list", usage: "specs list [--json]", mutates: false },
|
|
18
|
+
{ name: "specs add", usage: "specs add <id> <absolute-path> [--write mcp|cli|none] [--pii allow|deny] [--json]", mutates: true },
|
|
19
|
+
{ name: "specs remove", usage: "specs remove <id> [--confirm] [--json]", mutates: "confirm" },
|
|
17
20
|
{ name: "new", usage: "new <vault> <stem> --type TYPE --domain DOMAIN --topics a,b --description TEXT [--title TEXT] [--source TEXT|JSON] [--confidence TEXT] [--status TEXT] [--body file|-] [--extra JSON] [--dry-run] [--json]", mutates: true },
|
|
18
21
|
{ name: "patch", usage: "patch <vault> <note> --old TEXT --new TEXT [--expected-sha SHA] [--dry-run] [--json]", mutates: true },
|
|
19
22
|
{ name: "append", usage: "append <vault> <note> --section SECTION --markdown TEXT [--expected-sha SHA] [--dry-run] [--json]", mutates: true },
|
|
@@ -69,7 +72,7 @@ export function completionScript(shell: string): string {
|
|
|
69
72
|
const cond = d.path.length === 1 ? `__fish_seen_subcommand_from ${d.path[0]}` : `__fish_seen_subcommand_from ${d.path[1]}`;
|
|
70
73
|
for (const flag of Object.keys(d.options)) lines.push(`complete -c vmem -n ${quote(cond)} -l ${flag.slice(2)}`);
|
|
71
74
|
}
|
|
72
|
-
for (const parent of ["vaults", "log", "graph", "skills"]) lines.push(`complete -c vmem -n '__fish_seen_subcommand_from ${parent}' -a ${quote(defs.filter(d => d.path[0] === parent).map(d => d.path[1]).join(" "))}`);
|
|
75
|
+
for (const parent of ["vaults", "specs", "log", "graph", "skills"]) lines.push(`complete -c vmem -n '__fish_seen_subcommand_from ${parent}' -a ${quote(defs.filter(d => d.path[0] === parent).map(d => d.path[1]).join(" "))}`);
|
|
73
76
|
return lines.join("\n") + "\n";
|
|
74
77
|
}
|
|
75
78
|
const cases = defs.map(d => ` ${quote(d.path.join(" "))}) words=${quote(Object.keys(d.options).join(" "))} ;;`);
|
|
@@ -78,7 +81,7 @@ export function completionScript(shell: string): string {
|
|
|
78
81
|
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
79
82
|
if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W ${quote(top.join(" "))} -- "$cur")); return; fi
|
|
80
83
|
key="\${COMP_WORDS[1]}"
|
|
81
|
-
case "$key" in graph) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'build neighbors' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; skills) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'mine' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; vaults|log) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W "$( [ "$key" = vaults ] && echo 'list add remove' || echo 'tail grep append')" -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; esac
|
|
84
|
+
case "$key" in graph) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'build neighbors' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; skills) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'mine' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; specs) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'list add remove' -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; vaults|log) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W "$( [ "$key" = vaults ] && echo 'list add remove' || echo 'tail grep append')" -- "$cur")); return; fi; key="$key \${COMP_WORDS[2]}" ;; esac
|
|
82
85
|
case "$key" in
|
|
83
86
|
${cases.join("\n")}
|
|
84
87
|
*) words='' ;;
|
package/src/config.ts
CHANGED
|
@@ -23,10 +23,11 @@ export interface PiiSpec { email?: PiiCell; phone?: PiiCell; custom?: PiiCell; p
|
|
|
23
23
|
* The config format this build writes. A build that adds a key bumps it. A config whose `version` is newer
|
|
24
24
|
* than this build is read tolerantly: unknown keys are kept as they are and written back unchanged, so an
|
|
25
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
|
|
26
|
+
* An absent `version` means 1. Registry writes add format 2 only when `specs` is present; vault-only configs keep their existing version field.
|
|
27
27
|
*/
|
|
28
|
-
export const CONFIG_VERSION =
|
|
29
|
-
export interface
|
|
28
|
+
export const CONFIG_VERSION = 2;
|
|
29
|
+
export interface SpecSourceSpec { id: string; path: string; write: "mcp" | "cli" | "none"; pii: "allow" | "deny" }
|
|
30
|
+
export interface VmemConfig { version?: number; home?: string; vaults: VaultSpec[]; specs?: SpecSourceSpec[]; denyPaths?: string[]; piiPatterns?: string[] }
|
|
30
31
|
const controls = /[\x00-\x1f\x7f]/;
|
|
31
32
|
function object(value: unknown, keys: string[], label: string, tolerant = false): Record<string, unknown> {
|
|
32
33
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected object`);
|
|
@@ -125,25 +126,44 @@ export function parseVault(value: unknown, tolerant = false): VaultSpec {
|
|
|
125
126
|
if (v.registry !== undefined) result.registry = absolute(v.registry, "vault.registry");
|
|
126
127
|
return result;
|
|
127
128
|
}
|
|
129
|
+
/** Spec sources have their own registry shape; they are not vaults. */
|
|
130
|
+
export function parseSpec(value: unknown, tolerant = false): SpecSourceSpec {
|
|
131
|
+
try {
|
|
132
|
+
const keys = ["id", "path", "write", "pii"], raw = object(value, keys, "spec source", tolerant);
|
|
133
|
+
const id = string(raw.id, "spec source.id");
|
|
134
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(id)) throw new Error("spec source.id: expected a lowercase letter followed by at most 31 lowercase letters, digits or hyphens");
|
|
135
|
+
const write = raw.write === undefined ? "none" : raw.write, pii = raw.pii === undefined ? "deny" : raw.pii;
|
|
136
|
+
if (write !== "mcp" && write !== "cli" && write !== "none") throw new Error("spec source.write: expected mcp, cli or none");
|
|
137
|
+
if (pii !== "allow" && pii !== "deny") throw new Error("spec source.pii: expected allow or deny");
|
|
138
|
+
return { ...unknown(raw, keys, tolerant), id, path: absolute(raw.path, "spec source.path"), write, pii };
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new Error(`${error instanceof Error ? error.message : "invalid spec source"}; use vmem specs add <id> <absolute-path> with --write mcp|cli|none and --pii allow|deny`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
128
143
|
export function parseConfig(value: unknown): VmemConfig {
|
|
129
|
-
const keys = ["version", "home", "vaults", "denyPaths", "piiPatterns"];
|
|
144
|
+
const keys = ["version", "home", "vaults", "specs", "denyPaths", "piiPatterns"];
|
|
130
145
|
const version = (value as Record<string, unknown> | null)?.version;
|
|
131
146
|
if (version !== undefined && (!Number.isSafeInteger(version) || (version as number) < 1)) throw new Error("config.version: expected a positive integer");
|
|
132
147
|
const tolerant = newerFormat(value), raw = object(value, keys, "config", tolerant);
|
|
133
148
|
const stamp = version === undefined ? {} : { version: version as number };
|
|
134
149
|
if (raw.home !== undefined) {
|
|
135
|
-
if (["vaults", "denyPaths", "piiPatterns"].some(key => raw[key] !== undefined)) throw new Error("config: home pointer must not contain vaults or policy");
|
|
150
|
+
if (["vaults", "specs", "denyPaths", "piiPatterns"].some(key => raw[key] !== undefined)) throw new Error("config: home pointer must not contain vaults, specs or policy; put source entries in the pointed-to home config");
|
|
136
151
|
return { ...unknown(raw, keys, tolerant), ...stamp, home: absolute(raw.home, "config.home"), vaults: [] };
|
|
137
152
|
}
|
|
138
|
-
|
|
139
|
-
|
|
153
|
+
const vaultInput = raw.vaults === undefined && raw.specs !== undefined ? [] : raw.vaults;
|
|
154
|
+
if (!Array.isArray(vaultInput)) throw new Error("config.vaults: expected array");
|
|
155
|
+
const vaults = vaultInput.map(item => parseVault(item, tolerant));
|
|
156
|
+
if (raw.specs !== undefined && !Array.isArray(raw.specs)) throw new Error("config.specs: expected an array; list spec source entries in specs");
|
|
157
|
+
const specs = (raw.specs as unknown[] | undefined)?.map(item => parseSpec(item, tolerant));
|
|
158
|
+
if (specs && new Set([...vaults, ...specs].map(source => source.id)).size !== vaults.length + specs.length) throw new Error("config: duplicate source id across vaults/specs; give each registered source a unique id");
|
|
159
|
+
if (specs && new Set(specs.map(source => source.path)).size !== specs.length) throw new Error("config: duplicate spec source path; keep only one entry for each repository root");
|
|
140
160
|
if (new Set(vaults.map(v => v.id)).size !== vaults.length) throw new Error("config: duplicate vault id");
|
|
141
161
|
if (new Set(vaults.map(v => v.path)).size !== vaults.length) throw new Error("config: duplicate vault path");
|
|
142
162
|
const denyPaths = strings(raw.denyPaths, "denyPaths"), piiPatterns = strings(raw.piiPatterns, "piiPatterns");
|
|
143
163
|
for (const pattern of piiPatterns ?? []) {
|
|
144
164
|
try { new RegExp(pattern); } catch { throw new Error("piiPatterns: invalid regular expression"); }
|
|
145
165
|
}
|
|
146
|
-
return { ...unknown(raw, keys, tolerant), ...stamp, vaults, ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
|
|
166
|
+
return { ...unknown(raw, keys, tolerant), ...stamp, vaults, ...(specs !== undefined ? { specs } : {}), ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
|
|
147
167
|
}
|
|
148
168
|
/** Bounded descriptor read: reject links, devices and oversized/growing files. */
|
|
149
169
|
export function readConfig(home: string): VmemConfig {
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { resolveHome } from "./home.ts";
|
|
2
|
-
export { parseConfig, parseVault, readConfig, CONFIG_FILE, MAX_CONFIG_BYTES } from "./config.ts";
|
|
3
|
-
export type { VaultSpec, VmemConfig } from "./config.ts";
|
|
4
|
-
export { addVault, removeVault, loadRegistry, registryStatus, recoverRegistry, effectivePii, syncDirectory, LOCK_FILE } from "./registry.ts";
|
|
2
|
+
export { parseConfig, parseVault, parseSpec, readConfig, CONFIG_FILE, MAX_CONFIG_BYTES } from "./config.ts";
|
|
3
|
+
export type { VaultSpec, SpecSourceSpec, VmemConfig } from "./config.ts";
|
|
4
|
+
export { addVault, removeVault, addSpec, removeSpec, specStatus, loadRegistry, registryStatus, recoverRegistry, effectivePii, syncDirectory, LOCK_FILE } from "./registry.ts";
|
|
5
5
|
export type { RecoveryReport, LockOwner } from "./registry.ts";
|
|
6
6
|
export { parseFrontmatter, emitFrontmatter } from "./frontmatter.ts";
|
|
7
7
|
export type { FrontmatterDocument } from "./frontmatter.ts";
|
|
@@ -42,3 +42,8 @@ export { normalizeSource, sourceReferences } from "./source.ts";
|
|
|
42
42
|
export type { SourceValue, SourceReference } from "./source.ts";
|
|
43
43
|
export { tidyVault, parseTidyMap, TIDY_RULES } from "./tidy.ts";
|
|
44
44
|
export type { TidyRequest, TidyResult, TidyRule, TidyMap } from "./tidy.ts";
|
|
45
|
+
|
|
46
|
+
export { resolveSpecWorktree, discoverSpecs, SpecIndex } from "./spec-source.ts";
|
|
47
|
+
export type { SpecDocument, SpecDiagnostic, SpecSnapshot, SpecSourceOptions, SpecIndexOptions, SpecSearchResult } from "./spec-source.ts";
|
|
48
|
+
export { buildSpecGraph, specSlice, SPEC_EDGE_KINDS, MAX_SPEC_INPUTS, MAX_SPEC_EDGES } from "./spec-graph.ts";
|
|
49
|
+
export type { SpecNode, SpecEdge, SpecEdgeKind, SpecGraph, SpecSliceOptions, SpecSlice } from "./spec-graph.ts";
|
package/src/log-commands.ts
CHANGED
|
@@ -8,7 +8,7 @@ export function runLogCommand(args: string[], home: string, env: NodeJS.ProcessE
|
|
|
8
8
|
for (let i = 1; i < args.length; i++) {
|
|
9
9
|
const word = args[i]!;
|
|
10
10
|
if (!word.startsWith("--")) { positional.push(word); continue; }
|
|
11
|
-
if (Object.hasOwn(flags, word) || (word !== "--json" && !values.includes(word))) throw new Error(
|
|
11
|
+
if (Object.hasOwn(flags, word) || (word !== "--json" && !values.includes(word))) throw new Error(`log ${command}: unknown or repeated option: ${word}`);
|
|
12
12
|
if (word === "--json") flags[word] = true;
|
|
13
13
|
else {
|
|
14
14
|
const value = args[++i]; if (value === undefined || value.startsWith("--")) throw new Error("log: option value required");
|
package/src/mcp-vault.ts
CHANGED
|
@@ -44,7 +44,7 @@ export function vaultTools(ids: readonly string[], orgIds: readonly string[] = [
|
|
|
44
44
|
tool("vmem_mocs", "MOC notes with incoming link counts.", object({ vault })),
|
|
45
45
|
tool("vmem_check", "Validate a prospective note text without writing.", object({ vault, path: string(200), text: string(262144, 0) }, ["text"])),
|
|
46
46
|
tool("vmem_log_tail", "Read the newest vault log lines, optionally filtered.", object({ vault, n: integer(1, 200), type: { type: "string", enum: LOG_TYPES }, grep: string(256) })),
|
|
47
|
-
tool("vmem_create", "Create a validated note. Requires a vault with write=mcp.", object({ vault, stem: string(200), type: string(64), domain: string(64), topics: { type: "array", maxItems: 32, items: string(200) }, description: string(1000), title: string(300), body: string(65536, 0), source: { type: ["string", "array"], minLength: 1, maxLength: 512, minItems: 1, maxItems: 32, items: string(512) }, confidence: string(32), status: string(32), extra: { type: "object", maxProperties: 32, additionalProperties: scalar }, log: logEvent }, ["stem", "type", "domain", "description"]), "write"),
|
|
47
|
+
tool("vmem_create", "Create a validated note. Requires a vault with write=mcp.", object({ vault, stem: string(200), type: string(64), domain: string(64), topics: { type: "array", maxItems: 32, items: string(200) }, description: string(1000), title: string(300), body: string(65536, 0), source: { type: ["string", "array"], minLength: 1, maxLength: 512, minItems: 1, maxItems: 32, items: string(512) }, confidence: string(32), status: string(32), created: string(32, 0), extra: { type: "object", maxProperties: 32, additionalProperties: scalar }, log: logEvent }, ["stem", "type", "domain", "description"]), "write"),
|
|
48
48
|
tool("vmem_patch", "Replace one exact string in a note.", object({ vault, path: note, oldString: string(8192), newString: string(8192, 0), expectedSha: sha }, ["path", "oldString", "newString"]), "write"),
|
|
49
49
|
tool("vmem_append", "Append markdown to a section of a note.", object({ vault, path: note, section: string(120), markdown: string(16384), expectedSha: sha }, ["path", "section", "markdown"]), "write"),
|
|
50
50
|
tool("vmem_set_frontmatter", "Set or unset frontmatter keys of a note.", object({ vault, path: note, set: { type: "object", maxProperties: 32, additionalProperties: scalar }, unset: { type: "array", maxItems: 32, items: string(64) }, expectedSha: sha }, ["path"]), "write"),
|
|
@@ -140,8 +140,12 @@ export function createVaultMcp(home: string, env: NodeJS.ProcessEnv = process.en
|
|
|
140
140
|
vmem_check: args => write(args, { op: "check", path: notePath(str(args.path) ?? "checked-note"), text: str(args.text)! }),
|
|
141
141
|
vmem_log_tail: args => readLog(home, { vault: pick(args).id, limit: (args.n as number | undefined) ?? 50, ...(str(args.type) ? { type: str(args.type) } : {}), ...(str(args.grep) ? { grep: str(args.grep) } : {}) }),
|
|
142
142
|
vmem_create: args => {
|
|
143
|
+
if (args.created !== undefined) {
|
|
144
|
+
const created = str(args.created), time = typeof created === "string" && /^\d{4}-\d{2}-\d{2}$/.test(created) ? Date.parse(created + "T00:00:00Z") : NaN;
|
|
145
|
+
if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== created) return refusal("V01", "created: expected YYYY-MM-DD");
|
|
146
|
+
}
|
|
143
147
|
const frontmatter: Record<string, unknown> = { ...(args.extra as Record<string, unknown> | undefined ?? {}) };
|
|
144
|
-
for (const key of ["type", "domain", "description", "title", "source", "confidence", "status"]) if (str(args[key]) !== undefined) frontmatter[key] = args[key];
|
|
148
|
+
for (const key of ["type", "domain", "description", "title", "source", "confidence", "status", "created"]) if (str(args[key]) !== undefined) frontmatter[key] = args[key];
|
|
145
149
|
if (Array.isArray(args.source)) frontmatter.source = args.source as string[];
|
|
146
150
|
if (Array.isArray(args.topics)) frontmatter.topics = (args.topics as string[]).map(v => v.startsWith("[[") ? v : `[[${v}]]`);
|
|
147
151
|
return write(args, { op: "create", path: str(args.stem)!, frontmatter, ...(str(args.body) !== undefined ? { body: str(args.body) } : {}) });
|
package/src/mcp.ts
CHANGED
|
@@ -9,31 +9,54 @@ export interface McpServerOptions { tools: readonly McpTool[]; handlers: Record<
|
|
|
9
9
|
const protocols = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
10
10
|
const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
export function
|
|
12
|
+
/** First schema failure only; paths and schema limits are reported, never argument values. */
|
|
13
|
+
export function argumentError(value: unknown, raw: unknown, path = ""): string | undefined {
|
|
14
|
+
const at = path || "arguments", fail = (reason: string) => `${at}: ${reason}`;
|
|
15
|
+
if (!plain(raw)) return fail("invalid schema");
|
|
14
16
|
const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
15
17
|
const types: unknown[] = Array.isArray(s.type) ? s.type : [s.type];
|
|
16
|
-
if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return
|
|
17
|
-
if (type === "number" && !types.includes("number") && !Number.isSafeInteger(value)) return
|
|
18
|
-
if (s.enum && !s.enum.includes(value)) return
|
|
19
|
-
if (typeof value === "string"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (value.
|
|
23
|
-
|
|
18
|
+
if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return fail(`expected ${types.join(" or ")}`);
|
|
19
|
+
if (type === "number" && !types.includes("number") && !Number.isSafeInteger(value)) return fail("expected safe integer");
|
|
20
|
+
if (s.enum && !s.enum.includes(value)) return fail("not an allowed value");
|
|
21
|
+
if (typeof value === "string") {
|
|
22
|
+
if (value.length < (s.minLength ?? 0)) return fail(`shorter than ${s.minLength}`);
|
|
23
|
+
if (value.length > (s.maxLength ?? FRAME_BYTES)) return fail(`longer than ${s.maxLength ?? FRAME_BYTES}`);
|
|
24
|
+
if (value.includes("\0")) return fail("NUL is not allowed");
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === "number") {
|
|
27
|
+
if (!Number.isFinite(value)) return fail("expected finite number");
|
|
28
|
+
if (value < (s.minimum ?? -Infinity)) return fail(`less than ${s.minimum}`);
|
|
29
|
+
if (value > (s.maximum ?? Infinity)) return fail(`greater than ${s.maximum}`);
|
|
24
30
|
}
|
|
25
|
-
if (
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
if (value.length < (s.minItems ?? 0)) return fail(`fewer than ${s.minItems} items`);
|
|
33
|
+
if (value.length > (s.maxItems ?? 64)) return fail(`more than ${s.maxItems ?? 64} items`);
|
|
34
|
+
if (!plain(s.items)) return fail("invalid item schema");
|
|
35
|
+
for (let i = 0; i < value.length; i++) {
|
|
36
|
+
const error = argumentError(value[i], s.items, `${path}[${i}]`);
|
|
37
|
+
if (error) return error;
|
|
38
|
+
}
|
|
39
|
+
} else if (plain(value)) {
|
|
26
40
|
const keys = Object.keys(value);
|
|
27
|
-
if (keys.length < (s.minProperties ?? 0)
|
|
28
|
-
if ((s.
|
|
41
|
+
if (keys.length < (s.minProperties ?? 0)) return fail(`fewer than ${s.minProperties} properties`);
|
|
42
|
+
if (keys.length > (s.maxProperties ?? 64)) return fail(`more than ${s.maxProperties ?? 64} properties`);
|
|
43
|
+
for (const key of s.required ?? []) if (!Object.hasOwn(value, key)) return `${path ? path + "." : ""}${key}: required`;
|
|
29
44
|
for (const key of keys) {
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
if (key.length > 200) return fail("property name longer than 200");
|
|
46
|
+
const child = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? (path ? `${path}.${key}` : key) : `${path || "$"}[${JSON.stringify(key)}]`;
|
|
47
|
+
if (["__proto__", "prototype", "constructor"].includes(key)) return `${child}: forbidden property name`;
|
|
48
|
+
let schema;
|
|
49
|
+
if (Object.hasOwn(s.properties ?? {}, key)) schema = s.properties[key];
|
|
50
|
+
else if (s.additionalProperties === false || !plain(s.additionalProperties)) return `${path ? path + ": " : ""}unknown argument: ${key}`;
|
|
51
|
+
else schema = s.additionalProperties;
|
|
52
|
+
const error = argumentError(value[key], schema, child);
|
|
53
|
+
if (error) return error;
|
|
33
54
|
}
|
|
34
|
-
} else if (type === "object") return
|
|
35
|
-
return
|
|
55
|
+
} else if (type === "object") return fail("expected plain object");
|
|
56
|
+
return undefined;
|
|
36
57
|
}
|
|
58
|
+
/** Boolean compatibility API for the same closed, bounded schema vocabulary. */
|
|
59
|
+
export function validArguments(value: unknown, raw: unknown): boolean { return argumentError(value, raw) === undefined; }
|
|
37
60
|
|
|
38
61
|
/** Serve until input ends. stdout carries protocol messages only; nothing else is written to `output`. */
|
|
39
62
|
export async function serveMcp(options: McpServerOptions): Promise<void> {
|
|
@@ -70,7 +93,9 @@ export async function serveMcp(options: McpServerOptions): Promise<void> {
|
|
|
70
93
|
if (!plain(params) || Object.keys(params).some(k => !["name", "arguments", "_meta"].includes(k)) || typeof params.name !== "string") { await error(id, -32602, "Invalid tool call"); return; }
|
|
71
94
|
const definition = options.tools.find(t => t.name === params.name), args = params.arguments ?? {};
|
|
72
95
|
const handler = definition && options.handlers[definition.name];
|
|
73
|
-
if (!definition || !handler
|
|
96
|
+
if (!definition || !handler) { await error(id, -32602, `unknown tool: ${params.name.slice(0, 200)}`); return; }
|
|
97
|
+
const invalid = argumentError(args, definition.inputSchema);
|
|
98
|
+
if (invalid) { await error(id, -32602, invalid); return; }
|
|
74
99
|
let result: unknown;
|
|
75
100
|
try {
|
|
76
101
|
const text = JSON.stringify(await handler(args as Record<string, unknown>));
|
package/src/org-promote.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { normalizeTarget, proseLines } from "./links.ts";
|
|
|
8
8
|
import { loadRegistry } from "./registry.ts";
|
|
9
9
|
import { VaultIndex } from "./vault-index.ts";
|
|
10
10
|
import { applyWrite } from "./writer.ts";
|
|
11
|
-
import { notePath } from "./write-operations.ts";
|
|
11
|
+
import { notePath, replaceTopicsSection } from "./write-operations.ts";
|
|
12
12
|
import { screenContent } from "./write-validation.ts";
|
|
13
13
|
import { appendLog, logBinding } from "./vault-log.ts";
|
|
14
14
|
import type { WriteIssue, WriteReceipt } from "./write-types.ts";
|
|
@@ -97,19 +97,6 @@ function screenDroppedLinks(home: string, source: VaultSpec, all: VaultSpec[], d
|
|
|
97
97
|
// Counts and categories only: the linked names are exactly what must not be echoed.
|
|
98
98
|
return [...found].map(([category, count]) => issue("SCREEN", `${category}: ${count} body link${count === 1 ? "" : "s"} point at notes that are never promoted; remove those links from the source note first`));
|
|
99
99
|
}
|
|
100
|
-
/** Replace the last prose `## Topics` section (never one inside a fence) with the given topics; append one if absent. */
|
|
101
|
-
export function replaceTopicsSection(body: string, topics: string[]): string {
|
|
102
|
-
const prose = proseLines(body), raw = body.split("\n");
|
|
103
|
-
let start = -1;
|
|
104
|
-
for (let i = prose.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics[ \t]*#*[ \t]*$/.test(prose[i]!)) { start = i; break; }
|
|
105
|
-
const section = ["## Topics", "", ...topics.map(topic => `- ${topic}`), ""];
|
|
106
|
-
if (start < 0) return `${body.replace(/\n*$/, "")}\n\n${section.join("\n")}`;
|
|
107
|
-
// The section ends at the next prose heading of level 1 or 2.
|
|
108
|
-
let end = raw.length;
|
|
109
|
-
for (let i = start + 1; i < prose.length; i++) if (/^ {0,3}#{1,2}[ \t]/.test(prose[i]!)) { end = i; break; }
|
|
110
|
-
const tail = raw.slice(end);
|
|
111
|
-
return [...raw.slice(0, start), ...section, ...(tail.length ? tail : [])].join("\n").replace(/\n*$/, "\n");
|
|
112
|
-
}
|
|
113
100
|
/** Replace visible wiki links whose target does not resolve in the target vault with their plain text. */
|
|
114
101
|
export function dropUnresolvedLinks(body: string, resolves: (target: string) => boolean): { body: string; dropped: string[] } {
|
|
115
102
|
const prose = proseLines(body), raw = body.split("\n"), dropped: string[] = [];
|