@staix/vault-mem 0.2.2 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## [0.2.2] - 2026-10-31
4
10
 
5
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.
package/README.md CHANGED
@@ -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
- 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 and 32 deterministic label-propagation rounds. MOCs are excluded from clustering; ties sort by node id. Graph extraction intentionally retains the reference's body-only legacy wiki-link semantics, distinct from the richer navigation resolver.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/vault-mem",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Validated Obsidian-compatible knowledge vaults for agents and people",
5
5
  "license": "MIT",
6
6
  "type": "module",
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");
@@ -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]!);
@@ -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
@@ -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` loads the agent-mem seam, and only when it runs. */
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);
@@ -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) }) })),