@staix/vault-mem 0.2.4 → 0.3.1
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 +15 -0
- package/README.md +9 -6
- package/package.json +2 -2
- package/src/amem/seam.ts +5 -3
- package/src/cli.ts +22 -8
- package/src/commands-table.ts +2 -1
- package/src/config.ts +56 -19
- package/src/log-format.ts +4 -3
- package/src/registry.ts +4 -1
- package/src/setup.ts +53 -16
- package/src/sync.ts +17 -5
- package/src/tidy.ts +3 -0
- package/src/vault-index.ts +9 -4
- package/src/vault-log.ts +3 -0
- package/src/write-validation.ts +1 -1
- package/src/writer.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.1] - 2026-11-01
|
|
6
|
+
|
|
7
|
+
- `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.
|
|
8
|
+
|
|
9
|
+
## [0.3.0] - 2026-11-01
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- Versions coexist on one machine (#82). `vmem mcp` loads every module a tool can run at startup, the agent-mem root included, so a running server keeps its own code and tool list when its install is replaced or a newer release is installed beside it.
|
|
13
|
+
- `vmem setup <target> --root <path>` registers another install root, such as `<install>/releases/<version>/`, resolved to its real path. Without `--root` setup registers the running install as before.
|
|
14
|
+
- `vmem.config.json` accepts an optional `version` (format 1). A config from a newer format is read tolerantly and its unknown keys survive registry writes; at or below format 1 unknown keys stay errors. Log screening accepts the same extended PII policy, and `vaults pii --set` refuses to replace a policy whose unknown members it would drop.
|
|
15
|
+
- The derived index is `indexes/<sha256>-links<N>.sqlite`, named per link-resolution version, so two versions alternating on one vault no longer rebuild each other's links. The first run after upgrading builds it once.
|
|
16
|
+
- `vmem manifest --json` lists the version, the config format, the MCP tool names and the CLI commands.
|
|
17
|
+
- The embedded `@staix/agent-mem` pin moves from 0.11.0 to 0.17.0, at or above agent-mem's 0.13 floor for agent workflows.
|
|
18
|
+
- N-1: 0.2.4 runs beside this release. It keeps its own unversioned index file and reads the config this release writes, since this release never adds `version`; 0.2.4 refuses a config that carries `version` or any other key it does not know.
|
|
19
|
+
|
|
5
20
|
## [0.2.4] - 2026-11-01
|
|
6
21
|
|
|
7
22
|
- Report names (`graph-report-YYMMDD.md`, `lint-report-YYMMDD.md`), graph trend rows, L11 staleness, `promoted_at` (agent-mem and org promotion) and the skill-candidate report date now use the local calendar date, the same clock as the log lines, instead of UTC. A run early in the morning in a timezone ahead of UTC is no longer dated the previous day. `options.today` and `now` overrides still apply. Skill mining's `unique_days` keeps the UTC day of each record's `...Z` timestamp, as Python's `.date()` does.
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Home precedence: `VMEM_HOME` (the pointer is not consulted), else a `home` point
|
|
|
25
25
|
{"vaults":[{"id":"personal","path":"/absolute/vault","kind":"personal","write":"mcp"}],"denyPaths":[],"piiPatterns":[]}
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Configuration is limited to 64 KiB both on read and after serialization (a change whose pretty-printed result exceeds the limit is refused and the file is unchanged), rejects unknown keys, and is updated under an exclusive lock using a flushed temporary file and atomic rename. Errors name the config file or vault id and path but never echo config or note contents.
|
|
28
|
+
Configuration is limited to 64 KiB both on read and after serialization (a change whose pretty-printed result exceeds the limit is refused and the file is unchanged), rejects unknown keys, and is updated under an exclusive lock using a flushed temporary file and atomic rename. An optional top-level `version` (positive integer, absent means 1) names the config format; this build writes format 1 and never adds `version` itself, so 0.2.4 and older keep reading what it writes. A config whose `version` is newer than this build's format is read tolerantly: unknown keys at the top level, in vaults and in PII policies are kept and written back unchanged by registry commands, while the keys this build knows are still validated. A PII policy with members this build does not know cannot be enforced, so it fails closed: every write on that vault (note writes and `check`, two-step previews and confirms, tidy, `promote-from-amem`, `promote` and log appends) is refused with `PII_POLICY`, naming the members, until the newer vmem that wrote the policy does the write. Reads and `vaults pii` show are unaffected. `vaults pii --set` refuses to replace a policy that carries such members, since the replacement would drop them; change it with the newer vmem. Versions that add a key bump the format. Errors name the config file or vault id and path but never echo config or note contents.
|
|
29
29
|
|
|
30
30
|
Durability: the temporary file is fsynced before the rename and the home directory is fsynced after it. On Linux this makes the new config durable once the command returns. On macOS directory fsync succeeds but does not flush the drive cache (no `F_FULLFSYNC`), so a power loss right after a change can revert to the previous complete config, never to a partial one. Windows is not a supported platform for durability claims; the rename stays atomic.
|
|
31
31
|
|
|
@@ -33,7 +33,7 @@ Crash recovery: the lock file records the writer's pid, host and start time. Not
|
|
|
33
33
|
|
|
34
34
|
Vault fields: `id`, absolute `path` (must contain a real `notes/` directory), `kind` (`personal|org`), `write` (`mcp|cli|none`), optional `pii` (the `allow|deny` shorthand or a policy object, see PII policy below; default `allow` for personal and `deny` for org, and `status`/`vaults list` always report the effective shorthand and `piiPolicy` matrix, including for unhealthy entries), optional `remote` (any nonempty string such as an `https://` or `git@host:path` URL) and optional `registry` (absolute path). For healthy vaults, `doctor` checks locally that the vault is a git repository root with an origin whose fetch and push URLs all match `remote` (URL rewrites are refused), and that `registry` passes the same deny-path, size and project-id validation as log appends. Results appear under each vault's `checks.remote` and `checks.registry`; a failed check exits 1. These checks use no network and do not test reachability. `status` is unchanged. Policy fields are stored for later note-writing stages, not enforced by the read commands. Writer commands enforce write/channel policy and configured deny/PII screening.
|
|
35
35
|
|
|
36
|
-
The only runtime dependency is the exact first-party pin `@staix/agent-mem@0.
|
|
36
|
+
The only runtime dependency is the exact first-party pin `@staix/agent-mem@0.17.0`; it is not loaded by registry commands. Public library exports cover configuration, home resolution, registry operations, frontmatter, link resolution and the derived index. No console bin or UI contract is advertised before its implementation.
|
|
37
37
|
|
|
38
38
|
# PII policy
|
|
39
39
|
|
|
@@ -87,7 +87,7 @@ bun bin/vmem.js links personal "Note or alias" --limit 50 --json
|
|
|
87
87
|
|
|
88
88
|
All four commands require a registered vault id. They read only flat, regular `notes/*.md` files (case-sensitive `.md` extension), skip subdirectories and symlink files, and refuse a replaced vault/notes directory. Notes are limited to 2 MiB each. They never write note content, registry configuration or `.obsidian/`. `rebuild` rebuilds the derived cache; other commands scan metadata on every invocation and rehash new or metadata-changed files. Content-identical changes only update metadata; deleted/renamed paths leave the index. SQLite transactions make each scan atomic. A concurrently changing note aborts that scan, so retry after its writer finishes. No watcher, model calls or embeddings run.
|
|
89
89
|
|
|
90
|
-
The cache is `$VMEM_HOME/indexes/<canonical-vault-path-sha256>.sqlite` using the resolved home, not necessarily the environment variable. Cache files use mode 0600 on creation. The location must be outside every registered vault, including through symlinks; linked cache directories/files are refused. It contains note text and is disposable private derived state. Removing it while commands are stopped allows the next read to build it again. SQLite uses its own transaction lock and a five-second busy timeout. There is no automatic migration or live vault cutover.
|
|
90
|
+
The cache is `$VMEM_HOME/indexes/<canonical-vault-path-sha256>-links<N>.sqlite` using the resolved home, not necessarily the environment variable. `N` is the link-resolution version (currently 2), so two installed vmem versions that resolve links differently keep separate caches instead of rebuilding each other's links; 0.2.4 and older use the unversioned `<sha256>.sqlite`, and a first run after upgrading builds the new file once. Cache files use mode 0600 on creation. The location must be outside every registered vault, including through symlinks; linked cache directories/files are refused. It contains note text and is disposable private derived state. Removing it while commands are stopped allows the next read to build it again. SQLite uses its own transaction lock and a five-second busy timeout. There is no automatic migration or live vault cutover.
|
|
91
91
|
|
|
92
92
|
Search uses FTS5 `unicode61` over title, description and body with BM25 weights 5:2:1, ascending score and path tie-breaking. Whitespace-separated query terms are literal AND terms, not a raw FTS expression; quote the query as one shell argument. `--type` and `--domain` are exact scalar frontmatter filters. Results include a bounded snippet and metadata, not the full body. Search queries and note targets accept at most 1024 UTF-8 bytes. Search/links default to 50 results, with an enforced 1..500 range. Show defaults to 16384 UTF-8 bytes and permits 1..65536, returning `truncated` and the full note SHA-256. `--section "Exact H2"` selects one real H2 through the next H1/H2, including its H3 children; code-fenced headings are ignored and duplicate/missing names refuse. Truncated reads include bounded `headings` (at most 64 names/8KiB metadata; `headingsTruncated` when omitted) and `nextOffset`. `--offset N` is a UTF-8 byte offset within the selected section or whole note; use the returned `nextOffset` to page even a single section larger than 64 KiB, and compare note hashes across pages. Invalid UTF-8 boundaries and zero-progress byte budgets refuse. Link string fields are capped at 1024 bytes in command results. Oversized arguments fail instead of silently raising a bound.
|
|
93
93
|
|
|
@@ -146,7 +146,7 @@ Readers never create a home, log or lock, and work with `write: none`. They skip
|
|
|
146
146
|
|
|
147
147
|
# MCP server
|
|
148
148
|
|
|
149
|
-
`vmem mcp` serves newline-delimited JSON-RPC 2.0 (MCP tools, protocol 2025-06-18 and earlier) on stdio. stdout carries protocol messages only; it needs no TTY and runs in agent sessions. Requests are limited to 64 KiB per frame and results to 256 KiB (a larger result becomes a structured `LIMIT` refusal). Arguments are validated against each tool's bounded schema (`additionalProperties: false`, string/array/integer caps); invalid arguments or unknown tools return JSON-RPC `-32602`, unknown methods `-32601`, malformed frames `-32700`/`-32600`.
|
|
149
|
+
`vmem mcp` serves newline-delimited JSON-RPC 2.0 (MCP tools, protocol 2025-06-18 and earlier) on stdio. stdout carries protocol messages only; it needs no TTY and runs in agent sessions. Requests are limited to 64 KiB per frame and results to 256 KiB (a larger result becomes a structured `LIMIT` refusal). Arguments are validated against each tool's bounded schema (`additionalProperties: false`, string/array/integer caps); invalid arguments or unknown tools return JSON-RPC `-32602`, unknown methods `-32601`, malformed frames `-32700`/`-32600`. At startup it loads every module a tool can run, the `@staix/agent-mem` root included, so a server keeps its own code and tool list when its install is replaced or a newer release is installed beside it; restart the host session to pick up the new release.
|
|
150
150
|
|
|
151
151
|
The vault set is read once at startup: every tool's `vault` property is an enum of the registered ids and defaults to the first `personal` vault. Restart the host session after `vaults add/remove`. Reads go through the same index as the CLI; writes go through `applyWrite`/`appendLog` with channel `mcp`, so they require `write: mcp` and pass the same V01-V10 gates. Every tool returns a JSON result: a receipt, or `{refused: true, issues}` for policy, validation or lookup failures.
|
|
152
152
|
|
|
@@ -183,14 +183,17 @@ vmem setup claude # dry-run: lists the file and change per host, writ
|
|
|
183
183
|
vmem setup all --apply # claude codex kimi pi qwen jcode
|
|
184
184
|
vmem setup agy --apply # run inside the workspace; writes ./.agents/mcp_config.json
|
|
185
185
|
vmem setup all --undo
|
|
186
|
+
vmem setup all --root ~/.local/share/vmem/releases/0.3.0 --apply # register a versioned install root
|
|
186
187
|
```
|
|
187
188
|
|
|
188
|
-
Setup registers one server entry named `vmem` (`<bun> --smol <package>/bin/vmem.js mcp`) by editing the host's own MCP configuration: `~/.claude.json` (`mcpServers`; `$CLAUDE_CONFIG_DIR/.claude.json` when set), `$CODEX_HOME/config.toml` (`[mcp_servers.vmem]`), `$KIMI_CODE_HOME/mcp.json`, `$PI_CODING_AGENT_DIR/mcp.json`, `$QWEN_HOME/settings.json` (all `mcpServers`), `$JCODE_HOME/mcp.json` (`servers`) and the workspace's `.agents/mcp_config.json` for Antigravity. Defaults are `~/.codex`, `~/.kimi-code`, `~/.pi/agent`, `~/.qwen` and `~/.jcode`; overrides must be absolute. It does not call host CLIs and does not need a TTY. Run it while the host is closed: Claude Code in particular rewrites `.claude.json` while it runs and can overwrite a concurrent change.
|
|
189
|
+
Setup registers one server entry named `vmem` (`<bun> --smol <package>/bin/vmem.js mcp`) by editing the host's own MCP configuration: `~/.claude.json` (`mcpServers`; `$CLAUDE_CONFIG_DIR/.claude.json` when set), `$CODEX_HOME/config.toml` (`[mcp_servers.vmem]`), `$KIMI_CODE_HOME/mcp.json`, `$PI_CODING_AGENT_DIR/mcp.json`, `$QWEN_HOME/settings.json` (all `mcpServers`), `$JCODE_HOME/mcp.json` (`servers`) and the workspace's `.agents/mcp_config.json` for Antigravity. Defaults are `~/.codex`, `~/.kimi-code`, `~/.pi/agent`, `~/.qwen` and `~/.jcode`; overrides must be absolute. It does not call host CLIs and does not need a TTY. `--root <absolute path>` registers another vault-mem install root instead of the running one, for example a versioned `<install>/releases/<version>/` that a newer release is installed beside; the root is resolved to its real path (a `current` link registers the release it names) and must hold the package's `package.json` (a plain file of at most 64 KiB) and `bin/vmem.js`. The report names the root and its version. The current registration changes only on `--apply`; running servers keep their release until the host session restarts. Run it while the host is closed: Claude Code in particular rewrites `.claude.json` while it runs and can overwrite a concurrent change.
|
|
189
190
|
|
|
190
191
|
Apply is idempotent and touches only the `vmem` entry: other servers and settings are preserved (TOML is edited textually, so comments and other tables stay byte-identical; JSON is re-serialized with its indentation). Configuration files must be plain strict JSON or TOML; links, JSONC and unparsable files are refused per host without writing, and the command exits 1. Apply records the prior `vmem` value in a receipt, `$VMEM_HOME/setup/<host>-<hash of the config path>.json`, so separate host profiles (for example two `CODEX_HOME`s) sharing one `VMEM_HOME` keep separate receipts. The receipt is written before the host file; if the host write fails, the receipt is rolled back. In TOML, every `[mcp_servers.vmem]` and `[mcp_servers.vmem.*]` table belongs to the entry, even when other tables sit between them. Undo restores that prior value (or removes the entry, and the file if setup created it and nothing else was added) only while the entry is still the one setup installed; an entry edited later is left alone and reported as `refused`. Every mode also handles the console plugin manifest (see "Console plugin"): apply writes it, undo removes it while it still points at this install.
|
|
191
192
|
|
|
192
193
|
`vmem completion bash|zsh|fish` prints a completion script generated from the same command table as `--help`.
|
|
193
194
|
|
|
195
|
+
`vmem manifest --json` prints `{name, version, configVersion, mcp: {server, tools}, commands}`: the package version, the config format it writes, the MCP tool names and the CLI command names from the same table, for tools that would otherwise hard-code them.
|
|
196
|
+
|
|
194
197
|
# Promotion from agent-mem
|
|
195
198
|
|
|
196
199
|
`vmem promote-from-amem` turns one durable agent-mem entry into a vault note and records agent-mem's own vault link back to it. Agent memory is never indexed or copied into a second store, and no agent-mem tool is added: candidates come from agent-mem's `vaultCandidates` selection and the link from its `recordVaultLink`.
|
|
@@ -202,7 +205,7 @@ vmem promote-from-amem personal mem:<id> ... --confirm --fingerprint <sha>
|
|
|
202
205
|
vmem promote-from-amem personal mem:<id> --link-to existing-note # link an existing note instead of creating one
|
|
203
206
|
```
|
|
204
207
|
|
|
205
|
-
- `src/amem/seam.ts` is the only module that imports the `@staix/agent-mem` root (exact pin 0.
|
|
208
|
+
- `src/amem/seam.ts` is the only module that imports the `@staix/agent-mem` root (exact pin 0.17.0, at or above agent-mem's 0.13 floor for agent workflows). Reads, writes and other commands never load it; promotion and mining load it when they run, and `vmem mcp` loads it at startup. The project defaults to agent-mem's key for the current directory (`--project` overrides); `AMEM_HOME` selects the memory home as in agent-mem.
|
|
206
209
|
- Listing and linking require agent-mem's generation profile; a shared-v1 memory home is refused. Candidate listing applies agent-mem's durable selection and policy (secrets, default PII and the vault config's `piiPatterns`) before ordering and `--limit`; previews screen the requested entry with the same policy.
|
|
207
210
|
- The preview prefills the H1 and stem from the entry title, the body from its text, `about[]` as Relevant Notes hints, and provenance `promoted_from: mem:<id>` plus `promoted_at`. `--stem --type --domain --topics --description --title` and `--body file|-` override it; none of this needs a TTY. When a note with the same stem or title exists, the preview is refused with V08 and lists `link-existing <path>` choices; `--link-to` takes one. `--edit` opens `$VISUAL`/`$EDITOR` on the draft only when stdin is a terminal (the only TTY check); the edited text is previewed and confirmed with `--body`.
|
|
208
211
|
- Confirm requires the preview fingerprint, then creates the note through `applyWrite` (V01-V10), appends one `LEARN <project> mem:<id> → notes/<stem>.md <em-dash> promoted: <kind>` event (`--log-project` sets the project field; it defaults to the last segment of the agent-mem project key) and records the vault link last.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/vault-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Validated Obsidian-compatible knowledge vaults for agents and people",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"bun": ">=1.4.2"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@staix/agent-mem": "0.
|
|
15
|
+
"@staix/agent-mem": "0.17.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@staix/agent-console": "github:STAIxBWLB/agent-console#bd7234f",
|
package/src/amem/seam.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The only module that imports the `@staix/agent-mem` root.
|
|
3
|
-
*
|
|
4
|
-
* lists candidates from amem's own selection and records amem's own vault link.
|
|
2
|
+
* The only module that imports the `@staix/agent-mem` root. Promotion and mining load it lazily, so reads,
|
|
3
|
+
* writes and other commands never pay for it; `vmem mcp` loads it at start (`preloadAmem`). No agent memory
|
|
4
|
+
* is indexed or copied here: the seam lists candidates from amem's own selection and records amem's own vault link.
|
|
5
5
|
*/
|
|
6
6
|
import type { MemoryStore, View } from "@staix/agent-mem";
|
|
7
7
|
|
|
@@ -17,6 +17,8 @@ export interface AmemSeam {
|
|
|
17
17
|
}
|
|
18
18
|
export interface AmemOptions { env?: NodeJS.ProcessEnv; project?: string; cwd?: string; piiPatterns?: string[] }
|
|
19
19
|
const ID = /^[a-f0-9]{32}$/;
|
|
20
|
+
/** Load the agent-mem root now. `vmem mcp` calls it at start; every other caller keeps the lazy load. */
|
|
21
|
+
export async function preloadAmem(): Promise<void> { await import("@staix/agent-mem"); }
|
|
20
22
|
function view(value: View): AmemCandidate {
|
|
21
23
|
return { id: value.id, project: value.project, kind: value.kind, title: value.title, text: value.text, about: value.about.map(node => String(node)), at: value.at };
|
|
22
24
|
}
|
package/src/cli.ts
CHANGED
|
@@ -6,7 +6,9 @@ import { runOrgCommand } from "./org-commands.ts";
|
|
|
6
6
|
import { isWriteCommand, runWriteCommand } from "./write-commands.ts";
|
|
7
7
|
import { isReadCommand, runReadCommand } from "./read-commands.ts";
|
|
8
8
|
import { version } from "../package.json";
|
|
9
|
-
import { completionScript, help } from "./commands-table.ts";
|
|
9
|
+
import { COMMANDS, completionScript, help } from "./commands-table.ts";
|
|
10
|
+
import { VMEM_TOOL_NAMES } from "./mcp-vault.ts";
|
|
11
|
+
import { CONFIG_VERSION } from "./config.ts";
|
|
10
12
|
import { runSetup, type SetupMode } from "./setup.ts";
|
|
11
13
|
import { resolveHome } from "./home.ts";
|
|
12
14
|
import { addVault, effectivePii, loadRegistry, recoverRegistry, registryStatus, removeVault, setVaultPii } from "./registry.ts";
|
|
@@ -28,12 +30,18 @@ function parse(args: string[], allowed: string[], values: string[]) {
|
|
|
28
30
|
export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
|
|
29
31
|
if (!args.length || (args.length === 1 && ["--help", "-h", "help"].includes(args[0]!))) return { text: help(), code: 0 };
|
|
30
32
|
if (args.length === 1 && args[0] === "--version") return { text: version, code: 0 };
|
|
33
|
+
if (args[0] === "manifest") {
|
|
34
|
+
const { flags, positional } = parse(args.slice(1), ["--json"], []);
|
|
35
|
+
if (positional.length) throw new Error("manifest: unexpected arguments");
|
|
36
|
+
const manifest = { name: "@staix/vault-mem", version, configVersion: CONFIG_VERSION, mcp: { server: "vmem", tools: [...VMEM_TOOL_NAMES] }, commands: COMMANDS.map(c => c.name) };
|
|
37
|
+
return { text: JSON.stringify(manifest, null, flags["--json"] ? undefined : 2), code: 0 };
|
|
38
|
+
}
|
|
31
39
|
if (args[0] === "setup") {
|
|
32
|
-
const
|
|
33
|
-
if (positional.length !== 1 || flags
|
|
34
|
-
const mode: SetupMode = flags
|
|
35
|
-
const report = runSetup(positional[0]!, mode, { home: resolveHome(env).home, env });
|
|
36
|
-
return { text: JSON.stringify(report, null, flags
|
|
40
|
+
const { flags, positional } = parse(args.slice(1), ["--apply", "--undo", "--json", "--root"], ["--root"]);
|
|
41
|
+
if (positional.length !== 1 || (flags["--apply"] && flags["--undo"])) throw new Error("setup: requires one target and at most one of --apply/--undo");
|
|
42
|
+
const mode: SetupMode = flags["--apply"] ? "apply" : flags["--undo"] ? "undo" : "dry-run";
|
|
43
|
+
const report = runSetup(positional[0]!, mode, { home: resolveHome(env).home, env, ...(typeof flags["--root"] === "string" ? { root: flags["--root"] } : {}) });
|
|
44
|
+
return { text: JSON.stringify(report, null, flags["--json"] ? undefined : 2), code: [...report.actions, report.console].some(a => a?.change === "refused") ? 1 : 0 };
|
|
37
45
|
}
|
|
38
46
|
if (args[0] === "completion") {
|
|
39
47
|
if (args.length !== 2) throw new Error("completion: requires bash, zsh or fish");
|
|
@@ -107,9 +115,15 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
|
|
|
107
115
|
}
|
|
108
116
|
return { text: JSON.stringify(result, null, flags["--json"] ? undefined : 2), code };
|
|
109
117
|
}
|
|
110
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* `vmem mcp`: the vault set is fixed now; stdout carries protocol frames only. Every module a tool handler
|
|
120
|
+
* loads lazily, the agent-mem root included, is loaded here at start, so an install replaced under a running
|
|
121
|
+
* server never mixes two versions in one process.
|
|
122
|
+
*/
|
|
111
123
|
export async function runMcp(env: NodeJS.ProcessEnv = process.env): Promise<void> {
|
|
112
|
-
const [{ serveMcp }, { createVaultMcp }] = await Promise.all([import("./mcp.ts"), import("./mcp-vault.ts")
|
|
124
|
+
const [{ serveMcp }, { createVaultMcp }, , , , seam] = await Promise.all([import("./mcp.ts"), import("./mcp-vault.ts"),
|
|
125
|
+
import("./promote.ts"), import("./org-promote.ts"), import("./skills-mine.ts"), import("./amem/seam.ts")]);
|
|
126
|
+
await seam.preloadAmem();
|
|
113
127
|
const server = createVaultMcp(resolveHome(env).home, env);
|
|
114
128
|
await serveMcp({ tools: server.tools, handlers: server.handlers, name: "vmem", version });
|
|
115
129
|
}
|
package/src/commands-table.ts
CHANGED
|
@@ -32,7 +32,8 @@ export const COMMANDS = [
|
|
|
32
32
|
{ name: "promote", usage: "promote <personal-vault> <note> --to ORG [--topics a,b] [--again] [--log-project ID] [--confirm --fingerprint SHA] [--json]", mutates: "confirm" },
|
|
33
33
|
{ name: "sync", usage: "sync <vault> [--message TEXT] [--confirm --fingerprint SHA] [--json]", mutates: "confirm" },
|
|
34
34
|
{ name: "mcp", usage: "mcp", mutates: false },
|
|
35
|
-
{ name: "setup", usage: "setup <claude|codex|kimi|pi|qwen|jcode|agy|all> [--apply|--undo] [--json]", mutates: "apply" },
|
|
35
|
+
{ name: "setup", usage: "setup <claude|codex|kimi|pi|qwen|jcode|agy|all> [--root PATH] [--apply|--undo] [--json]", mutates: "apply" },
|
|
36
|
+
{ name: "manifest", usage: "manifest [--json]", mutates: false },
|
|
36
37
|
{ name: "completion", usage: "completion <bash|zsh|fish>", mutates: false },
|
|
37
38
|
] as const;
|
|
38
39
|
const RELATIONS = ["supports", "contradicts", "extends", "applies-to", "context-for"] as const;
|
package/src/config.ts
CHANGED
|
@@ -19,14 +19,30 @@ export type PiiCell = PiiAction | Partial<Record<PiiSurface, PiiAction>>;
|
|
|
19
19
|
export interface PiiPattern { name: string; regex: string; action?: PiiCell }
|
|
20
20
|
/** Fine-grained PII policy: per category, optionally per surface; omitted cells take the vault default. */
|
|
21
21
|
export interface PiiSpec { email?: PiiCell; phone?: PiiCell; custom?: PiiCell; patterns?: PiiPattern[]; allow?: string[] }
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* The config format this build writes. A build that adds a key bumps it. A config whose `version` is newer
|
|
24
|
+
* than this build is read tolerantly: unknown keys are kept as they are and written back unchanged, so an
|
|
25
|
+
* older vmem can share a config with a newer one. At or below this version unknown keys stay errors (typos).
|
|
26
|
+
* An absent `version` means 1, and this build never adds one, so 0.2.4 and older keep reading what it writes.
|
|
27
|
+
*/
|
|
28
|
+
export const CONFIG_VERSION = 1;
|
|
29
|
+
export interface VmemConfig { version?: number; home?: string; vaults: VaultSpec[]; denyPaths?: string[]; piiPatterns?: string[] }
|
|
23
30
|
const controls = /[\x00-\x1f\x7f]/;
|
|
24
|
-
function object(value: unknown, keys: string[], label: string): Record<string, unknown> {
|
|
31
|
+
function object(value: unknown, keys: string[], label: string, tolerant = false): Record<string, unknown> {
|
|
25
32
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label}: expected object`);
|
|
26
33
|
const record = value as Record<string, unknown>;
|
|
27
|
-
for (const key of Object.keys(record)) if (!keys.includes(key)) throw new Error(`${label}: unknown key ${key}`);
|
|
34
|
+
if (!tolerant) for (const key of Object.keys(record)) if (!keys.includes(key)) throw new Error(`${label}: unknown key ${key}`);
|
|
28
35
|
return record;
|
|
29
36
|
}
|
|
37
|
+
/** True when `config` is in a format newer than this build: its unknown keys are kept, not refused. */
|
|
38
|
+
export function newerFormat(config: unknown): boolean {
|
|
39
|
+
const version = config && typeof config === "object" ? (config as { version?: unknown }).version : undefined;
|
|
40
|
+
return typeof version === "number" && Number.isSafeInteger(version) && version > CONFIG_VERSION;
|
|
41
|
+
}
|
|
42
|
+
/** The keys of `record` this build does not know, kept only when the config is newer than this build. */
|
|
43
|
+
function unknown(record: Record<string, unknown>, keys: string[], tolerant: boolean): Record<string, unknown> {
|
|
44
|
+
return tolerant ? Object.fromEntries(Object.entries(record).filter(([key]) => !keys.includes(key))) : {};
|
|
45
|
+
}
|
|
30
46
|
function string(value: unknown, label: string): string {
|
|
31
47
|
if (typeof value !== "string" || !value.trim() || controls.test(value)) throw new Error(`${label}: expected nonempty string without control characters`);
|
|
32
48
|
return value;
|
|
@@ -46,12 +62,12 @@ function strings(value: unknown, label: string): string[] | undefined {
|
|
|
46
62
|
});
|
|
47
63
|
}
|
|
48
64
|
const ACTIONS = new Set(["allow", "warn", "deny"]);
|
|
49
|
-
function cell(value: unknown, label: string): PiiCell {
|
|
65
|
+
function cell(value: unknown, label: string, tolerant = false): PiiCell {
|
|
50
66
|
if (typeof value === "string") {
|
|
51
67
|
if (!ACTIONS.has(value)) throw new Error(`${label}: expected allow, warn or deny`);
|
|
52
68
|
return value as PiiAction;
|
|
53
69
|
}
|
|
54
|
-
const record = object(value, [...PII_SURFACES], label), result
|
|
70
|
+
const record = object(value, [...PII_SURFACES], label, tolerant), result = unknown(record, [...PII_SURFACES], tolerant) as Partial<Record<PiiSurface, PiiAction>>;
|
|
55
71
|
for (const surface of PII_SURFACES) if (record[surface] !== undefined) {
|
|
56
72
|
if (typeof record[surface] !== "string" || !ACTIONS.has(record[surface] as string)) throw new Error(`${label}.${surface}: expected allow, warn or deny`);
|
|
57
73
|
result[surface] = record[surface] as PiiAction;
|
|
@@ -59,54 +75,75 @@ function cell(value: unknown, label: string): PiiCell {
|
|
|
59
75
|
return result;
|
|
60
76
|
}
|
|
61
77
|
/** Validate `vault.pii`: the `allow`/`deny` shorthand or a PiiSpec object. */
|
|
62
|
-
export function parsePii(value: unknown): "allow" | "deny" | PiiSpec {
|
|
78
|
+
export function parsePii(value: unknown, tolerant = false): "allow" | "deny" | PiiSpec {
|
|
63
79
|
if (value === "allow" || value === "deny") return value;
|
|
64
80
|
if (typeof value === "string") throw new Error("vault.pii: expected allow, deny or a policy object");
|
|
65
|
-
const raw = object(value,
|
|
66
|
-
for (const key of ["email", "phone", "custom"] as const) if (raw[key] !== undefined) spec[key] = cell(raw[key], `vault.pii.${key}
|
|
81
|
+
const raw = object(value, PII_KEYS, "vault.pii", tolerant), spec: PiiSpec = unknown(raw, PII_KEYS, tolerant);
|
|
82
|
+
for (const key of ["email", "phone", "custom"] as const) if (raw[key] !== undefined) spec[key] = cell(raw[key], `vault.pii.${key}`, tolerant);
|
|
67
83
|
if (raw.patterns !== undefined) {
|
|
68
84
|
if (!Array.isArray(raw.patterns) || raw.patterns.length > 32) throw new Error("vault.pii.patterns: expected at most 32 patterns");
|
|
69
85
|
const names = new Set<string>();
|
|
70
86
|
spec.patterns = raw.patterns.map((item, index) => {
|
|
71
|
-
const p = object(item,
|
|
87
|
+
const p = object(item, PATTERN_KEYS, `vault.pii.patterns[${index}]`, tolerant);
|
|
72
88
|
const name = string(p.name, "vault.pii.patterns.name"), regex = string(p.regex, "vault.pii.patterns.regex");
|
|
73
89
|
if (!/^[a-z][a-z0-9-]{0,31}$/.test(name) || names.has(name)) throw new Error(`vault.pii.patterns: invalid or duplicate name ${name.slice(0, 40)}`);
|
|
74
90
|
if (Buffer.byteLength(regex) > 256) throw new Error("vault.pii.patterns.regex: exceeds 256 bytes");
|
|
75
91
|
try { new RegExp(regex, "u"); } catch { throw new Error(`vault.pii.patterns.${name}: invalid regular expression`); }
|
|
76
92
|
names.add(name);
|
|
77
|
-
return { name, regex, ...(p.action !== undefined ? { action: cell(p.action, `vault.pii.patterns.${name}.action
|
|
93
|
+
return { ...unknown(p, PATTERN_KEYS, tolerant), name, regex, ...(p.action !== undefined ? { action: cell(p.action, `vault.pii.patterns.${name}.action`, tolerant) } : {}) };
|
|
78
94
|
});
|
|
79
95
|
}
|
|
80
96
|
if (raw.allow !== undefined) spec.allow = strings(raw.allow, "vault.pii.allow");
|
|
81
97
|
return spec;
|
|
82
98
|
}
|
|
83
|
-
|
|
84
|
-
|
|
99
|
+
const PII_KEYS = ["email", "phone", "custom", "patterns", "allow"], PATTERN_KEYS = ["name", "regex", "action"];
|
|
100
|
+
/** Members of a parsed policy that this build does not know (kept from a newer-format config), as dotted paths. */
|
|
101
|
+
export function piiExtensions(pii: VaultSpec["pii"]): string[] {
|
|
102
|
+
if (!pii || typeof pii !== "object") return [];
|
|
103
|
+
const extra = (value: unknown, keys: readonly string[], at: string) => value && typeof value === "object" ? Object.keys(value).filter(key => !keys.includes(key)).map(key => at + key) : [];
|
|
104
|
+
const record = pii as Record<string, unknown>;
|
|
105
|
+
return [...extra(record, PII_KEYS, ""), ...(["email", "phone", "custom"] as const).flatMap(key => extra(record[key], PII_SURFACES, `${key}.`)),
|
|
106
|
+
...(pii.patterns ?? []).flatMap(p => [...extra(p, PATTERN_KEYS, `patterns.${p.name}.`), ...extra(p.action, PII_SURFACES, `patterns.${p.name}.action.`)])];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Fail closed: a vault whose PII policy has members this build does not know (kept from a newer-format config)
|
|
110
|
+
* cannot be screened as that policy asks, so every write and log append on it is refused with this reason.
|
|
111
|
+
*/
|
|
112
|
+
export function unenforceablePii(vault: VaultSpec): string | undefined {
|
|
113
|
+
const members = piiExtensions(vault.pii);
|
|
114
|
+
return members.length ? `vault ${vault.id}: PII policy has members this vmem cannot enforce (${members.slice(0, 8).join(", ")}); write with the newer vmem that wrote the policy` : undefined;
|
|
115
|
+
}
|
|
116
|
+
export function parseVault(value: unknown, tolerant = false): VaultSpec {
|
|
117
|
+
const keys = ["id", "path", "kind", "write", "pii", "remote", "registry"], v = object(value, keys, "vault", tolerant);
|
|
85
118
|
const id = string(v.id, "vault.id");
|
|
86
119
|
if (!/^[a-z][a-z0-9-]{0,31}$/.test(id)) throw new Error("vault.id: invalid id");
|
|
87
120
|
if (v.kind !== "personal" && v.kind !== "org") throw new Error("vault.kind: expected personal or org");
|
|
88
121
|
if (v.write !== "mcp" && v.write !== "cli" && v.write !== "none") throw new Error("vault.write: expected mcp, cli or none");
|
|
89
|
-
const result: VaultSpec = { id, path: absolute(v.path, "vault.path"), kind: v.kind, write: v.write };
|
|
90
|
-
if (v.pii !== undefined) result.pii = parsePii(v.pii);
|
|
122
|
+
const result: VaultSpec = { ...unknown(v, keys, tolerant), id, path: absolute(v.path, "vault.path"), kind: v.kind, write: v.write };
|
|
123
|
+
if (v.pii !== undefined) result.pii = parsePii(v.pii, tolerant);
|
|
91
124
|
if (v.remote !== undefined) result.remote = string(v.remote, "vault.remote");
|
|
92
125
|
if (v.registry !== undefined) result.registry = absolute(v.registry, "vault.registry");
|
|
93
126
|
return result;
|
|
94
127
|
}
|
|
95
128
|
export function parseConfig(value: unknown): VmemConfig {
|
|
96
|
-
const
|
|
129
|
+
const keys = ["version", "home", "vaults", "denyPaths", "piiPatterns"];
|
|
130
|
+
const version = (value as Record<string, unknown> | null)?.version;
|
|
131
|
+
if (version !== undefined && (!Number.isSafeInteger(version) || (version as number) < 1)) throw new Error("config.version: expected a positive integer");
|
|
132
|
+
const tolerant = newerFormat(value), raw = object(value, keys, "config", tolerant);
|
|
133
|
+
const stamp = version === undefined ? {} : { version: version as number };
|
|
97
134
|
if (raw.home !== undefined) {
|
|
98
|
-
if (
|
|
99
|
-
return { home: absolute(raw.home, "config.home"), vaults: [] };
|
|
135
|
+
if (["vaults", "denyPaths", "piiPatterns"].some(key => raw[key] !== undefined)) throw new Error("config: home pointer must not contain vaults or policy");
|
|
136
|
+
return { ...unknown(raw, keys, tolerant), ...stamp, home: absolute(raw.home, "config.home"), vaults: [] };
|
|
100
137
|
}
|
|
101
138
|
if (!Array.isArray(raw.vaults)) throw new Error("config.vaults: expected array");
|
|
102
|
-
const vaults = raw.vaults.map(parseVault);
|
|
139
|
+
const vaults = raw.vaults.map(item => parseVault(item, tolerant));
|
|
103
140
|
if (new Set(vaults.map(v => v.id)).size !== vaults.length) throw new Error("config: duplicate vault id");
|
|
104
141
|
if (new Set(vaults.map(v => v.path)).size !== vaults.length) throw new Error("config: duplicate vault path");
|
|
105
142
|
const denyPaths = strings(raw.denyPaths, "denyPaths"), piiPatterns = strings(raw.piiPatterns, "piiPatterns");
|
|
106
143
|
for (const pattern of piiPatterns ?? []) {
|
|
107
144
|
try { new RegExp(pattern); } catch { throw new Error("piiPatterns: invalid regular expression"); }
|
|
108
145
|
}
|
|
109
|
-
return { vaults, ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
|
|
146
|
+
return { ...unknown(raw, keys, tolerant), ...stamp, vaults, ...(denyPaths ? { denyPaths } : {}), ...(piiPatterns ? { piiPatterns } : {}) };
|
|
110
147
|
}
|
|
111
148
|
/** Bounded descriptor read: reject links, devices and oversized/growing files. */
|
|
112
149
|
export function readConfig(home: string): VmemConfig {
|
package/src/log-format.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { localDate } from "./local-date.ts";
|
|
2
2
|
import { screenContent } from "./write-validation.ts";
|
|
3
|
-
import { parsePii } from "./config.ts";
|
|
4
|
-
|
|
3
|
+
import { newerFormat, parsePii } from "./config.ts";
|
|
4
|
+
/** Known fields are always validated; members unknown to this build are accepted only from a newer-format config. */
|
|
5
|
+
function validPii(value: unknown, config: unknown): boolean { try { parsePii(value, newerFormat(config)); return true; } catch { return false; } }
|
|
5
6
|
import type { LogContext, LogEvent } from "./log-types.ts";
|
|
6
7
|
import type { WriteIssue } from "./write-types.ts";
|
|
7
8
|
|
|
@@ -34,7 +35,7 @@ export function formatLogEvent(event: LogEvent, context: LogContext): { line?: s
|
|
|
34
35
|
}
|
|
35
36
|
if ((context.channel !== undefined && context.channel !== "cli" && context.channel !== "mcp") ||
|
|
36
37
|
!record(context.vault) || !["personal", "org"].includes(context.vault.kind) ||
|
|
37
|
-
(context.vault.pii !== undefined && !validPii(context.vault.pii)) ||
|
|
38
|
+
(context.vault.pii !== undefined && !validPii(context.vault.pii, context.config)) ||
|
|
38
39
|
!record(context.config) || !Array.isArray(context.config.vaults) ||
|
|
39
40
|
(context.config.piiPatterns !== undefined && (!Array.isArray(context.config.piiPatterns) || context.config.piiPatterns.some(value => typeof value !== "string"))) ||
|
|
40
41
|
(context.now !== undefined && !(context.now instanceof Date)) ||
|
package/src/registry.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { closeSync, constants, fsyncSync, mkdirSync, openSync, readdirSync, read
|
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parsePii, parseVault, readConfig, type VaultSpec, type VmemConfig } from "./config.ts";
|
|
5
|
+
import { CONFIG_FILE, MAX_CONFIG_BYTES, parseConfig, parsePii, parseVault, piiExtensions, readConfig, type VaultSpec, type VmemConfig } from "./config.ts";
|
|
6
6
|
import { loosenedCells, piiPolicy, piiShorthand } from "./pii.ts";
|
|
7
7
|
export const LOCK_FILE = "config.lock";
|
|
8
8
|
const TEMP = /^\.config-[0-9a-f-]{36}\.tmp$/;
|
|
@@ -178,6 +178,9 @@ export function setVaultPii(home: string, id: string, value: unknown, confirm =
|
|
|
178
178
|
if (!vault) throw new Error(`${join(home, CONFIG_FILE)}: unknown vault id ${id}`);
|
|
179
179
|
const before = piiPolicy(vault);
|
|
180
180
|
if (next === undefined) return { vault: id, pii: piiShorthand(vault), piiPolicy: before };
|
|
181
|
+
// A replacement would drop policy members a newer vmem wrote and this build cannot carry over.
|
|
182
|
+
const extensions = piiExtensions(vault.pii);
|
|
183
|
+
if (extensions.length) throw new Error(`vault ${id}: PII policy has members this vmem does not know (${extensions.slice(0, 8).join(", ")}); change it with the newer vmem that wrote them`);
|
|
181
184
|
const updated: VaultSpec = { ...vault, pii: next }, after = piiPolicy(updated);
|
|
182
185
|
const loosened = loosenedCells(before, after), changed = JSON.stringify(before) !== JSON.stringify(after);
|
|
183
186
|
if (confirm && changed) vault.pii = next;
|
package/src/setup.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -15,7 +15,8 @@ export interface McpEntry { command: string; args: string[] }
|
|
|
15
15
|
interface HostFile { target: SetupTarget; file: string; format: "json" | "toml"; key: string }
|
|
16
16
|
export interface SetupAction { target: SetupTarget; file: string; change: "add" | "replace" | "unchanged" | "restore" | "remove" | "refused" | "none"; reason?: string }
|
|
17
17
|
export interface ConsoleAction { file: string; module: string; change: "add" | "replace" | "unchanged" | "remove" | "none" | "refused"; reason?: string }
|
|
18
|
-
export interface SetupReport { mode: SetupMode; applied: boolean; entry: McpEntry; actions: SetupAction[]; console?: ConsoleAction; notes: string[] }
|
|
18
|
+
export interface SetupReport { mode: SetupMode; applied: boolean; entry: McpEntry; root?: InstallRoot; actions: SetupAction[]; console?: ConsoleAction; notes: string[] }
|
|
19
|
+
export interface InstallRoot { path: string; version: string }
|
|
19
20
|
interface Receipt { version: 1; target: SetupTarget; file: string; existed: boolean; keyExisted?: boolean; prior: unknown; priorToml?: string[]; installed: McpEntry }
|
|
20
21
|
|
|
21
22
|
function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<SetupTarget, HostFile> {
|
|
@@ -37,12 +38,27 @@ function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<SetupTarget, HostFil
|
|
|
37
38
|
agy: { target: "agy", file: join(resolve(cwd), ".agents", "mcp_config.json"), format: "json", key: "mcpServers" },
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
const MAX_PACKAGE_JSON = 64 * 1024;
|
|
42
|
+
/** Bounded plain-file read on one descriptor: links, FIFOs and other non-regular files are refused, never read. */
|
|
43
|
+
function readText(file: string, max = MAX_HOST_CONFIG): string | null {
|
|
44
|
+
let fd: number;
|
|
45
|
+
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
48
|
+
if (code === "ENOENT") return null;
|
|
49
|
+
if (code === "ELOOP") throw new Error(`${file}: expected a plain file (links are refused)`);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const stat = fstatSync(fd), limit = max >= 1024 * 1024 ? `${max / 1024 / 1024} MiB` : `${max / 1024} KiB`;
|
|
54
|
+
if (!stat.isFile()) throw new Error(`${file}: expected a plain file (links are refused)`);
|
|
55
|
+
if (stat.size > max) throw new Error(`${file}: exceeds ${limit}`);
|
|
56
|
+
const buffer = Buffer.alloc(max + 1);
|
|
57
|
+
let size = 0, n: number;
|
|
58
|
+
while (size < buffer.length && (n = readSync(fd, buffer, size, buffer.length - size, null)) > 0) size += n;
|
|
59
|
+
if (size > max) throw new Error(`${file}: exceeds ${limit}`);
|
|
60
|
+
return buffer.subarray(0, size).toString("utf8");
|
|
61
|
+
} finally { closeSync(fd); }
|
|
46
62
|
}
|
|
47
63
|
function atomicWrite(file: string, text: string | null): void {
|
|
48
64
|
if (text === null) { unlinkSync(file); return; }
|
|
@@ -155,21 +171,42 @@ export function stableRuntime(execPath: string = process.execPath, onPath: strin
|
|
|
155
171
|
if (!onPath || !isAbsolute(onPath)) return execPath;
|
|
156
172
|
try { return realpathSync(onPath) === realpathSync(execPath) ? onPath : execPath; } catch { return execPath; }
|
|
157
173
|
}
|
|
158
|
-
|
|
159
|
-
|
|
174
|
+
/** The root of the running install: the directory that holds `bin/`, `src/` and `package.json`. */
|
|
175
|
+
const OWN_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
176
|
+
export function defaultEntry(root: string = OWN_ROOT): McpEntry {
|
|
177
|
+
return { command: stableRuntime(), args: ["--smol", join(root, "bin", "vmem.js"), "mcp"] };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* A vault-mem install root to register, such as `<install>/releases/<version>/`. It is resolved to its real
|
|
181
|
+
* path, so a `current` link registers the release it names and an upgrade beside it never changes what running
|
|
182
|
+
* hosts load.
|
|
183
|
+
*/
|
|
184
|
+
export function installRoot(root: string): InstallRoot {
|
|
185
|
+
if (!isAbsolute(root)) throw new Error("setup: --root must be an absolute path");
|
|
186
|
+
let path: string, text: string | null, pkg: { name?: unknown; version?: unknown };
|
|
187
|
+
try { path = realpathSync(root); text = readText(join(path, "package.json"), MAX_PACKAGE_JSON); }
|
|
188
|
+
catch (error) { throw new Error(`setup: ${root}: package.json is not readable (${error instanceof Error ? error.message : "read failed"})`); }
|
|
189
|
+
if (text === null) throw new Error(`setup: ${root} has no package.json`);
|
|
190
|
+
try { pkg = JSON.parse(text); } catch { throw new Error(`setup: ${root}: package.json is not valid JSON`); }
|
|
191
|
+
if (!pkg || typeof pkg !== "object") throw new Error(`setup: ${root} is not a vault-mem install root`);
|
|
192
|
+
if (pkg.name !== "@staix/vault-mem" || typeof pkg.version !== "string") throw new Error(`setup: ${root} is not a vault-mem install root`);
|
|
193
|
+
try { if (!statSync(join(path, "bin", "vmem.js")).isFile()) throw new Error(); }
|
|
194
|
+
catch { throw new Error(`setup: ${root} has no bin/vmem.js`); }
|
|
195
|
+
return { path, version: pkg.version };
|
|
160
196
|
}
|
|
161
197
|
/**
|
|
162
198
|
* Plan, apply or undo vmem MCP registration. Dry-run writes nothing. Apply is idempotent, edits only
|
|
163
199
|
* the `vmem` server entry and records the prior value. Undo restores that prior value only while the
|
|
164
200
|
* entry is still the one setup installed; a later user edit is left alone and reported as refused.
|
|
165
201
|
*/
|
|
166
|
-
export function runSetup(target: string, mode: SetupMode, options: { home: string; env?: NodeJS.ProcessEnv; cwd?: string; entry?: McpEntry }): SetupReport {
|
|
167
|
-
const env = options.env ?? process.env,
|
|
202
|
+
export function runSetup(target: string, mode: SetupMode, options: { home: string; env?: NodeJS.ProcessEnv; cwd?: string; entry?: McpEntry; root?: string }): SetupReport {
|
|
203
|
+
const env = options.env ?? process.env, root = options.root === undefined ? undefined : installRoot(options.root);
|
|
204
|
+
const entry = options.entry ?? defaultEntry(root?.path);
|
|
168
205
|
if (target !== "all" && !(SETUP_TARGETS as readonly string[]).includes(target)) throw new Error(`setup: unknown target ${target}; use ${SETUP_TARGETS.join("|")}|all`);
|
|
169
206
|
if (!isAbsolute(entry.command) || entry.args.some(arg => arg.includes("\0"))) throw new Error("setup: MCP command must be absolute");
|
|
170
207
|
const table = hosts(env, options.cwd ?? process.cwd());
|
|
171
208
|
const targets = target === "all" ? SETUP_TARGETS.filter(t => t !== "agy") : [target as SetupTarget];
|
|
172
|
-
const report: SetupReport = { mode, applied: false, entry, actions: [], notes: [
|
|
209
|
+
const report: SetupReport = { mode, applied: false, entry, ...(root ? { root } : {}), actions: [], notes: [
|
|
173
210
|
"Registers the stdio server `vmem mcp`; restart the host session to load it.",
|
|
174
211
|
"Writes the acon console plugin manifest ($AGENTHUB_HOME/console/plugins/vmem.json); re-run setup after upgrading vmem.",
|
|
175
212
|
] };
|
|
@@ -215,16 +252,16 @@ export function runSetup(target: string, mode: SetupMode, options: { home: strin
|
|
|
215
252
|
action.change = "refused"; action.reason = error instanceof Error ? error.message : "setup failed";
|
|
216
253
|
}
|
|
217
254
|
}
|
|
218
|
-
report.console = consoleManifest(mode, env);
|
|
255
|
+
report.console = consoleManifest(mode, env, root?.path);
|
|
219
256
|
report.applied = mode !== "dry-run" && [...report.actions, report.console].some(a => !["none", "unchanged", "refused"].includes(a.change));
|
|
220
257
|
return report;
|
|
221
258
|
}
|
|
222
259
|
|
|
223
260
|
/** The acon plugin manifest: `{api, tool, module, sha256}` in the family home, dir 0700 and file 0600 as acon requires. */
|
|
224
|
-
export function consoleManifest(mode: SetupMode, env: NodeJS.ProcessEnv = process.env): ConsoleAction {
|
|
261
|
+
export function consoleManifest(mode: SetupMode, env: NodeJS.ProcessEnv = process.env, root: string = OWN_ROOT): ConsoleAction {
|
|
225
262
|
const family = env.AGENTHUB_HOME?.trim() ? resolve(env.AGENTHUB_HOME) : join(env.HOME?.trim() || homedir(), ".agenthub");
|
|
226
263
|
const dir = join(family, "console", "plugins"), file = join(dir, "vmem.json");
|
|
227
|
-
const module =
|
|
264
|
+
const module = join(root, "src", "ui", "plugin.ts");
|
|
228
265
|
const action: ConsoleAction = { file, module, change: "none" };
|
|
229
266
|
try {
|
|
230
267
|
const next = { api: 1, tool: "vmem", module, sha256: createHash("sha256").update(readFileSync(module)).digest("hex") };
|
package/src/sync.ts
CHANGED
|
@@ -52,19 +52,30 @@ function gitIn(cwd: string, env: NodeJS.ProcessEnv): Git {
|
|
|
52
52
|
return out;
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* insteadOf/pushInsteadOf rules that could rewrite any of `urls` or origin's configured URLs. Git applies a rule
|
|
57
|
+
* when its value is a literal prefix of the configured URL, so the raw config values are checked too; this covers
|
|
58
|
+
* an explicit pushurl, whose rewrite git does not report. A rule without a value is treated as matching.
|
|
59
|
+
*/
|
|
60
|
+
function applicableRewrites(lines: (args: string[]) => string[], urls: string[]): string[] {
|
|
61
|
+
const all = [...urls, ...lines(["config", "--get-all", "remote.origin.url"]), ...lines(["config", "--get-all", "remote.origin.pushurl"])];
|
|
62
|
+
return lines(["config", "--get-regexp", "^url\\..*\\.(insteadof|pushinsteadof)$"]).filter(line => {
|
|
63
|
+
const at = line.indexOf(" "), prefix = at < 0 ? "" : line.slice(at + 1);
|
|
64
|
+
return !prefix || all.some(url => url.startsWith(prefix));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
55
67
|
/** Local-only origin validation shared by doctor and sync; never fetches or pushes. */
|
|
56
68
|
export function originCheck(root: string, remote: string, env: NodeJS.ProcessEnv = process.env): { ok: boolean; origin: string | null; fetchUrls: string[]; pushUrls: string[]; issue?: string } {
|
|
57
69
|
const git = gitIn(root, env);
|
|
58
70
|
if (realpathSync(git(["rev-parse", "--show-toplevel"]).stdout.trim()) !== root) throw new Error("vault path is not the root of its git repository");
|
|
59
71
|
const origin = git(["remote", "get-url", "origin"], { allowFail: true });
|
|
60
72
|
const originUrl = origin.status === 0 ? origin.stdout.trim() : null;
|
|
61
|
-
// Every effective fetch and push URL must be the configured remote
|
|
62
|
-
// because git does not report pushInsteadOf rewrites of an explicit pushurl.
|
|
73
|
+
// Every effective fetch and push URL must be the configured remote, and no URL rewrite may apply to it.
|
|
63
74
|
const lines = (args: string[]) => { const out = git(args, { allowFail: true }); return out.status === 0 ? out.stdout.split("\n").map(line => line.trim()).filter(Boolean) : []; };
|
|
64
75
|
const fetchUrls = lines(["remote", "get-url", "--all", "origin"]), pushUrls = lines(["remote", "get-url", "--push", "--all", "origin"]);
|
|
65
|
-
const rewrites = lines
|
|
76
|
+
const rewrites = applicableRewrites(lines, [remote, ...fetchUrls, ...pushUrls]);
|
|
66
77
|
const ok = originUrl !== null && rewrites.length === 0 && fetchUrls.length > 0 && pushUrls.length > 0 && [...fetchUrls, ...pushUrls].every(url => sameRemote(url, remote));
|
|
67
|
-
return { ok, origin: originUrl, fetchUrls, pushUrls, ...(!ok ? { issue: originUrl === null ? "repository has no origin remote" : rewrites.length ?
|
|
78
|
+
return { ok, origin: originUrl, fetchUrls, pushUrls, ...(!ok ? { issue: originUrl === null ? "repository has no origin remote" : rewrites.length ? `git url rewrite ${rewrites[0]} applies to the vault remote; refusing because the push destination cannot be verified` : "an origin fetch or push URL does not match the configured remote; nothing was fetched or pushed" } : {}) };
|
|
68
79
|
}
|
|
69
80
|
function planned(path: string): boolean { return STAGE_FILES.includes(path) || STAGE_PREFIXES.some(prefix => path.startsWith(prefix)); }
|
|
70
81
|
/** Parse `status --porcelain=v1 -z` into changed paths (renames contribute both sides). */
|
|
@@ -141,7 +152,8 @@ export function syncVault(home: string, request: { vault: string; confirm?: bool
|
|
|
141
152
|
}
|
|
142
153
|
}
|
|
143
154
|
if (!result.commit && (!hasUpstream ? false : git(["rev-list", "--count", `${upstream}..HEAD`]).stdout.trim() === "0")) return { ...result, ok: true, applied: false, pushed: false };
|
|
144
|
-
|
|
155
|
+
const pushNow = lines(["remote", "get-url", "--push", "--all", "origin"]);
|
|
156
|
+
if (pushNow.some(url => !sameRemote(url, spec.remote!)) || applicableRewrites(lines, [spec.remote, ...fetchUrls, ...pushNow]).length) return { ...result, applied: Boolean(result.commit), pushed: false, issues: [issue("REMOTE", "push destination changed before push; nothing was pushed")] };
|
|
145
157
|
const push = git(["push", "--quiet", "--porcelain", "origin", `HEAD:refs/heads/${result.branch}`], { allowFail: true });
|
|
146
158
|
if (push.status !== 0) return { ...result, applied: Boolean(result.commit), pushed: false, issues: [issue("PUSH", `push failed: ${push.stderr.trim().split("\n").at(-1)?.slice(0, 300) ?? "error"}; the local commit was kept`)] };
|
|
147
159
|
// Verify the remote really has our head before reporting success.
|
package/src/tidy.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { hostname } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import type { VaultSpec, VmemConfig } from "./config.ts";
|
|
7
|
+
import { unenforceablePii } from "./config.ts";
|
|
7
8
|
import { emitFrontmatter, parseFrontmatter } from "./frontmatter.ts";
|
|
8
9
|
import { extractLinks, normalizeTarget } from "./links.ts";
|
|
9
10
|
import { isDenied } from "./platform.ts";
|
|
@@ -335,6 +336,8 @@ export function tidyVault(home: string, request: TidyRequest, options: { env?: N
|
|
|
335
336
|
const config = loadRegistry(home), vault = config.vaults.find(v => v.id === request.vault);
|
|
336
337
|
if (!vault) return { ...result, issues: [issue("V04", "unknown vault id")] };
|
|
337
338
|
if (vault.write === "none" || (request.channel === "mcp" && vault.write !== "mcp")) return { ...result, issues: [issue("POLICY", "vault write policy refuses this channel")] };
|
|
339
|
+
const unenforceable = unenforceablePii(vault);
|
|
340
|
+
if (unenforceable) return { ...result, issues: [issue("PII_POLICY", unenforceable)] };
|
|
338
341
|
const root = realpathSync(vault.path);
|
|
339
342
|
if (root !== vault.path || isDenied(root, config.denyPaths)) return { ...result, issues: [issue("V06", "vault path denied or changed")] };
|
|
340
343
|
// The LINT event must land in the vault this run read and wrote, even if the registry is changed meanwhile.
|
package/src/vault-index.ts
CHANGED
|
@@ -9,8 +9,13 @@ import { normalizeTarget, noteKey, proseLines, resolveLinks, type LinkNote, type
|
|
|
9
9
|
|
|
10
10
|
export const MAX_NOTE_BYTES = 2 * 1024 * 1024;
|
|
11
11
|
export const MAX_RESULTS = 500;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Bump when link resolution changes. 2 = path-only. The index file is named per value, so two installed versions
|
|
14
|
+
* that resolve links differently keep separate indexes instead of rebuilding each other's links. 0.2.4 and older
|
|
15
|
+
* share the unversioned `<digest>.sqlite`; the first run of a newer version builds its own file once.
|
|
16
|
+
*/
|
|
17
|
+
export const LINK_RESOLUTION = 2;
|
|
18
|
+
const indexName = (root: string) => `${digest(root)}-links${LINK_RESOLUTION}.sqlite`;
|
|
14
19
|
export const MAX_READ_BYTES = 64 * 1024;
|
|
15
20
|
export interface IndexedNote extends LinkNote { hash: string; description: string; type: string; domain: string }
|
|
16
21
|
export interface NoteReadResult {
|
|
@@ -118,7 +123,7 @@ export class VaultIndex {
|
|
|
118
123
|
if (!options.memory) {
|
|
119
124
|
mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
120
125
|
if (lstatSync(cache).isSymbolicLink() || !lstatSync(cache).isDirectory()) throw new Error("index: expected plain cache directory");
|
|
121
|
-
this.file = join(cache,
|
|
126
|
+
this.file = join(cache, indexName(this.root));
|
|
122
127
|
try {
|
|
123
128
|
const fd = openSync(this.file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
124
129
|
closeSync(fd);
|
|
@@ -136,7 +141,7 @@ export class VaultIndex {
|
|
|
136
141
|
CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
|
|
137
142
|
// Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
|
|
138
143
|
if (options.memory) {
|
|
139
|
-
const file = join(cache,
|
|
144
|
+
const file = join(cache, indexName(this.root));
|
|
140
145
|
let exists = false;
|
|
141
146
|
try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("index: expected plain database file"); exists = true; }
|
|
142
147
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
package/src/vault-log.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { formatLogEvent, LOG_TYPES } from "./log-format.ts";
|
|
|
8
8
|
import type { LogBinding, LogContext, LogReadOptions, LogReadResult, LogReceipt, LogRequest } from "./log-types.ts";
|
|
9
9
|
import type { WriteIssue } from "./write-types.ts";
|
|
10
10
|
import type { VaultSpec, VmemConfig } from "./config.ts";
|
|
11
|
+
import { unenforceablePii } from "./config.ts";
|
|
11
12
|
export const LOG_LOCK = "log.lock";
|
|
12
13
|
export const MAX_LOG_READ_BYTES = 32 * 1024 * 1024;
|
|
13
14
|
export const LARGE_LOG_LINES = 20000;
|
|
@@ -60,6 +61,8 @@ function context(home: string, id: string, channel: "cli" | "mcp" | undefined, w
|
|
|
60
61
|
if (!vault) refuse("L09", "unknown vault id");
|
|
61
62
|
if (channel !== undefined && channel !== "cli" && channel !== "mcp") refuse("POLICY", "unknown log channel");
|
|
62
63
|
if (write && (vault.write === "none" || (channel === "mcp" && vault.write !== "mcp"))) refuse("POLICY", "vault write policy refuses log append");
|
|
64
|
+
const unenforceable = write ? unenforceablePii(vault) : undefined;
|
|
65
|
+
if (unenforceable) refuse("PII_POLICY", unenforceable);
|
|
63
66
|
if (realpathSync(vault.path) !== vault.path || !lstatSync(vault.path).isDirectory()) refuse("L09", "registered vault path changed");
|
|
64
67
|
if (isDenied(join(vault.path, "log"), config.denyPaths)) refuse("V06", "log path is denied");
|
|
65
68
|
return { vault, config, channel, ...(write && vault.registry ? { projectIds: registryIds(vault.registry, config.denyPaths) } : {}) };
|
package/src/write-validation.ts
CHANGED
|
@@ -28,7 +28,7 @@ const KEYS = new Set([
|
|
|
28
28
|
export const TYPED_KEYS: Readonly<Record<string, string>> = { decided_at: "decision", decided_by: "decision", email: "person", phone: "person" };
|
|
29
29
|
export const SCHEMA_KEYS: ReadonlySet<string> = KEYS;
|
|
30
30
|
const WIKI_FIELDS = ["topics", "project", "superseded_by", "supersedes", "source_ref"];
|
|
31
|
-
// Exact regex set from @staix/agent-mem v0.
|
|
31
|
+
// Exact regex set from @staix/agent-mem v0.17.0 src/memory/screen.ts; parity-tested against its root export.
|
|
32
32
|
const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /\bgh[pousr]_[A-Za-z0-9]{30,}/, /\bgithub_pat_[A-Za-z0-9_]{40,}/, /\bsk-[A-Za-z0-9_-]{20,}/,
|
|
33
33
|
/\bxox[abprs]-[A-Za-z0-9-]{10,}/, /\bAIza[0-9A-Za-z_-]{35}\b/, /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, /\b(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*\S{8,}/i];
|
|
34
34
|
export const SECRET_PATTERNS: readonly RegExp[] = SECRETS;
|
package/src/writer.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { canonicalPath, isDenied } from "./platform.ts";
|
|
|
12
12
|
import { moveSourcePath, notePath, planOperation, refuse, WriteRefusal } from "./write-operations.ts";
|
|
13
13
|
import { MAX_REPORT_BYTES, validateWrite } from "./write-validation.ts";
|
|
14
14
|
import type { PiiSurface } from "./config.ts";
|
|
15
|
+
import { unenforceablePii } from "./config.ts";
|
|
15
16
|
import type { LogBinding } from "./log-types.ts";
|
|
16
17
|
import type { ChangeReceipt, PlannedChange, WriteIssue, WriteReceipt, WriteRequest } from "./write-types.ts";
|
|
17
18
|
export const MUTATION_LOCK = "mutation.lock";
|
|
@@ -129,6 +130,8 @@ export function applyWrite(home: string, request: WriteRequest, options: { env?:
|
|
|
129
130
|
const config = loadRegistry(home), vault = config.vaults.find(value => value.id === request.vault);
|
|
130
131
|
if (options.binding && !matchesBinding(config, request.vault, options.binding)) refuse("VAULT_DRIFT", "vault identity or policy changed since the run started; nothing written");
|
|
131
132
|
if (!vault) refuse("V04", "unknown vault id");
|
|
133
|
+
const unenforceable = unenforceablePii(vault);
|
|
134
|
+
if (unenforceable) refuse("PII_POLICY", unenforceable);
|
|
132
135
|
if (request.operation.op !== "check" && (vault.write === "none" || (request.channel === "mcp" && vault.write !== "mcp"))) refuse("POLICY", "vault write policy refuses this channel");
|
|
133
136
|
root = realpathSync(vault.path);
|
|
134
137
|
if (root !== vault.path || isDenied(root, config.denyPaths)) refuse("V06", "vault path denied or changed");
|