@staix/vault-mem 0.2.2 → 0.2.4
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 +14 -0
- package/README.md +35 -3
- package/bin/vmem.js +1 -1
- package/package.json +4 -2
- 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 +12 -3
- package/src/cli.ts +6 -2
- package/src/commands-table.ts +4 -3
- package/src/local-date.ts +9 -0
- package/src/log-format.ts +2 -1
- package/src/mcp-vault.ts +11 -3
- package/src/org-promote.ts +2 -1
- package/src/promote.ts +2 -1
- package/src/setup.ts +46 -4
- package/src/skills-mine.ts +487 -0
- package/src/ui/panel.css +16 -0
- package/src/ui/panel.js +137 -0
- package/src/ui/plugin.ts +292 -0
- package/src/vault-graph.ts +234 -29
- package/src/workspace-graph.ts +139 -0
- package/src/workspace-input.ts +150 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.2.4] - 2026-11-01
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- Console plugin for `acon` (agent-console api 1), exported as `@staix/vault-mem/ui-plugin`: Overview, Search, Note, Promote, Lint and Log panels, inbox items for promotion candidates and lint alerts, palette search, and two-step `delete`, `move`, `promote` and `promote-from-amem` behind the console's confirm dialog.
|
|
9
|
+
- `vmem setup` writes, reports and undoes the console plugin manifest `$AGENTHUB_HOME/console/plugins/vmem.json`.
|
|
10
|
+
|
|
11
|
+
## [0.2.3] - 2026-10-31
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
- `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.
|
|
15
|
+
- `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.
|
|
16
|
+
|
|
3
17
|
## [0.2.2] - 2026-10-31
|
|
4
18
|
|
|
5
19
|
- 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.
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
An Obsidian-compatible knowledge vault tool for agents and people. Requires Bun >=1.4.2. The full check (`bun run check`) needs Bun, git and tar; it includes a package dry-run and clean installed CLI/MCP smoke. Node/npm are used by the separate release workflow, not required for the ordinary Bun gate. CI runs on Ubuntu and macOS for pull requests and for pushes to `main`.
|
|
4
4
|
|
|
5
|
-
Implemented: configuration, vault registration, lossless frontmatter parsing/emission, wiki-link resolution and derived SQLite FTS5 indexing with bounded read commands. Validated note writes, the append-only log, the `vmem mcp` stdio server, reversible `vmem setup` host registration and promotion from agent-mem (`promote-from-amem`) are also implemented below. Lint/graph analysis, promotion to org vaults (`vmem promote`) and explicit `vmem sync` are implemented below. The `acon` console
|
|
5
|
+
Implemented: configuration, vault registration, lossless frontmatter parsing/emission, wiki-link resolution and derived SQLite FTS5 indexing with bounded read commands. Validated note writes, the append-only log, the `vmem mcp` stdio server, reversible `vmem setup` host registration and promotion from agent-mem (`promote-from-amem`) are also implemented below. Lint/graph analysis, promotion to org vaults (`vmem promote`) and explicit `vmem sync` are implemented below. The vmem plugin for the `acon` console ([agent-console](https://github.com/STAIxBWLB/agent-console)) is described under "Console plugin"; vault-mem ships no console bin. Do not use this increment to replace an existing vault writer.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
bun install
|
|
@@ -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/`.
|
|
@@ -186,7 +187,7 @@ vmem setup all --undo
|
|
|
186
187
|
|
|
187
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.
|
|
188
189
|
|
|
189
|
-
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`.
|
|
190
|
+
Apply is idempotent and touches only the `vmem` entry: other servers and settings are preserved (TOML is edited textually, so comments and other tables stay byte-identical; JSON is re-serialized with its indentation). Configuration files must be plain strict JSON or TOML; links, JSONC and unparsable files are refused per host without writing, and the command exits 1. Apply records the prior `vmem` value in a receipt, `$VMEM_HOME/setup/<host>-<hash of the config path>.json`, so separate host profiles (for example two `CODEX_HOME`s) sharing one `VMEM_HOME` keep separate receipts. The receipt is written before the host file; if the host write fails, the receipt is rolled back. In TOML, every `[mcp_servers.vmem]` and `[mcp_servers.vmem.*]` table belongs to the entry, even when other tables sit between them. Undo restores that prior value (or removes the entry, and the file if setup created it and nothing else was added) only while the entry is still the one setup installed; an entry edited later is left alone and reported as `refused`. Every mode also handles the console plugin manifest (see "Console plugin"): apply writes it, undo removes it while it still points at this install.
|
|
190
191
|
|
|
191
192
|
`vmem completion bash|zsh|fish` prints a completion script generated from the same command table as `--help`.
|
|
192
193
|
|
|
@@ -208,13 +209,34 @@ vmem promote-from-amem personal mem:<id> --link-to existing-note # link an exis
|
|
|
208
209
|
- If the final link fails, the note stays, the result reports `LINK` and `link-only notes/<stem>.md`, and the entry stays a candidate. A rerun finds the note through `promoted_from` and offers link-only, never a second note or a second LEARN event.
|
|
209
210
|
- `vmem_promote_from_amem` is the same two steps over MCP and needs a vault with `write: mcp`.
|
|
210
211
|
|
|
212
|
+
## Console plugin
|
|
213
|
+
|
|
214
|
+
`vmem setup <target> --apply` also writes `$AGENTHUB_HOME/console/plugins/vmem.json` (default `~/.agenthub`; directory 0700, file 0600) with the absolute path and sha256 of `src/ui/plugin.ts`. `acon ui` then shows a Vault area. Re-run setup after upgrading vmem: acon refuses a module whose hash changed.
|
|
215
|
+
|
|
216
|
+
- Panels: Overview (vaults, maps, recent log), Search, Note (body, links, actions), Promote (agent-mem candidates), Lint (typed findings linked to notes) and Log (parsed rows).
|
|
217
|
+
- Inbox: promotion candidates (group `candidate`), lint errors and a stale graph report (group `alert`). The palette searches notes.
|
|
218
|
+
- Actions: `delete`, `move`, `promote` (to an org vault) and `promote-from-amem` reuse the two-step MCP tools. Each first answers a preview with the planned changes; the write happens only after the console's confirm dialog, and only while the fingerprint still matches.
|
|
219
|
+
- The provider calls the `vmem mcp` handlers in process, so the console has exactly the MCP rights: writes need a vault with `write: mcp`, and log lines carry the agent attribution.
|
|
220
|
+
- The module is exported as `@staix/vault-mem/ui-plugin`. Lint results are cached for 60 seconds per vault.
|
|
221
|
+
|
|
211
222
|
## Lint and graph analysis
|
|
212
223
|
|
|
213
224
|
`vmem lint <vault>` checks L01/L02/L03/L09/L10/L11/L12 and L13 (lowercase ASCII kebab-case filenames). `--note notes/example.md` limits note findings. Section names and `SUMMARY errors=N warnings=N` follow the Python reference. `--write` writes a screened lint report and LINT event; `--dry-run` previews without persistence. Findings may identify paths, link targets and malformed log prefixes. Note bodies are never included in analysis results.
|
|
214
225
|
|
|
215
226
|
`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
227
|
|
|
217
|
-
|
|
228
|
+
**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.
|
|
229
|
+
|
|
230
|
+
`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:
|
|
231
|
+
|
|
232
|
+
- **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.
|
|
233
|
+
- **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.
|
|
234
|
+
- **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.
|
|
235
|
+
- **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.
|
|
236
|
+
|
|
237
|
+
`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.
|
|
238
|
+
|
|
239
|
+
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
240
|
|
|
219
241
|
`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
242
|
|
|
@@ -244,6 +266,16 @@ vmem sync team --confirm --fingerprint <sha>
|
|
|
244
266
|
- **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
267
|
- **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
268
|
|
|
269
|
+
## Skill mining
|
|
270
|
+
|
|
271
|
+
`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.
|
|
272
|
+
|
|
273
|
+
- **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.
|
|
274
|
+
- **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`.
|
|
275
|
+
- **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).
|
|
276
|
+
- **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.
|
|
277
|
+
- **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.
|
|
278
|
+
|
|
247
279
|
|
|
248
280
|
|
|
249
281
|
## 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/vault-mem",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Validated Obsidian-compatible knowledge vaults for agents and people",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
"@staix/agent-mem": "0.11.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
+
"@staix/agent-console": "github:STAIxBWLB/agent-console#bd7234f",
|
|
18
19
|
"@types/bun": "^1.3.0",
|
|
19
20
|
"typescript": "^5.8.0"
|
|
20
21
|
},
|
|
21
22
|
"exports": {
|
|
22
|
-
".": "./src/index.ts"
|
|
23
|
+
".": "./src/index.ts",
|
|
24
|
+
"./ui-plugin": "./src/ui/plugin.ts"
|
|
23
25
|
},
|
|
24
26
|
"bin": {
|
|
25
27
|
"vmem": "bin/vmem.js"
|
package/src/amem/seam.ts
CHANGED
|
@@ -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 { localDate } from "./local-date.ts";
|
|
1
2
|
import { MAX_REPORT_BYTES } from "./write-validation.ts";
|
|
2
3
|
import { parseFrontmatter } from "./frontmatter.ts";
|
|
3
4
|
import { sourceWorkspaceRoot, missingSourceWarnings, sourceReferences } from "./source.ts";
|
|
@@ -35,7 +36,7 @@ export function analysisInput(home: string, id: string, options: { today?: strin
|
|
|
35
36
|
if (!vault) throw new Error("analysis: unknown vault id");
|
|
36
37
|
if (options.expectedVault && JSON.stringify(options.expectedVault) !== JSON.stringify(vault)) throw new Error("analysis: startup vault binding changed; restart MCP");
|
|
37
38
|
const identity = lstatSync(vault.path);
|
|
38
|
-
const today = options.today ??
|
|
39
|
+
const today = options.today ?? localDate();
|
|
39
40
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(today) || !Number.isFinite(Date.parse(today)) || new Date(today).toISOString().slice(0,10) !== today) throw new Error("analysis: invalid date");
|
|
40
41
|
permitted(vault.path, config);
|
|
41
42
|
// Check denied paths before opening the index or reading note sources.
|
package/src/analysis-reports.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { workspaceInput } from "./workspace-input.ts";
|
|
2
|
+
import { buildWorkspaceGraph } from "./workspace-graph.ts";
|
|
1
3
|
import { MAX_REPORT_BYTES } from "./write-validation.ts";
|
|
2
4
|
import type { VaultSpec } from "./config.ts";
|
|
3
5
|
import { lstatSync } from "node:fs";
|
|
@@ -12,7 +14,7 @@ import { analysisInput } from "./analysis-input.ts";
|
|
|
12
14
|
import { lintVault } from "./vault-lint.ts";
|
|
13
15
|
import { buildGraph, graphReport } from "./vault-graph.ts";
|
|
14
16
|
|
|
15
|
-
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 }
|
|
16
18
|
function trend(snapshot: AnalysisSnapshot, nodes: number, edges: number, communities: number, top: string): string {
|
|
17
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";
|
|
18
20
|
const source = snapshot.reports["graph-trend.md"] ?? seed;
|
|
@@ -27,8 +29,11 @@ function trend(snapshot: AnalysisSnapshot, nodes: number, edges: number, communi
|
|
|
27
29
|
}
|
|
28
30
|
export function writeAnalysisReports(home: string, request: AnalysisRequest, env: NodeJS.ProcessEnv = process.env) {
|
|
29
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;
|
|
30
34
|
const binding = {vault,denyPaths:config.denyPaths ?? [],piiPatterns:config.piiPatterns ?? [],dev:Number(identity.dev),ino:Number(identity.ino)};
|
|
31
35
|
const unchanged = () => {
|
|
36
|
+
work?.assertUnchanged?.();
|
|
32
37
|
const current = loadRegistry(home), stat = lstatSync(vault.path);
|
|
33
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");
|
|
34
39
|
};
|
|
@@ -37,6 +42,10 @@ export function writeAnalysisReports(home: string, request: AnalysisRequest, env
|
|
|
37
42
|
if (request.kind === "lint") {
|
|
38
43
|
const lint = lintVault(snapshot,request.note); summary = {findings:lint.findings,errors:lint.errors,warnings:lint.warnings,summary:lint.summary};
|
|
39
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"});
|
|
40
49
|
} else {
|
|
41
50
|
const graph = buildGraph(snapshot), communities = new Set(graph.nodes.flatMap(n => n.community === null ? [] : [n.community])).size;
|
|
42
51
|
summary = {nodes:graph.nodes.length,edges:graph.edges.length,communities};
|
|
@@ -47,7 +56,7 @@ export function writeAnalysisReports(home: string, request: AnalysisRequest, env
|
|
|
47
56
|
}
|
|
48
57
|
const originals = new Map(outputs.map(out => {
|
|
49
58
|
const name = out.path.slice("reports/".length);
|
|
50
|
-
return [out.path, request.kind === "graph" ? snapshot.reports[name] ?? null : analysisRead(join(vault.path,out.path), MAX_REPORT_BYTES)] 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;
|
|
51
60
|
}));
|
|
52
61
|
unchanged();
|
|
53
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");
|
|
@@ -65,6 +74,6 @@ export function writeAnalysisReports(home: string, request: AnalysisRequest, env
|
|
|
65
74
|
}
|
|
66
75
|
const complete = receipts.length === outputs.length && receipts.every(r => r.ok);
|
|
67
76
|
unchanged();
|
|
68
|
-
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;
|
|
69
78
|
return {ok:complete && log?.ok === true,applied:receipts.some(r => r.applied),summary,paths:outputs.map(o => o.path),receipts,log};
|
|
70
79
|
}
|
package/src/cli.ts
CHANGED
|
@@ -33,7 +33,7 @@ export function run(args: string[], env: NodeJS.ProcessEnv = process.env): { tex
|
|
|
33
33
|
if (positional.length !== 1 || flags.some(f => !["--apply", "--undo", "--json"].includes(f)) || new Set(flags).size !== flags.length || (flags.includes("--apply") && flags.includes("--undo"))) throw new Error("setup: requires one target and at most one of --apply/--undo");
|
|
34
34
|
const mode: SetupMode = flags.includes("--apply") ? "apply" : flags.includes("--undo") ? "undo" : "dry-run";
|
|
35
35
|
const report = runSetup(positional[0]!, mode, { home: resolveHome(env).home, env });
|
|
36
|
-
return { text: JSON.stringify(report, null, flags.includes("--json") ? undefined : 2), code: report.actions.some(a => a
|
|
36
|
+
return { text: JSON.stringify(report, null, flags.includes("--json") ? undefined : 2), code: [...report.actions, report.console].some(a => a?.change === "refused") ? 1 : 0 };
|
|
37
37
|
}
|
|
38
38
|
if (args[0] === "completion") {
|
|
39
39
|
if (args.length !== 2) throw new Error("completion: requires bash, zsh or fish");
|
|
@@ -113,8 +113,12 @@ export async function runMcp(env: NodeJS.ProcessEnv = process.env): Promise<void
|
|
|
113
113
|
const server = createVaultMcp(resolveHome(env).home, env);
|
|
114
114
|
await serveMcp({ tools: server.tools, handlers: server.handlers, name: "vmem", version });
|
|
115
115
|
}
|
|
116
|
-
/** 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. */
|
|
117
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
|
+
}
|
|
118
122
|
if (args[0] !== "promote-from-amem") return run(args, env);
|
|
119
123
|
const { runPromoteCommand } = await import("./promote-command.ts");
|
|
120
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='' ;;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local calendar date as YYYY-MM-DD, from the same local clock the vault log lines use (`log-format.ts`).
|
|
3
|
+
* Every user-facing calendar date (report names, trend rows, L11 staleness, `promoted_at`, skill reports) comes from here,
|
|
4
|
+
* so an early-morning run in a timezone ahead of UTC is not dated the previous day.
|
|
5
|
+
*/
|
|
6
|
+
export function localDate(now: Date = new Date()): string {
|
|
7
|
+
const pad = (value: number, size = 2) => String(value).padStart(size, "0");
|
|
8
|
+
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
9
|
+
}
|
package/src/log-format.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localDate } from "./local-date.ts";
|
|
1
2
|
import { screenContent } from "./write-validation.ts";
|
|
2
3
|
import { parsePii } from "./config.ts";
|
|
3
4
|
function validPii(value: unknown): boolean { try { parsePii(value); return true; } catch { return false; } }
|
|
@@ -49,7 +50,7 @@ export function formatLogEvent(event: LogEvent, context: LogContext): { line?: s
|
|
|
49
50
|
return { issues: [error("L09", "invalid log timestamp")] };
|
|
50
51
|
}
|
|
51
52
|
const pad = (value: number, size = 2) => String(value).padStart(size, "0");
|
|
52
|
-
const timestamp = `${
|
|
53
|
+
const timestamp = `${localDate(now)} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
53
54
|
const env = context.env ?? process.env;
|
|
54
55
|
const agent = context.channel === "mcp" || AGENT_MARKERS.some(marker => typeof env[marker] === "string" && env[marker]!.length > 0);
|
|
55
56
|
const line = `${timestamp} ${event.type} ${event.project} ${event.source}${event.dest === undefined ? "" : ` → ${event.dest}`} — ${event.note}${agent ? " (vmem:agent)" : ""}\n`;
|
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/org-promote.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localDate } from "./local-date.ts";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { closeSync, constants, fstatSync, openSync, readSync, realpathSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
@@ -225,7 +226,7 @@ export function promoteToOrg(home: string, request: OrgPromoteRequest, options:
|
|
|
225
226
|
const transformed = dropUnresolvedLinks(parsed.body, value => targetNotes.resolved.has(linkKey(value)));
|
|
226
227
|
// Body Topics section follows the target topics.
|
|
227
228
|
const body = replaceTopicsSection(transformed.body, topics);
|
|
228
|
-
const day = (options.now ?? new Date())
|
|
229
|
+
const day = localDate(options.now ?? new Date());
|
|
229
230
|
const frontmatter: Record<string, unknown> = { topics, promoted_from: result.source, promoted_at: day, promoted_to: undefined };
|
|
230
231
|
// Wiki-valued fields that point outside the target vault are dropped as well.
|
|
231
232
|
for (const key of ["project", "superseded_by", "supersedes", "source_ref"]) {
|
package/src/promote.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localDate } from "./local-date.ts";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import type { VaultSpec } from "./config.ts";
|
|
3
4
|
import { emitFrontmatter, parseFrontmatter } from "./frontmatter.ts";
|
|
@@ -63,7 +64,7 @@ export function draftNote(entry: AmemCandidate, meta: PromoteMetadata, now = new
|
|
|
63
64
|
const topics = (meta.topics ?? []).map(value => value.startsWith("[[") ? value : `[[${value}]]`);
|
|
64
65
|
const frontmatter: Record<string, unknown> = {
|
|
65
66
|
title, description: meta.description ?? firstSentence(entry.text), type: meta.type ?? (entry.kind === "decision" ? "decision" : entry.kind === "fail" ? "method" : "insight"),
|
|
66
|
-
...(meta.domain ? { domain: meta.domain } : {}), topics, promoted_from: `mem:${entry.id}`, promoted_at: now
|
|
67
|
+
...(meta.domain ? { domain: meta.domain } : {}), topics, promoted_from: `mem:${entry.id}`, promoted_at: localDate(now),
|
|
67
68
|
};
|
|
68
69
|
const hints = entry.about.length ? `${entry.about.slice(0, 20).map(node => `- ${node.replace(/[\r\n[\]]/g, " ")}`).join("\n")}\n` : "";
|
|
69
70
|
const body = meta.body ?? `# ${title}\n\n${entry.text.trim()}\n\n## Relevant Notes\n\n${hints}\n## Topics\n\n${topics.map(topic => `- ${topic}`).join("\n")}\n`;
|
package/src/setup.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, closeSync, constants, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, 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";
|
|
@@ -14,7 +14,8 @@ const MAX_HOST_CONFIG = 4 * 1024 * 1024;
|
|
|
14
14
|
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
|
-
export interface
|
|
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
19
|
interface Receipt { version: 1; target: SetupTarget; file: string; existed: boolean; keyExisted?: boolean; prior: unknown; priorToml?: string[]; installed: McpEntry }
|
|
19
20
|
|
|
20
21
|
function hosts(env: NodeJS.ProcessEnv, cwd: string): Record<SetupTarget, HostFile> {
|
|
@@ -170,7 +171,7 @@ export function runSetup(target: string, mode: SetupMode, options: { home: strin
|
|
|
170
171
|
const targets = target === "all" ? SETUP_TARGETS.filter(t => t !== "agy") : [target as SetupTarget];
|
|
171
172
|
const report: SetupReport = { mode, applied: false, entry, actions: [], notes: [
|
|
172
173
|
"Registers the stdio server `vmem mcp`; restart the host session to load it.",
|
|
173
|
-
"
|
|
174
|
+
"Writes the acon console plugin manifest ($AGENTHUB_HOME/console/plugins/vmem.json); re-run setup after upgrading vmem.",
|
|
174
175
|
] };
|
|
175
176
|
if (target === "all") report.notes.push("`all` covers user-level hosts; run `vmem setup agy` inside a workspace for Antigravity.");
|
|
176
177
|
for (const name of targets) {
|
|
@@ -214,6 +215,47 @@ export function runSetup(target: string, mode: SetupMode, options: { home: strin
|
|
|
214
215
|
action.change = "refused"; action.reason = error instanceof Error ? error.message : "setup failed";
|
|
215
216
|
}
|
|
216
217
|
}
|
|
217
|
-
report.
|
|
218
|
+
report.console = consoleManifest(mode, env);
|
|
219
|
+
report.applied = mode !== "dry-run" && [...report.actions, report.console].some(a => !["none", "unchanged", "refused"].includes(a.change));
|
|
218
220
|
return report;
|
|
219
221
|
}
|
|
222
|
+
|
|
223
|
+
/** 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 {
|
|
225
|
+
const family = env.AGENTHUB_HOME?.trim() ? resolve(env.AGENTHUB_HOME) : join(env.HOME?.trim() || homedir(), ".agenthub");
|
|
226
|
+
const dir = join(family, "console", "plugins"), file = join(dir, "vmem.json");
|
|
227
|
+
const module = fileURLToPath(new URL("./ui/plugin.ts", import.meta.url));
|
|
228
|
+
const action: ConsoleAction = { file, module, change: "none" };
|
|
229
|
+
try {
|
|
230
|
+
const next = { api: 1, tool: "vmem", module, sha256: createHash("sha256").update(readFileSync(module)).digest("hex") };
|
|
231
|
+
// Apply acon's own loader rules (agent-console scanPlugins): the directory and manifest must be real, owned by you
|
|
232
|
+
// and not writable by others. Kinds and owners are refused here; modes are fixed on apply.
|
|
233
|
+
for (const [path, kind] of [[dir, "directory"], [file, "file"]] as const) {
|
|
234
|
+
let info;
|
|
235
|
+
try { info = lstatSync(path); } catch { continue; }
|
|
236
|
+
if (kind === "directory" ? !info.isDirectory() : !info.isFile()) throw new Error(`${path}: acon needs a real ${kind}, not a link or other entry`);
|
|
237
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw new Error(`${path}: owned by another user; acon refuses it`);
|
|
238
|
+
}
|
|
239
|
+
const text = readText(file);
|
|
240
|
+
const current = text === null ? undefined : JSON.parse(text) as Record<string, unknown>;
|
|
241
|
+
if (mode === "undo") {
|
|
242
|
+
if (!current) { action.reason = "no manifest"; return action; }
|
|
243
|
+
if (current.module !== module) { action.change = "refused"; action.reason = "manifest points at another vmem install; left as is"; return action; }
|
|
244
|
+
unlinkSync(file); action.change = "remove"; return action;
|
|
245
|
+
}
|
|
246
|
+
const loose = (path: string, want: number) => { try { return (lstatSync(path).mode & 0o777) !== want; } catch { return false; } };
|
|
247
|
+
const modesOk = !loose(dir, 0o700) && !loose(file, 0o600);
|
|
248
|
+
if (isDeepStrictEqual(current, next) && modesOk) { action.change = "unchanged"; return action; }
|
|
249
|
+
action.change = current ? "replace" : "add";
|
|
250
|
+
if (!modesOk) action.reason = "permissions: plugin directory 0700, manifest 0600";
|
|
251
|
+
if (mode === "apply") {
|
|
252
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
253
|
+
chmodSync(dir, 0o700);
|
|
254
|
+
atomicWrite(file, JSON.stringify(next, null, 2) + "\n");
|
|
255
|
+
chmodSync(file, 0o600);
|
|
256
|
+
}
|
|
257
|
+
} catch (error) {
|
|
258
|
+
action.change = "refused"; action.reason = error instanceof Error ? error.message : "manifest write failed";
|
|
259
|
+
}
|
|
260
|
+
return action;
|
|
261
|
+
}
|