@staix/vault-mem 0.2.1 → 0.2.3
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 +13 -0
- package/README.md +26 -4
- package/bin/vmem.js +1 -1
- package/package.json +1 -1
- package/src/amem/seam.ts +56 -0
- package/src/analysis-commands.ts +4 -4
- package/src/analysis-input.ts +2 -1
- package/src/analysis-reports.ts +15 -5
- package/src/cli.ts +24 -2
- package/src/commands-table.ts +4 -3
- package/src/mcp-vault.ts +11 -3
- package/src/registry.ts +15 -3
- package/src/skills-mine.ts +485 -0
- package/src/sync.ts +20 -10
- package/src/vault-graph.ts +234 -29
- package/src/vault-log.ts +1 -1
- package/src/workspace-graph.ts +139 -0
- package/src/workspace-input.ts +150 -0
- package/src/write-validation.ts +2 -1
- package/src/writer.ts +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.3] - 2026-10-31
|
|
4
|
+
|
|
5
|
+
- Graph communities use a deterministic, dependency-free Leiden (modularity, resolution 1.0) instead of label propagation, which collapsed most of a real vault into one community. Every community is connected, ids are numbered by size then smallest member, and `vault-graph.json` and the report Overview record the partitioner, resolution and modularity. The vault graph, the workspace graph and skill mining share it.
|
|
6
|
+
- `vmem graph build <vault> --workspace` and `vmem_graph_build` with `workspace: true` build `reports/workspace-graph.json`: the vault graph plus the work layer (work docs, business-unit hubs, `source:` stubs and `related`, `fm_ref`, `wiki_link`, `bu_member`, `source_ref` and `supersedes` edges), a rule-for-rule port of `build-graph.py --work-root`. It writes only that file and one GRAPH event and returns the work counters and per-relation edge counts. `bun scripts/workspace-parity.ts <vault>` compares it against the Python reference, counts only.
|
|
7
|
+
- `vmem skills mine <vault>` and the MCP tool `vmem_skills_mine` rank new-skill candidates from agent-mem curated entries, agent-mem capture prompts, observations and summaries, and vault notes. They port the skill-mine dictionary matching, masking and scoring, cluster with vmem's deterministic graph communities, report counts by source and write nothing.
|
|
8
|
+
|
|
9
|
+
## [0.2.2] - 2026-10-31
|
|
10
|
+
|
|
11
|
+
- Report operations under `reports/` and `ops/` accept and reread files up to 16 MiB, so graph artifacts above 2 MiB can be rebuilt. Notes keep their 256 KiB write / 2 MiB read limits; CLI body input limits are unchanged.
|
|
12
|
+
- `vmem doctor` checks configured remotes and project registries locally, without network access. Remote validation shares sync's origin URL rules, registry validation uses the log-write parser, and failed checks exit 1.
|
|
13
|
+
- A registry update whose directory flush fails after the config was replaced now reports `registry updated but directory fsync failed`, so the caller knows the update committed and need not retry. The lock is still released; a retried `vaults add` remains refused as already registered.
|
|
14
|
+
- `vmem recover` reads at most 4 KiB of the lock owner record; a larger lock file is treated as unreadable (state `unknown`) and is never removed.
|
|
15
|
+
|
|
3
16
|
## [0.2.1] - 2026-10-30
|
|
4
17
|
|
|
5
18
|
- The `tidy` LINT event and the org-promotion SYNC events are bound to the vaults resolved at the start of the run; if a vault id is re-registered before the append, the event is refused with LOG_DRIFT instead of landing in the other vault. The committed notes are not rolled back.
|
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ Durability: the temporary file is fsynced before the rename and the home directo
|
|
|
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
|
|
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
36
|
The only runtime dependency is the exact first-party pin `@staix/agent-mem@0.11.0`; it is not loaded by registry commands. Public library exports cover configuration, home resolution, registry operations, frontmatter, link resolution and the derived index. No console bin or UI contract is advertised before its implementation.
|
|
37
37
|
|
|
@@ -121,9 +121,9 @@ Syntax follows indexed reads: the vault id comes first. `new` supports `--title`
|
|
|
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 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 (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.
|
|
125
125
|
|
|
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
|
|
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
|
|
|
128
128
|
|
|
129
129
|
# Append-only events
|
|
@@ -171,6 +171,7 @@ The vault set is read once at startup: every tool's `vault` property is an enum
|
|
|
171
171
|
| `vmem_move` | `from`, `to`, `confirm`, `fingerprint` (two-step) |
|
|
172
172
|
| `vmem_delete` | `path`, `confirm`, `fingerprint` (two-step; the preview lists backlinks) |
|
|
173
173
|
| `vmem_promote` | `path` (in the personal vault), `to` (enum of org vaults), `topics[]`, `again`, `confirm`, `fingerprint` (two-step; never pushes) |
|
|
174
|
+
| `vmem_skills_mine` | `vault`, `days`, `project`, `allProjects`, `vocab`, `skills[]`, `minSize`, `minNovelty` (read-only; see Skill mining) |
|
|
174
175
|
| `vmem_promote_from_amem` | `id`, `project`, `stem`, `type`, `domain`, `topics[]`, `description`, `title`, `body`<=65536, `linkTo`, `confirm`, `fingerprint` (two-step) |
|
|
175
176
|
|
|
176
177
|
Move and delete without `confirm` write nothing and return the preview and `fingerprint`; `confirm: true` requires the fingerprint recomputed on the current state, otherwise the call is refused with `V09`. `vmem_promote_from_amem` is described under Promotion from agent-mem. `vmem_promote` is described under Org promotion and sync. Graph/lint tools are listed below. Note paths in read results are relative to `notes/`.
|
|
@@ -214,7 +215,18 @@ vmem promote-from-amem personal mem:<id> --link-to existing-note # link an exis
|
|
|
214
215
|
|
|
215
216
|
`vmem graph build <vault> [--dry-run]` writes `reports/vault-graph.json`, a dated graph report, graph trend history and GRAPH events through `applyWrite`. Old dated reports are preserved. Trend history appends one row per build, including repeated builds on the same date. Output receipts expose partial report/log failure; these separate writes are not a single transaction. `vmem graph neighbors <vault> <stem> [--hops 1..2] [--limit 1..500]` reads a bounded neighborhood. Matching MCP tools are `vmem_lint`, `vmem_graph_build`, and `vmem_graph_neighbors`; MCP report writes require `write=mcp`.
|
|
216
217
|
|
|
217
|
-
|
|
218
|
+
**Communities** come from a deterministic, dependency-free Leiden (Traag, Waltman and van Eck 2019) optimizing modularity with resolution γ = 1.0: fast local moving with a node queue, refinement that merges only well-connected nodes inside each community (the greedy variant, so no random step), and aggregation on the refined partition initialized from the unrefined one, repeated while modularity improves. Nodes are processed in code-point id order and gain ties go to the lowest community id, so the same graph gives byte-identical output whatever the input order. Every community induces a connected subgraph. MOC (and, in the workspace graph, `bu`) nodes are excluded and get `community: null`; an isolated note is its own community. Community ids are numbered by size (descending), then by smallest member id; they are build-local, so cite a member, not the id. `vault-graph.json` records `graph.partitioner: "leiden"`, `resolution`, the achieved `modularity` and the number of levels, and the report Overview prints the partitioner, resolution and modularity. Modularity has a resolution limit: communities smaller than a size that grows with the whole graph cannot be separated, so a very small cluster may merge into a neighbor. There is no resolution setting yet. Skill mining (`vmem skills mine`) and the workspace graph use the same function. `bun scripts/leiden-live.ts <vault-id>` prints counts only: modularity, community count, largest share, connectivity, clustering time and NMI against the Python reference's Leiden partition.
|
|
219
|
+
|
|
220
|
+
`vmem graph build <vault> --workspace [--dry-run]` (MCP: `vmem_graph_build` with `workspace: true`) builds the workspace graph: the vault graph exactly as above plus the work layer under the vault's work root (the parent of its `registry`; a vault without `registry` is refused). It writes only `reports/workspace-graph.json` through `applyWrite` (no dated report, no trend row) and one GRAPH event, and returns the work counters (`work_docs`, `yaml_fail`, `template_skip`, `related_*`, `source_*`, `collisions`, `dup_edges`) and edge counts per relation. It ports `extract_work` from `build-graph.py --work-root` rule for rule:
|
|
221
|
+
|
|
222
|
+
- **Walk.** `.md` files under the work root, never following symlinks, pruning `.git node_modules .venv .sync-conflicts temp .maru .pnpm-store .conductor .dotfiles .omx .omc .gstack .frontend-slides .claude .archive archive vault inbox sites dev shared _templates templates` and the scratchpad memos/temp roots (`workspace.config.yaml` `paths.scratchpad`, `scratchpad.memos_subdir`, `scratchpad.temp_subdir`; default `scratchpad/memos`, `scratchpad/temp`). `denyPaths` are honored. Only a file whose first bytes open frontmatter (`---` and whitespace) is read; the rest are probed and skipped.
|
|
223
|
+
- **Nodes.** A work doc qualifies with `document_type`, a non-empty `projects project vault_note topics attendees relatedMeetings relatedTasks`, or `related`; frontmatter containing `{{` is a template (`template_skip`) and unparseable frontmatter is `yaml_fail`. Ids are `work:<relpath>`; business-unit hubs `bu:<slug>` come from `bu_id` in `bu-config.yaml` files named by a doc's `business_unit`; a vault `source:` that names an existing work file outside the qualifying set becomes a `type: file` stub.
|
|
224
|
+
- **Edges.** `related` (work to work, by unique stem or relative `.md` path, `rel` kept), `fm_ref` (work field wiki-link to a vault stem, `field` kept), `wiki_link` (work body to a vault stem), `bu_member`, `source_ref` (vault `source:` to a work node) and `supersedes` (`supersedes`/`superseded_by`, `field` kept). Edges to unknown nodes and self-loops are dropped; one edge per pair survives by priority `wiki_link < fm_ref < bu_member < source_ref < related < supersedes`, and a work-layer edge replaces a vault-layer edge on the same pair. Communities exclude `moc` and `bu` nodes.
|
|
225
|
+
- **Bounds.** 50,000 probed files (each counted once, whether or not it is then read), 2 MiB per read work doc, 128 MiB read in total and 2,000,000 directory entries per walk; exceeding one fails explicitly. Every probed file is stamped, so a file that changes during the build (including a plain file that gains frontmatter) refuses the write. The output is within the 16 MiB report bound.
|
|
226
|
+
|
|
227
|
+
`bun scripts/workspace-parity.ts <vault-id>` runs the unchanged Python `build-graph.py --work-root` into a private temp directory and the in-memory vmem build on the same tree, and prints only counts: node-id, edge-pair, relation, attribute and counter differences (`--python`, `--reference` select the interpreter and script). Communities are not compared.
|
|
228
|
+
|
|
229
|
+
Analysis consumes note sources from the derived SQLite index, including invalid-frontmatter rows retained separately from searchable notes. Normal scans also retain note sources in `analysis_notes`, roughly doubling cached source storage outside the vault; the cache remains derived and can be rebuilt. Read-only analysis and dry runs clone any existing derived cache through a readonly connection, then refresh changed notes in memory. A missing cache is built in memory. They create no cache, locks, reports or events. Auxiliary log, registry and report inputs use bounded plain-file reads. Snapshot limits are 10,000 notes / 64 MiB source, 1,000 reports / 16 MiB per file and combined, and 32 MiB log; oversized inputs fail explicitly. Graph limits are 100,000 edges. MOCs are excluded from clustering; ties sort by node id. Graph extraction intentionally retains the reference's body-only legacy wiki-link semantics, distinct from the richer navigation resolver.
|
|
218
230
|
|
|
219
231
|
`bun scripts/lint-parity.ts` and `bun scripts/graph-parity.ts` compare the synthetic corpus against frozen Python reference oracles. Use `--reference /absolute/path/to/lint.py` or `--reference /absolute/path/to/build-graph.py` to run the unchanged Python tools directly (`PYTHON` selects an interpreter with PyYAML/networkx). Keep those references until a separate retirement decision after two consecutive zero-diff runs. Fixture parity does not establish private-corpus parity or authorize a live vault cutover. Private corpus evidence stays private; no note bodies belong in public fixtures.
|
|
220
232
|
|
|
@@ -244,6 +256,16 @@ vmem sync team --confirm --fingerprint <sha>
|
|
|
244
256
|
- **Git hardening.** Git runs through agent-mem's hardened argv: hooks, fsmonitor, external diff, pager, credential helper and askpass are disabled, `GIT_TERMINAL_PROMPT=0` is set, and repository-redirecting `GIT_*` variables are removed. So sync uses ssh keys or a deploy key and never prompts.
|
|
245
257
|
- **Not decided by the tool.** Which org, visibility, remote and deploy key a vault uses are deployment decisions, and whether an agent may push a given org vault is the workspace's external-sending rule.
|
|
246
258
|
|
|
259
|
+
## Skill mining
|
|
260
|
+
|
|
261
|
+
`vmem skills mine <vault> [--days N] [--project KEY|--all-projects] [--vocab DIR] [--skills DIR ...] [--min-size N] [--min-novelty X] [--json]` ranks repeated work patterns as new-skill candidates. It replaces the Python `skill-mine` pipeline and reads memory instead of raw session logs. `vmem_skills_mine` takes the same inputs over MCP.
|
|
262
|
+
|
|
263
|
+
- **Records** (within `--days`, default 30; exact duplicate texts kept once). agent-mem curated entries of kind `decision`, `finding`, `contract` or `fail` (title and text), screened by agent-mem's standalone policy plus the vault config's `piiPatterns`. agent-mem capture rows with event `UserPromptSubmit`, `observation` or `summary` (the excerpt), opened read-only; tool events are skipped. Vault notes of every type except `person`, `moc` and `reference` (title, description and body), dated by frontmatter `created`/`date`, else the file mtime. All active agent-mem projects are read by default; `--project` narrows to one.
|
|
264
|
+
- **Matching and graph.** The `materialize.py` rules: action and object vocabularies from `--vocab DIR` (`actions.yaml`, `objects.yaml`; the skill ships them), plus project keywords, acronyms, people and orgs from the vault's `registry`; every contained surface matches (ASCII case-insensitively, Hangul exactly), giving nodes `action--<canon>`, `object--<canon>` and `entity--<kind>--<slug>`. A person node id is a digest, never the name. Records and vocabulary nodes form one graph clustered by the same deterministic Leiden as `vmem graph build`.
|
|
265
|
+
- **Scoring.** The `analyze.py` rules: a community with at least `--min-size` records (default 3) and novelty at least `--min-novelty` (default 0.3) is a candidate, labelled `{top action}-{top object}`, with up to 8 triggers. Cohesion is the community's edge density; novelty is 1 minus the highest Jaccard similarity against the `SKILL.md` name and description tokens under each `--skills` directory; rank is `log2(size) × (unique days / day span) × max(0.1, cohesion) × max(0.1, novelty)`. The report keeps the god nodes, candidates, surprising connections and rejected sections and adds per-candidate counts by source (curated, capture, vault).
|
|
266
|
+
- **Privacy boundary.** The command writes nothing: no vault file, lock, cache, report or log event, nothing under the vmem home and no content under the agent-mem home (it opens no store that does not exist). Reading a SQLite database may create an empty `-wal` file and a `-shm` WAL index (no row content) next to it in the agent-mem home, and nothing else. A record matching vmem's secret screen is dropped and counted. Returned samples from all three sources are masked the same way: registry people become `[PERSON]` (ASCII names case-insensitively, as matching does), emails `[EMAIL]`, absolute paths `[PATH]` and matches of the vault config's `piiPatterns` `[PII]`. Over MCP a vault re-registered after server startup is refused. The JSON result and the markdown report are the only output, and the calling skill decides where to save it.
|
|
267
|
+
- **Bounds.** At most 20,000 records, 64 KiB per record (larger records are dropped and counted) and 64 MiB in total; exceeding the record or total bound fails with an explicit error. Capture reads keep the newest 20,000 rows per machine and count a truncated shard.
|
|
268
|
+
|
|
247
269
|
|
|
248
270
|
|
|
249
271
|
## Release readiness
|
package/bin/vmem.js
CHANGED
|
@@ -8,5 +8,5 @@ const { main, mainAsync, runMcp } = await import("../src/cli.ts");
|
|
|
8
8
|
if (process.argv.length === 3 && process.argv[2] === "mcp") {
|
|
9
9
|
try { await runMcp(); }
|
|
10
10
|
catch (error) { console.error(`vmem mcp: ${error instanceof Error ? error.message : "failed"}`); process.exitCode = 1; }
|
|
11
|
-
} else if (process.argv[2] === "promote-from-amem") process.exitCode = await mainAsync(process.argv.slice(2));
|
|
11
|
+
} else if (process.argv[2] === "promote-from-amem" || process.argv[2] === "skills") process.exitCode = await mainAsync(process.argv.slice(2));
|
|
12
12
|
else process.exitCode = main(process.argv.slice(2));
|
package/package.json
CHANGED
package/src/amem/seam.ts
CHANGED
|
@@ -47,6 +47,62 @@ export async function openAmem(options: AmemOptions = {}): Promise<AmemSeam> {
|
|
|
47
47
|
close() { store.close(); },
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
|
+
export interface MineAmemRecord { source: "curated" | "capture"; kind: string; at: string; text: string; project: string }
|
|
51
|
+
export interface MineAmemRead { records: MineAmemRecord[]; projects: string[]; screened: number; truncatedCapture: number; unreadable: number }
|
|
52
|
+
const MINE_KINDS = new Set(["decision", "finding", "contract", "fail"]);
|
|
53
|
+
const MINE_EVENTS = new Set(["UserPromptSubmit", "observation", "summary"]);
|
|
54
|
+
/**
|
|
55
|
+
* Skill mining input, read-only: curated entries (screened by amem's standalone policy plus the vault's piiPatterns) and
|
|
56
|
+
* capture rows (events UserPromptSubmit/observation/summary) of every active project, or one. Opens nothing that does not
|
|
57
|
+
* exist: an absent home, a capture-only shard or an unsplit home without curated logs yields no rows, never a new store.
|
|
58
|
+
*/
|
|
59
|
+
export async function readAmemForMining(options: { env?: NodeJS.ProcessEnv; project?: string; since: string; piiPatterns?: string[]; maxCaptureRows?: number }): Promise<MineAmemRead> {
|
|
60
|
+
const amem = await import("@staix/agent-mem");
|
|
61
|
+
const { existsSync } = await import("node:fs"), { basename, join } = await import("node:path");
|
|
62
|
+
const base = amem.memoryHome(options.env ?? process.env), maxRows = options.maxCaptureRows ?? 20000;
|
|
63
|
+
const out: MineAmemRead = { records: [], projects: [], screened: 0, truncatedCapture: 0, unreadable: 0 };
|
|
64
|
+
if (!existsSync(base)) return out;
|
|
65
|
+
const policy = amem.createStandalonePolicy({ ...(options.piiPatterns?.length ? { piiPatterns: [...options.piiPatterns] } : {}) });
|
|
66
|
+
const state = amem.lifecycleResolver(base);
|
|
67
|
+
const sharded = existsSync(join(base, "shards.json"));
|
|
68
|
+
const unsplitCurated = !sharded && ["log", "local", "generations"].some(name => existsSync(join(base, name)));
|
|
69
|
+
const shared: MemoryStore | undefined = unsplitCurated ? new amem.MemoryStore({ memoryDir: base, deferPromotions: true }) : undefined;
|
|
70
|
+
try {
|
|
71
|
+
// A split home lists its projects as shards; an unsplit home keeps curated projects in the one shared store.
|
|
72
|
+
const machineDirs = new Map(amem.walkShards(base).map(shard => [shard.key, shard.machineDirs]));
|
|
73
|
+
for (const view of shared?.list({ limit: 1_000_000, includeSuperseded: true }) ?? []) if (!machineDirs.has(view.project)) machineDirs.set(view.project, []);
|
|
74
|
+
const shards = [...machineDirs].map(([key, dirs]) => ({ key, machineDirs: dirs }))
|
|
75
|
+
.filter(shard => state(shard.key) === "active" && (options.project === undefined || shard.key === options.project))
|
|
76
|
+
.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
|
|
77
|
+
if (options.project !== undefined && !shards.length) throw new Error(`amem: no active project shard for ${options.project}`);
|
|
78
|
+
for (const shard of shards) {
|
|
79
|
+
out.projects.push(shard.key);
|
|
80
|
+
const store: MemoryStore | undefined = shared ?? (sharded && existsSync(join(amem.shardDir(base, shard.key), "generation.json"))
|
|
81
|
+
? amem.openShardStore({ memoryDir: base, project: shard.key, deferPromotions: true, lifecycle: "ignore" }) : undefined);
|
|
82
|
+
try {
|
|
83
|
+
for (const { view } of store?.vaultEntries(shard.key) ?? []) {
|
|
84
|
+
if (!MINE_KINDS.has(view.kind) || view.supersededBy || view.at < options.since) continue;
|
|
85
|
+
if (!await policy.screen(view)) { out.screened++; continue; }
|
|
86
|
+
out.records.push({ source: "curated", kind: view.kind, at: view.at, text: [view.title, view.text].filter(Boolean).join("\n"), project: shard.key });
|
|
87
|
+
}
|
|
88
|
+
} catch { out.unreadable++; }
|
|
89
|
+
finally { if (store && store !== shared) store.close(); }
|
|
90
|
+
for (const dir of shard.machineDirs) {
|
|
91
|
+
let capture: InstanceType<typeof amem.CaptureStore> | undefined;
|
|
92
|
+
try {
|
|
93
|
+
capture = new amem.CaptureStore({ memoryDir: base, project: shard.key, machine: basename(dir), readOnly: true, recentReadRows: true, maxReadRows: maxRows });
|
|
94
|
+
if (capture.recentReadTruncated) out.truncatedCapture++;
|
|
95
|
+
for (const row of capture.rows({ limit: maxRows })) {
|
|
96
|
+
if (!MINE_EVENTS.has(row.event) || row.at < options.since || !row.excerpt.trim()) continue;
|
|
97
|
+
out.records.push({ source: "capture", kind: row.event, at: row.at, text: row.excerpt, project: shard.key });
|
|
98
|
+
}
|
|
99
|
+
} catch { out.unreadable++; }
|
|
100
|
+
finally { capture?.close(); }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
} finally { shared?.close(); }
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
50
106
|
/** Cross-project candidate table from amem's own selection, read-only. */
|
|
51
107
|
export async function allProjectCandidates(options: { env?: NodeJS.ProcessEnv; limit?: number; piiPatterns?: string[] }): Promise<{ project: string; candidates: AmemCandidate[] }[]> {
|
|
52
108
|
const amem = await import("@staix/agent-mem");
|
package/src/analysis-commands.ts
CHANGED
|
@@ -9,12 +9,12 @@ export function runAnalysisCommand(command: string, args: string[], home: string
|
|
|
9
9
|
const word = args[i]!;
|
|
10
10
|
if (!word.startsWith("-")) { positional.push(word); continue; }
|
|
11
11
|
if (word in values || flags.has(word)) throw new Error("analysis: repeated option");
|
|
12
|
-
if (["--json","--dry-run","--write"].includes(word)) flags.add(word);
|
|
12
|
+
if (["--json","--dry-run","--write","--workspace"].includes(word)) flags.add(word);
|
|
13
13
|
else if (["--note","--hops","--limit"].includes(word)) { const value = args[++i]; if (!value || value.startsWith("--")) throw new Error(`${word}: requires value`); values[word]=value; }
|
|
14
14
|
else throw new Error(`analysis: unknown option ${word}`);
|
|
15
15
|
}
|
|
16
16
|
if (command === "lint") {
|
|
17
|
-
if (positional.length !== 1 || values["--hops"] || values["--limit"]) throw new Error("lint: requires one vault id");
|
|
17
|
+
if (positional.length !== 1 || values["--hops"] || values["--limit"] || flags.has("--workspace")) throw new Error("lint: requires one vault id");
|
|
18
18
|
if (flags.has("--write")) { const result = writeAnalysisReports(home,{vault:positional[0]!,kind:"lint",note:values["--note"],dryRun:flags.has("--dry-run")},env); return {text:JSON.stringify(result),code:result.ok && (result.summary as {errors?:number}).errors === 0 ? 0 : 1}; }
|
|
19
19
|
const {snapshot} = analysisInput(home,positional[0]!);
|
|
20
20
|
const report = lintVault(snapshot,values["--note"]);
|
|
@@ -24,10 +24,10 @@ export function runAnalysisCommand(command: string, args: string[], home: string
|
|
|
24
24
|
if (values["--note"] || flags.has("--write")) throw new Error("graph: unsupported option");
|
|
25
25
|
if (action === "build") {
|
|
26
26
|
if (positional.length !== 1 || values["--hops"] || values["--limit"]) throw new Error("graph build: requires one vault id");
|
|
27
|
-
const result = writeAnalysisReports(home,{vault:positional[0]!,kind:"graph",dryRun:flags.has("--dry-run")},env);
|
|
27
|
+
const result = writeAnalysisReports(home,{vault:positional[0]!,kind:"graph",workspace:flags.has("--workspace"),dryRun:flags.has("--dry-run")},env);
|
|
28
28
|
return {text:JSON.stringify(result),code:result.ok ? 0 : 1};
|
|
29
29
|
}
|
|
30
|
-
if (action !== "neighbors" || positional.length !== 2) throw new Error("graph: use build <vault> or neighbors <vault> <stem>");
|
|
30
|
+
if (action !== "neighbors" || positional.length !== 2 || flags.has("--workspace")) throw new Error("graph: use build <vault> or neighbors <vault> <stem>");
|
|
31
31
|
const hops = Number(values["--hops"] ?? 1), limit = Number(values["--limit"] ?? 50);
|
|
32
32
|
if ((hops !== 1 && hops !== 2) || !Number.isInteger(limit) || limit < 1 || limit > 500) throw new Error("graph neighbors: hops 1..2, limit 1..500");
|
|
33
33
|
const {snapshot} = analysisInput(home,positional[0]!);
|
package/src/analysis-input.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MAX_REPORT_BYTES } from "./write-validation.ts";
|
|
1
2
|
import { parseFrontmatter } from "./frontmatter.ts";
|
|
2
3
|
import { sourceWorkspaceRoot, missingSourceWarnings, sourceReferences } from "./source.ts";
|
|
3
4
|
import { parseAnalysisYaml } from "./analysis-yaml.ts";
|
|
@@ -50,7 +51,7 @@ export function analysisInput(home: string, id: string, options: { today?: strin
|
|
|
50
51
|
let bytes = 0;
|
|
51
52
|
for (const name of entries) {
|
|
52
53
|
const file = join(reportsDir,name); permitted(file,config);
|
|
53
|
-
const source = analysisRead(file); if (source !== null) { bytes += Buffer.byteLength(source); if (bytes >
|
|
54
|
+
const source = analysisRead(file, MAX_REPORT_BYTES); if (source !== null) { bytes += Buffer.byteLength(source); if (bytes > MAX_REPORT_BYTES) throw new Error("analysis: reports exceed aggregate bound"); reports[name] = source; }
|
|
54
55
|
}
|
|
55
56
|
let registryIds: string[] | null = null;
|
|
56
57
|
if (vault.registry) {
|
package/src/analysis-reports.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { workspaceInput } from "./workspace-input.ts";
|
|
2
|
+
import { buildWorkspaceGraph } from "./workspace-graph.ts";
|
|
3
|
+
import { MAX_REPORT_BYTES } from "./write-validation.ts";
|
|
1
4
|
import type { VaultSpec } from "./config.ts";
|
|
2
5
|
import { lstatSync } from "node:fs";
|
|
3
6
|
import { join } from "node:path";
|
|
@@ -11,7 +14,7 @@ import { analysisInput } from "./analysis-input.ts";
|
|
|
11
14
|
import { lintVault } from "./vault-lint.ts";
|
|
12
15
|
import { buildGraph, graphReport } from "./vault-graph.ts";
|
|
13
16
|
|
|
14
|
-
export interface AnalysisRequest { vault: string; kind: "lint" | "graph"; dryRun?: boolean; channel?: "cli" | "mcp"; today?: string; note?: string; expectedVault?: VaultSpec }
|
|
17
|
+
export interface AnalysisRequest { vault: string; kind: "lint" | "graph"; workspace?: boolean; dryRun?: boolean; channel?: "cli" | "mcp"; today?: string; note?: string; expectedVault?: VaultSpec }
|
|
15
18
|
function trend(snapshot: AnalysisSnapshot, nodes: number, edges: number, communities: number, top: string): string {
|
|
16
19
|
const seed = "---\ntype: report\nkind: graph-trend\n---\n\n# Graph Trend (vault layer)\n\n| 빌드 | Nodes | Edges | Communities | Density | Top hub (degree 1위) |\n|---|---|---|---|---|---|\n";
|
|
17
20
|
const source = snapshot.reports["graph-trend.md"] ?? seed;
|
|
@@ -26,8 +29,11 @@ function trend(snapshot: AnalysisSnapshot, nodes: number, edges: number, communi
|
|
|
26
29
|
}
|
|
27
30
|
export function writeAnalysisReports(home: string, request: AnalysisRequest, env: NodeJS.ProcessEnv = process.env) {
|
|
28
31
|
const {snapshot,vault,config,identity} = analysisInput(home,request.vault,{today:request.today,expectedVault:request.expectedVault});
|
|
32
|
+
if (request.workspace && request.kind !== "graph") throw new Error("workspace is only supported for graph build");
|
|
33
|
+
const work = request.workspace ? workspaceInput(snapshot,vault,config) : undefined;
|
|
29
34
|
const binding = {vault,denyPaths:config.denyPaths ?? [],piiPatterns:config.piiPatterns ?? [],dev:Number(identity.dev),ino:Number(identity.ino)};
|
|
30
35
|
const unchanged = () => {
|
|
36
|
+
work?.assertUnchanged?.();
|
|
31
37
|
const current = loadRegistry(home), stat = lstatSync(vault.path);
|
|
32
38
|
if (JSON.stringify(current) !== JSON.stringify(config) || stat.dev !== identity.dev || stat.ino !== identity.ino || stat.isSymbolicLink()) throw new Error("analysis: vault or policy changed during build");
|
|
33
39
|
};
|
|
@@ -36,6 +42,10 @@ export function writeAnalysisReports(home: string, request: AnalysisRequest, env
|
|
|
36
42
|
if (request.kind === "lint") {
|
|
37
43
|
const lint = lintVault(snapshot,request.note); summary = {findings:lint.findings,errors:lint.errors,warnings:lint.warnings,summary:lint.summary};
|
|
38
44
|
outputs.push({path:`reports/lint-report-${snapshot.today.slice(2).replaceAll("-","")}.md`,text:lint.markdown+"\n"+lint.summary+"\n"});
|
|
45
|
+
} else if (work) {
|
|
46
|
+
const result = buildWorkspaceGraph(snapshot,work);
|
|
47
|
+
summary = { nodes: result.graph.nodes.length, edges: result.graph.edges.length, ...result.counters, relations: result.relations };
|
|
48
|
+
outputs.push({path:"reports/workspace-graph.json",text:JSON.stringify(result.graph,null,2)+"\n"});
|
|
39
49
|
} else {
|
|
40
50
|
const graph = buildGraph(snapshot), communities = new Set(graph.nodes.flatMap(n => n.community === null ? [] : [n.community])).size;
|
|
41
51
|
summary = {nodes:graph.nodes.length,edges:graph.edges.length,communities};
|
|
@@ -46,10 +56,10 @@ export function writeAnalysisReports(home: string, request: AnalysisRequest, env
|
|
|
46
56
|
}
|
|
47
57
|
const originals = new Map(outputs.map(out => {
|
|
48
58
|
const name = out.path.slice("reports/".length);
|
|
49
|
-
return [out.path, request.kind === "graph" ? snapshot.reports[name] ?? null : analysisRead(join(vault.path,out.path))] as const;
|
|
59
|
+
return [out.path, request.kind === "graph" && !request.workspace ? snapshot.reports[name] ?? null : analysisRead(join(vault.path,out.path), MAX_REPORT_BYTES)] as const;
|
|
50
60
|
}));
|
|
51
61
|
unchanged();
|
|
52
|
-
for (const out of outputs) if (analysisRead(join(vault.path,out.path)) !== originals.get(out.path)) throw new Error("analysis: report changed during build");
|
|
62
|
+
for (const out of outputs) if (analysisRead(join(vault.path,out.path), MAX_REPORT_BYTES) !== originals.get(out.path)) throw new Error("analysis: report changed during build");
|
|
53
63
|
const expectedShas = Object.fromEntries(snapshot.notes.map(n => ["notes/"+n.path.replace(/^notes\//,""),sha256(n.source)]));
|
|
54
64
|
// Preflight every output through the same validation pipeline, before any output is written.
|
|
55
65
|
const previews = outputs.map(out => applyWrite(home,{vault:request.vault,operation:{op:"report",...out},dryRun:true,channel:request.channel,expectedShas,log:false},{env}));
|
|
@@ -57,13 +67,13 @@ export function writeAnalysisReports(home: string, request: AnalysisRequest, env
|
|
|
57
67
|
const receipts: WriteReceipt[] = [];
|
|
58
68
|
for (const out of outputs) {
|
|
59
69
|
unchanged();
|
|
60
|
-
if (analysisRead(join(vault.path,out.path)) !== originals.get(out.path)) throw new Error("analysis: report changed during build");
|
|
70
|
+
if (analysisRead(join(vault.path,out.path), MAX_REPORT_BYTES) !== originals.get(out.path)) throw new Error("analysis: report changed during build");
|
|
61
71
|
const receipt = applyWrite(home,{vault:request.vault,operation:{op:"report",...out},channel:request.channel,expectedShas,log:false},{env});
|
|
62
72
|
receipts.push(receipt);
|
|
63
73
|
if (!receipt.ok) break;
|
|
64
74
|
}
|
|
65
75
|
const complete = receipts.length === outputs.length && receipts.every(r => r.ok);
|
|
66
76
|
unchanged();
|
|
67
|
-
const log = complete ? appendLog(home,{vault:request.vault,channel:request.channel,event:{type:request.kind === "lint" ? "LINT" : "GRAPH",project:"-",source:"vmem",dest:outputs[0]!.path,note:request.kind === "lint" ? "Vault lint report" : "Vault graph reports"}},{env,binding}) : undefined;
|
|
77
|
+
const log = complete ? appendLog(home,{vault:request.vault,channel:request.channel,event:{type:request.kind === "lint" ? "LINT" : "GRAPH",project:"-",source:"vmem",dest:outputs[0]!.path,note:request.kind === "lint" ? "Vault lint report" : request.workspace ? "Workspace graph" : "Vault graph reports"}},{env,binding}) : undefined;
|
|
68
78
|
return {ok:complete && log?.ok === true,applied:receipts.some(r => r.applied),summary,paths:outputs.map(o => o.path),receipts,log};
|
|
69
79
|
}
|
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { originCheck } from "./sync.ts";
|
|
2
|
+
import { registryIds } from "./vault-log.ts";
|
|
1
3
|
import { runAnalysisCommand } from "./analysis-commands.ts";
|
|
2
4
|
import { runLogCommand } from "./log-commands.ts";
|
|
3
5
|
import { runOrgCommand } from "./org-commands.ts";
|
|
@@ -51,7 +53,23 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
|
|
|
51
53
|
switch (command) {
|
|
52
54
|
case "status": case "doctor": {
|
|
53
55
|
if (positional.length) throw new Error(`${command}: unexpected arguments`);
|
|
54
|
-
const
|
|
56
|
+
const config = command === "doctor" ? loadRegistry(home.home) : undefined;
|
|
57
|
+
const vaults = registryStatus(home.home).map(vault => {
|
|
58
|
+
if (command !== "doctor" || !vault.healthy || (!vault.remote && !vault.registry)) return vault;
|
|
59
|
+
const checks: Record<string, { ok: boolean; issue?: string }> = {};
|
|
60
|
+
if (vault.remote) {
|
|
61
|
+
try {
|
|
62
|
+
const checked = originCheck(vault.path, vault.remote, env);
|
|
63
|
+
checks.remote = { ok: checked.ok, ...(checked.issue ? { issue: checked.issue } : {}) };
|
|
64
|
+
} catch (error) { checks.remote = { ok: false, issue: error instanceof Error ? error.message : "git failed" }; }
|
|
65
|
+
}
|
|
66
|
+
if (vault.registry) {
|
|
67
|
+
try { registryIds(vault.registry, config!.denyPaths); checks.registry = { ok: true }; }
|
|
68
|
+
catch (error) { checks.registry = { ok: false, issue: error instanceof Error ? error.message : "cannot read project registry" }; }
|
|
69
|
+
}
|
|
70
|
+
return { ...vault, checks };
|
|
71
|
+
});
|
|
72
|
+
const healthy = vaults.every(v => v.healthy && (!("checks" in v) || Object.values(v.checks).every(check => check.ok)));
|
|
55
73
|
result = { ...home, version, stage: "foundation", healthy, vaults };
|
|
56
74
|
if (command === "doctor" && !healthy) code = 1;
|
|
57
75
|
break;
|
|
@@ -95,8 +113,12 @@ export async function runMcp(env: NodeJS.ProcessEnv = process.env): Promise<void
|
|
|
95
113
|
const server = createVaultMcp(resolveHome(env).home, env);
|
|
96
114
|
await serveMcp({ tools: server.tools, handlers: server.handlers, name: "vmem", version });
|
|
97
115
|
}
|
|
98
|
-
/** Async commands. Only `promote-from-amem`
|
|
116
|
+
/** Async commands. Only `promote-from-amem` and `skills mine` load the agent-mem seam, and only when they run. */
|
|
99
117
|
export async function runAsync(args: string[], env: NodeJS.ProcessEnv = process.env, io: { stdinIsTTY?: boolean } = {}): Promise<{ text: string; code: number }> {
|
|
118
|
+
if (args[0] === "skills") {
|
|
119
|
+
const { runSkillsCommand } = await import("./skills-mine.ts");
|
|
120
|
+
return runSkillsCommand(args.slice(1), resolveHome(env).home, env);
|
|
121
|
+
}
|
|
100
122
|
if (args[0] !== "promote-from-amem") return run(args, env);
|
|
101
123
|
const { runPromoteCommand } = await import("./promote-command.ts");
|
|
102
124
|
return runPromoteCommand(args.slice(1), resolveHome(env).home, env, io);
|
package/src/commands-table.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export const COMMANDS = [
|
|
2
2
|
{ name: "lint", usage: "lint <vault> [--note PATH] [--write] [--dry-run] [--json]", mutates: "write" },
|
|
3
|
-
{ name: "graph build", usage: "graph build <vault> [--dry-run] [--json]", mutates: true },
|
|
3
|
+
{ name: "graph build", usage: "graph build <vault> [--workspace] [--dry-run] [--json]", mutates: true },
|
|
4
4
|
{ name: "graph neighbors", usage: "graph neighbors <vault> <stem> [--hops 1..2] [--limit 1..500] [--json]", mutates: false },
|
|
5
|
+
{ name: "skills mine", usage: "skills mine <vault> [--days N] [--project KEY|--all-projects] [--vocab DIR] [--skills DIR] [--min-size N] [--min-novelty X] [--json]", mutates: false },
|
|
5
6
|
{ name: "rebuild", usage: "rebuild <vault> [--json]", mutates: "cache" },
|
|
6
7
|
{ name: "search", usage: "search <vault> <query> [--type TYPE] [--domain DOMAIN] [--limit 1..500] [--json]", mutates: "cache" },
|
|
7
8
|
{ name: "show", usage: "show <vault> <target> [--section TEXT] [--offset 0..2097152] [--max-bytes 1..65536] [--json]", mutates: "cache" },
|
|
@@ -67,7 +68,7 @@ export function completionScript(shell: string): string {
|
|
|
67
68
|
const cond = d.path.length === 1 ? `__fish_seen_subcommand_from ${d.path[0]}` : `__fish_seen_subcommand_from ${d.path[1]}`;
|
|
68
69
|
for (const flag of Object.keys(d.options)) lines.push(`complete -c vmem -n ${quote(cond)} -l ${flag.slice(2)}`);
|
|
69
70
|
}
|
|
70
|
-
for (const parent of ["vaults", "log", "graph"]) 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(" "))}`);
|
|
71
|
+
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(" "))}`);
|
|
71
72
|
return lines.join("\n") + "\n";
|
|
72
73
|
}
|
|
73
74
|
const cases = defs.map(d => ` ${quote(d.path.join(" "))}) words=${quote(Object.keys(d.options).join(" "))} ;;`);
|
|
@@ -76,7 +77,7 @@ export function completionScript(shell: string): string {
|
|
|
76
77
|
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
77
78
|
if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W ${quote(top.join(" "))} -- "$cur")); return; fi
|
|
78
79
|
key="\${COMP_WORDS[1]}"
|
|
79
|
-
case "$key" in graph) if [ "$COMP_CWORD" -eq 2 ]; then COMPREPLY=($(compgen -W 'build neighbors' -- "$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
|
|
80
|
+
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
|
|
80
81
|
case "$key" in
|
|
81
82
|
${cases.join("\n")}
|
|
82
83
|
*) words='' ;;
|
package/src/mcp-vault.ts
CHANGED
|
@@ -27,7 +27,7 @@ const scalar = { type: ["string", "number", "boolean", "null", "array"], maxLeng
|
|
|
27
27
|
|
|
28
28
|
/** Tool names for the implemented read, write, log, analysis and promotion providers. */
|
|
29
29
|
export const VMEM_TOOL_NAMES = ["vmem_status", "vmem_search", "vmem_read", "vmem_read_many", "vmem_list", "vmem_links", "vmem_mocs", "vmem_check", "vmem_log_tail",
|
|
30
|
-
"vmem_create", "vmem_patch", "vmem_append", "vmem_set_frontmatter", "vmem_link", "vmem_supersede", "vmem_log_append", "vmem_move", "vmem_delete", "vmem_promote_from_amem", "vmem_lint", "vmem_graph_build", "vmem_graph_neighbors", "vmem_promote"] as const;
|
|
30
|
+
"vmem_create", "vmem_patch", "vmem_append", "vmem_set_frontmatter", "vmem_link", "vmem_supersede", "vmem_log_append", "vmem_move", "vmem_delete", "vmem_promote_from_amem", "vmem_lint", "vmem_graph_build", "vmem_graph_neighbors", "vmem_promote", "vmem_skills_mine"] as const;
|
|
31
31
|
|
|
32
32
|
/** Tool definitions for a fixed vault set: `vault` is an enum of the registered ids. */
|
|
33
33
|
export function vaultTools(ids: readonly string[], orgIds: readonly string[] = []): McpTool[] {
|
|
@@ -56,10 +56,12 @@ export function vaultTools(ids: readonly string[], orgIds: readonly string[] = [
|
|
|
56
56
|
tool("vmem_promote_from_amem", "Two-step promotion of one durable agent-mem entry (id from amem_vault_candidates) into a vault note: without confirm returns the full note text, possible duplicates and fingerprint; confirm=true with that fingerprint creates the note, logs LEARN and records the agent-mem vault link. linkTo links an existing note instead.",
|
|
57
57
|
object({ vault, id: { type: "string", minLength: 32, maxLength: 36 }, stem: string(120), type: string(64), domain: string(64), topics: { type: "array", maxItems: 32, items: string(200) }, description: string(1000), title: string(300), body: string(65536, 0), linkTo: note, project: string(400), ...twoStep }, ["id"]), "confirm"),
|
|
58
58
|
tool("vmem_lint", "Lint a private derived snapshot; write=true writes a screened report.", object({ vault, write: { type: "boolean" }, dryRun: { type: "boolean" } }), "write"),
|
|
59
|
-
tool("vmem_graph_build", "Build a deterministic graph and screened reports; dryRun changes nothing.", object({ vault, dryRun: { type: "boolean" } }), "write"),
|
|
59
|
+
tool("vmem_graph_build", "Build a deterministic graph and screened reports; dryRun changes nothing.", object({ vault, dryRun: { type: "boolean" }, workspace: { type: "boolean" } }), "write"),
|
|
60
60
|
tool("vmem_graph_neighbors", "Read a bounded graph neighborhood.", object({ vault, path: note, hops: integer(1,2), limit: integer(1,500) }, ["path"])),
|
|
61
61
|
tool("vmem_promote", "Two-step promotion of a personal note into a local org vault clone (never pushes): without confirm returns the full transformed text, dropped links, screen result and fingerprint; confirm=true with that fingerprint writes the org note, source provenance and SYNC log lines. Publishing is the separate `vmem sync` CLI.",
|
|
62
62
|
object({ path: note, to: orgVault, topics: { type: "array", maxItems: 32, items: string(200) }, again: { type: "boolean" }, ...twoStep }, ["path", "to"]), "confirm"),
|
|
63
|
+
tool("vmem_skills_mine", "Read-only skill-candidate mining over amem curated entries, amem capture rows and vault notes: dictionary matching, deterministic communities and novelty scoring. Returns counts, masked candidates and a markdown report; writes nothing.",
|
|
64
|
+
object({ vault, days: integer(1, 3650), project: string(400), allProjects: { type: "boolean" }, vocab: string(4096), skills: { type: "array", maxItems: 16, items: string(4096) }, minSize: integer(1, 10000), minNovelty: { type: "number", minimum: 0, maximum: 1 } })),
|
|
63
65
|
];
|
|
64
66
|
}
|
|
65
67
|
type Issue = { code: string; severity: "error"; message: string };
|
|
@@ -112,8 +114,14 @@ export function createVaultMcp(home: string, env: NodeJS.ProcessEnv = process.en
|
|
|
112
114
|
...(Array.isArray(args.topics) ? { topics: args.topics as string[] } : {}) }, { env, ...(str(args.project) ? { project: str(args.project) } : {}) });
|
|
113
115
|
return result.ok ? result : { ...result, refused: true };
|
|
114
116
|
},
|
|
117
|
+
vmem_skills_mine: async args => {
|
|
118
|
+
const { mineSkills } = await import("./skills-mine.ts");
|
|
119
|
+
return mineSkills(home, { vault: pick(args).id, expectedVault: pick(args), env, allProjects: args.allProjects === true, ...(str(args.project) ? { project: str(args.project) } : {}), ...(str(args.vocab) ? { vocab: str(args.vocab) } : {}),
|
|
120
|
+
...(Array.isArray(args.skills) ? { skills: args.skills as string[] } : {}), ...(typeof args.days === "number" ? { days: args.days } : {}),
|
|
121
|
+
...(typeof args.minSize === "number" ? { minSize: args.minSize } : {}), ...(typeof args.minNovelty === "number" ? { minNovelty: args.minNovelty } : {}) });
|
|
122
|
+
},
|
|
115
123
|
vmem_lint: args => args.write === true ? writeAnalysisReports(home,{vault:pick(args).id,kind:"lint",channel:"mcp",expectedVault:pick(args),dryRun:args.dryRun === true},env) : lintVault(analysisInput(home,pick(args).id,{expectedVault:pick(args)}).snapshot),
|
|
116
|
-
vmem_graph_build: args => writeAnalysisReports(home,{vault:pick(args).id,kind:"graph",channel:"mcp",expectedVault:pick(args),dryRun:args.dryRun === true},env),
|
|
124
|
+
vmem_graph_build: args => writeAnalysisReports(home,{vault:pick(args).id,kind:"graph",workspace:args.workspace === true,channel:"mcp",expectedVault:pick(args),dryRun:args.dryRun === true},env),
|
|
117
125
|
vmem_graph_neighbors: args => graphNeighbors(buildGraph(analysisInput(home,pick(args).id,{expectedVault:pick(args)}).snapshot),str(args.path)!, (args.hops as 1|2|undefined) ?? 1, (args.limit as number|undefined) ?? 50),
|
|
118
126
|
vmem_status: () => ({ defaultVault: defaultVault ?? null, vaults: registryStatus(home).filter(v => ids.includes(v.id)) }),
|
|
119
127
|
vmem_search: args => read(args, index => ({ hits: index.search(str(args.query)!, { limit: (args.limit as number | undefined) ?? 20, type: str(args.type), domain: str(args.domain) }) })),
|
package/src/registry.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, constants, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, constants, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -50,7 +50,8 @@ function mutate<T>(home: string, update: (config: VmemConfig) => T): T {
|
|
|
50
50
|
try { writeFileSync(fd, text); fsyncSync(fd); } finally { closeSync(fd); }
|
|
51
51
|
renameSync(temporary, join(home, CONFIG_FILE));
|
|
52
52
|
temporary = undefined;
|
|
53
|
-
syncDirectory(home);
|
|
53
|
+
try { syncDirectory(home); }
|
|
54
|
+
catch (error) { throw new Error(`${join(home, CONFIG_FILE)}: registry updated but directory fsync failed: ${message(error)}`, { cause: error }); }
|
|
54
55
|
return result;
|
|
55
56
|
} catch (error) {
|
|
56
57
|
failed = true; primary = error;
|
|
@@ -73,9 +74,20 @@ function mutate<T>(home: string, update: (config: VmemConfig) => T): T {
|
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
}
|
|
77
|
+
/** Upper bound for a lock owner record; larger files are treated as unreadable. */
|
|
78
|
+
const MAX_LOCK_BYTES = 4096;
|
|
76
79
|
function lockOwner(lock: string): LockOwner | undefined {
|
|
77
80
|
try {
|
|
78
|
-
const
|
|
81
|
+
const fd = openSync(lock, constants.O_RDONLY);
|
|
82
|
+
let text: string;
|
|
83
|
+
try {
|
|
84
|
+
const buffer = Buffer.alloc(MAX_LOCK_BYTES + 1);
|
|
85
|
+
let length = 0, read: number;
|
|
86
|
+
while (length < buffer.length && (read = readSync(fd, buffer, length, buffer.length - length, null)) > 0) length += read;
|
|
87
|
+
if (length > MAX_LOCK_BYTES) return undefined; // oversized: never trusted
|
|
88
|
+
text = buffer.subarray(0, length).toString("utf8");
|
|
89
|
+
} finally { closeSync(fd); }
|
|
90
|
+
const value = JSON.parse(text) as Partial<LockOwner>;
|
|
79
91
|
if (typeof value.pid === "number" && Number.isInteger(value.pid) && value.pid > 0 && typeof value.host === "string" && typeof value.created === "string") return value as LockOwner;
|
|
80
92
|
} catch { /* unreadable or legacy lock */ }
|
|
81
93
|
return undefined;
|