@staix/vault-mem 0.1.1 → 0.1.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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.3] - 2026-10-30
4
+
5
+ - `vmem tidy --log` fixes legacy log lines that fail lint L09: a trailing seconds field is dropped, and any other malformed line is kept verbatim behind `# legacy: `.
6
+
7
+ ## [0.1.2] - 2026-10-30
8
+
9
+ - `vmem move` accepts an existing legacy-named note (lint L13) as its source and may rewrite inbound links in other legacy-named notes, so the finding can be fixed with a normal rename. Destinations still need a valid stem.
10
+
3
11
  ## [0.1.1] - 2026-10-30
4
12
 
5
13
  - `vmem setup` registers the PATH `bun` launcher when it runs the same binary, so host MCP entries survive a runtime upgrade that removes the versioned real path.
package/README.md CHANGED
@@ -78,7 +78,7 @@ vmem tidy personal --all --json # migration sanitize preview (se
78
78
 
79
79
  Syntax follows indexed reads: the vault id comes first. `new` supports `--title`, `--source`, `--confidence`, `--status`, `--extra JSON` and `--body file|-`; omitted body gets an H1/Relevant Notes/Topics skeleton. Patch accepts nonempty old/new strings up to 8192 bytes and requires exactly one occurrence (including overlapping occurrences). Append uses an existing section or inserts it before Topics; it refuses targeting or introducing a Topics H2. `frontmatter` supports `--set JSON`, `--unset key,key` and `--expected-sha`; append also supports `--expected-sha`. Link relations are `supports`, `contradicts`, `extends`, `applies-to`, `context-for`; links are bidirectional unless `--one-way`. Supersede sets `status`/`superseded_by` on the old note and adds a reverse link in the next note. Report permits plain paths below `reports/` or `ops/`, is schema-exempt and still screened. Inputs are bounded to 256 KiB, follow no file symlinks and accept stdin with `-`.
80
80
 
81
- `--dry-run` plans and validates any writer without creating locks, temporary files, directories or caches. `check` always takes this path, even without `--dry-run`. Move, delete and tidy first return a preview with planned path/hash changes and a fingerprint; confirmation requires that fingerprint. It covers canonical operation input, the canonical vault path/identity and policy, expected SHAs, every scanned note and all touched files, so even a new inbound reference after preview forces another preview. Move rewrites visible wiki/heading/block/embed targets and wiki references in frontmatter across the vault, preserving explicit `|label` text, anchors and code. Alias-only links resolve to no note and are left as written. Tidy folds Connections sections into Relevant Notes. Delete refuses when it would newly break inbound links or MOC topics; fix those references first.
81
+ `--dry-run` plans and validates any writer without creating locks, temporary files, directories or caches. `check` always takes this path, even without `--dry-run`. Move, delete and tidy first return a preview with planned path/hash changes and a fingerprint; confirmation requires that fingerprint. It covers canonical operation input, the canonical vault path/identity and policy, expected SHAs, every scanned note and all touched files, so even a new inbound reference after preview forces another preview. Move rewrites visible wiki/heading/block/embed targets and wiki references in frontmatter across the vault, preserving explicit `|label` text, anchors and code. Alias-only links resolve to no note and are left as written. The move source may be an existing note with a legacy file name (lint L13, for example a dot in the stem); the destination must be a valid stem. Tidy folds Connections sections into Relevant Notes. Delete refuses when it would newly break inbound links or MOC topics; fix those references first.
82
82
 
83
83
  Validation returns named issues with severity and no matched secret text: V01 schema/enums/description; V02 existing MOC topics; V03 newly unresolved links; V04 flat ASCII note paths (maximum 200 bytes) and safe report paths; V05 final Topics placement/content (warning), with explicit append-to-Topics refusal; V06 pinned agent-mem secret regex parity and denied sources/input/snapshot paths; V07 email/phone/configured PII patterns when denied; V08 create duplicates (stem/NFKC H1); V09 256 KiB prospective size, exact patch and expected-SHA/drift; V10 supersession target integrity. Unchanged pre-existing broken links warn, including after a move. `possibleDuplicates` lists candidate paths for warnings from a disposable in-memory FTS5 query of title terms, with BM25 <= -0.000001, up to three paths; no disk cache is written for that check. `LOCKED`, `IO`, `ROLLBACK`, `INDEX` and `CLEANUP` identify operational failures. CLI returns exit 1 when `ok` is false.
84
84
 
@@ -247,6 +247,6 @@ Runtime changes intentionally make `release:verify` report stale historical rele
247
247
  | `--topics` | append `## Topics` listing the frontmatter topics where no such heading exists; mismatched sections are reported |
248
248
  | `--connections` | fold `## Connections (...)` headings into `## Relevant Notes` |
249
249
  | `--secrets` | replace V06 secret matches with `[redacted]`: in the body the match itself, in frontmatter the whole field value (the key stays, so the YAML stays valid). A match outside a field value, such as a YAML comment, is reported for manual repair. The preview names note and line, never the value. PII in personal vaults is left alone |
250
- | `--log` | replace secret-looking substrings and email addresses in existing log lines with `[redacted]`, under the log lock (refused with V06 when `denyPaths` covers the log); legacy lines are otherwise kept byte for byte. Git history is not rewritten: rotate exposed credentials |
250
+ | `--log` | replace secret-looking substrings and email addresses in existing log lines with `[redacted]`, under the log lock (refused with V06 when `denyPaths` covers the log). A line that fails the lint L09 structure check loses a trailing seconds field when that makes it valid, and is otherwise kept verbatim behind `# legacy: ` (readers skip `#` lines); no time, zone or TYPE is guessed. Other lines are kept byte for byte. Git history is not rewritten: rotate exposed credentials |
251
251
 
252
252
  `--map <file>` overrides defaults with strict JSON `{"enums": {"confidence|status|type": {"from": "to"}}, "keys": {"key": "drop" | "keep" | {"rename": "key"}}}` (at most 64 KiB and 1000 entries). For `tidy`, `--log` selects the log rule; tidy always writes its own `LINT` event.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/vault-mem",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Validated Obsidian-compatible knowledge vaults for agents and people",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/tidy.ts CHANGED
@@ -29,7 +29,7 @@ export interface TidyResult {
29
29
  notes: { path: string; changes: string[] }[];
30
30
  /** Values and keys left unchanged because no rule or map entry covers them. */
31
31
  reported: { path: string; message: string }[];
32
- log?: { lines: number; redactions: number; changes: string[] };
32
+ log?: { lines: number; redactions: number; formats: number; changes: string[] };
33
33
  fingerprint?: string;
34
34
  written: string[];
35
35
  issues: WriteIssue[];
@@ -271,17 +271,32 @@ function readLogFile(root: string): string | null {
271
271
  return buffer.subarray(0, read).toString("utf8");
272
272
  } finally { closeSync(fd); }
273
273
  }
274
- function sanitizeLog(text: string): { text: string; lines: number; redactions: number; changes: string[] } {
275
- let lines = 0, redactions = 0;
274
+ /** The lint L09 structure check (kept identical to vault-lint.ts). */
275
+ const LOG_STRUCTURE = /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2})\s+(\S+)\s+(.*)$/;
276
+ /** Fix a legacy line that fails L09: drop a seconds field when that makes it valid, otherwise comment it out verbatim. */
277
+ function normalizeLogLine(line: string): string | undefined {
278
+ const body = line.endsWith("\r") ? line.slice(0, -1) : line;
279
+ if (!body.trim() || body.startsWith("#") || LOG_STRUCTURE.test(body)) return undefined;
280
+ const seconds = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}):\d{2}(\s.*)$/.exec(body);
281
+ if (seconds && LOG_STRUCTURE.test(seconds[1]! + seconds[2]!)) return seconds[1]! + seconds[2]! + line.slice(body.length);
282
+ return `# legacy: ${line}`;
283
+ }
284
+ function sanitizeLog(text: string): { text: string; lines: number; redactions: number; formats: number; changes: string[] } {
285
+ let lines = 0, redactions = 0, formats = 0;
276
286
  const changes: string[] = [];
277
287
  const out = text.split("\n").map((line, index) => {
278
288
  const secret = redact(line);
279
289
  let hits = secret.hits, next = secret.text;
280
290
  next = next.replace(new RegExp(EMAIL_PATTERN.source, "gi"), () => { hits++; return "[redacted]"; });
281
- if (hits) { lines++; redactions += hits; changes.push(`log: line ${index + 1}, ${hits} redaction${hits === 1 ? "" : "s"}`); }
291
+ const normalized = normalizeLogLine(next);
292
+ if (normalized !== undefined) { next = normalized; formats++; }
293
+ if (hits || normalized !== undefined) {
294
+ lines++; redactions += hits;
295
+ changes.push(`log: line ${index + 1}, ${[hits ? `${hits} redaction${hits === 1 ? "" : "s"}` : "", normalized !== undefined ? "format" : ""].filter(Boolean).join(", ")}`);
296
+ }
282
297
  return next;
283
298
  }).join("\n");
284
- return { text: out, lines, redactions, changes };
299
+ return { text: out, lines, redactions, formats, changes };
285
300
  }
286
301
  /** Replace the log under its own lock, atomically (the one sanctioned in-place rewrite of the append-only log). */
287
302
  function rewriteLog(root: string, expected: string, next: string): void {
@@ -339,14 +354,14 @@ export function tidyVault(home: string, request: TidyRequest, options: { env?: N
339
354
  }
340
355
  }
341
356
  result.notes = planned.map(change => ({ path: change.path, changes: change.changes }));
342
- let logPlan: { before: string; text: string; lines: number; redactions: number; changes: string[] } | undefined;
357
+ let logPlan: { before: string; text: string; lines: number; redactions: number; formats: number; changes: string[] } | undefined;
343
358
  if (rules.includes("log") && isDenied(join(root, "log"), config.denyPaths)) return { ...result, issues: [issue("V06", "the vault log is denied by the path policy; drop --log")] };
344
359
  if (rules.includes("log")) {
345
360
  const before = readLogFile(root);
346
361
  if (before !== null) {
347
362
  const sanitized = sanitizeLog(before);
348
- result.log = { lines: sanitized.lines, redactions: sanitized.redactions, changes: sanitized.changes };
349
- if (sanitized.redactions) { logPlan = { before, ...sanitized }; result.counts.log = sanitized.redactions; }
363
+ result.log = { lines: sanitized.lines, redactions: sanitized.redactions, formats: sanitized.formats, changes: sanitized.changes };
364
+ if (sanitized.text !== before) { logPlan = { before, ...sanitized }; result.counts.log = sanitized.redactions + sanitized.formats; }
350
365
  }
351
366
  }
352
367
  // Validate every planned note with the writer (dry-run). A note the writer would refuse is reported and skipped
@@ -14,6 +14,15 @@ export function notePath(path: string): string {
14
14
  if (!/^notes\/[a-z0-9][a-z0-9-]*\.md$/.test(value) || Buffer.byteLength(value) > 200) refuse("V04", "expected flat lowercase ASCII note path <=200 bytes");
15
15
  return value;
16
16
  }
17
+ /** A move source may keep a legacy file name (lint L13) as long as it is an existing flat note; `to` still uses notePath. */
18
+ export function moveSourcePath(value: string, notes: ReadonlyMap<string, string>): string {
19
+ try { return notePath(value); } catch (error) {
20
+ if (typeof value !== "string") throw error;
21
+ const path = value.startsWith("notes/") ? value : `notes/${value.endsWith(".md") ? value : `${value}.md`}`;
22
+ if (path.slice(6).includes("/") || path.includes("\\") || !path.endsWith(".md") || !notes.has(path)) throw error;
23
+ return path;
24
+ }
25
+ }
17
26
  function needed(notes: Map<string, string>, path: string): string {
18
27
  const source = notes.get(path);
19
28
  if (source === undefined) refuse("V04", "note does not exist", path);
@@ -138,7 +147,7 @@ export function planOperation(operation: WriteOp, notes: Map<string, string>, re
138
147
  }
139
148
  case "delete": { const { path } = existing(operation.path); set(path, null); break; }
140
149
  case "move": {
141
- const from = existing(operation.from), to = notePath(operation.to);
150
+ const fromPath = moveSourcePath(operation.from, notes), from = { path: fromPath, source: needed(notes, fromPath) }, to = notePath(operation.to);
142
151
  if (notes.has(to)) refuse("V08", "move destination already exists", to);
143
152
  const names = new Map<string, Set<string>>();
144
153
  for (const [path, source] of notes) {
@@ -6,6 +6,7 @@ import { parseFrontmatter } from "./frontmatter.ts";
6
6
  import { extractLinks, normalizeTarget, proseLines, resolveLinks, type Link, type LinkNote, type ResolvedLink } from "./links.ts";
7
7
  import { canonicalPath, isDenied } from "./platform.ts";
8
8
  import type { PlannedChange, ValidationContext, WriteIssue } from "./write-types.ts";
9
+ import { moveSourcePath } from "./write-operations.ts";
9
10
 
10
11
  const TYPES = new Set(["insight", "decision", "observation", "person", "project", "method", "moc", "reference"]);
11
12
  const DOMAINS = new Set(["research", "projects", "teaching", "operations", "people", "ai-practice"]);
@@ -147,10 +148,14 @@ function linkSignature(link: ResolvedLink, source: string = link.source): string
147
148
  return JSON.stringify([source, noteKey(link.target), link.heading ? normalizeTarget(link.heading) : null,
148
149
  link.block ? normalizeTarget(link.block) : null, link.embed, link.reason]);
149
150
  }
151
+ /** The snapshot path a move removes, legacy file names included. */
152
+ function movedFrom(value: string, context: ValidationContext): string {
153
+ try { return moveSourcePath(value, context.notes); } catch { return `notes/${value.replace(/^notes\//, "").replace(/\.md$/, "")}.md`; }
154
+ }
150
155
  function changedBrokenLinks(oldNotes: Map<string, ParsedNote>, newNotes: Map<string, ParsedNote>, context: ValidationContext): WriteIssue[] {
151
156
  const oldCounts = new Map<string, number>();
152
157
  const move = context.operation.op === "move" ? context.operation : undefined;
153
- const from = move ? `notes/${move.from.replace(/^notes\//, "").replace(/\.md$/, "")}.md` : "";
158
+ const from = move ? movedFrom(move.from, context) : "";
154
159
  const to = move ? `notes/${move.to.replace(/^notes\//, "").replace(/\.md$/, "")}.md` : "";
155
160
  const oldNames = move ? names(oldNotes) : undefined;
156
161
  for (const link of references(oldNotes)) if (link.reason) {
@@ -229,8 +234,9 @@ export function validateWrite(changes: PlannedChange[], context: ValidationConte
229
234
  const issues: WriteIssue[] = [], final = new Map(context.notes), touched = new Set(changes.map(change => change.path));
230
235
  const report = context.operation.op === "report";
231
236
  for (const change of changes) {
232
- // Sanitize may rewrite an existing legacy-named note in place (L13 names stay until moved).
233
- if (!validPath(change.path, report) && !(context.operation.op === "sanitize" && context.notes.has(change.path))) issues.push(issue("V04", "invalid mutation path"));
237
+ // Sanitize and move may touch an existing legacy-named note (L13 names stay until moved); move destinations stay strict.
238
+ const legacy = context.notes.has(change.path) && (context.operation.op === "sanitize" || context.operation.op === "move");
239
+ if (!validPath(change.path, report) && !legacy) issues.push(issue("V04", "invalid mutation path"));
234
240
  if (change.after === null) final.delete(change.path);
235
241
  else if (change.path.startsWith("notes/")) final.set(change.path, change.after);
236
242
  if (change.after === null) continue;
@@ -249,7 +255,7 @@ export function validateWrite(changes: PlannedChange[], context: ValidationConte
249
255
  if (change.after === null || report) continue;
250
256
  const note = newNotes.get(change.path);
251
257
  if (!note) { issues.push(issue("V01", "invalid frontmatter", "error", change.path)); continue; }
252
- const prior=context.operation.op==="move"&&noteKey(context.operation.to)===noteKey(note.path)?oldNotes.get(`notes/${noteKey(context.operation.from)}.md`):oldNotes.get(note.path);
258
+ const prior=context.operation.op==="move"&&noteKey(context.operation.to)===noteKey(note.path)?oldNotes.get(movedFrom(context.operation.from, context)):oldNotes.get(note.path);
253
259
  issues.push(...schemaIssues(note,prior,context), ...topicPlacement(note));
254
260
  const source = note.data.source;
255
261
  let sourceDenied=strings(source).some(entry=>isDenied(entry.replaceAll("\\","/"),context.config.denyPaths));
package/src/writer.ts CHANGED
@@ -9,7 +9,7 @@ import { loadRegistry, syncDirectory } from "./registry.ts";
9
9
  import { VaultIndex, MAX_NOTE_BYTES } from "./vault-index.ts";
10
10
  import { parseFrontmatter } from "./frontmatter.ts";
11
11
  import { canonicalPath, isDenied } from "./platform.ts";
12
- import { notePath, planOperation, refuse, WriteRefusal } from "./write-operations.ts";
12
+ import { moveSourcePath, notePath, planOperation, refuse, WriteRefusal } from "./write-operations.ts";
13
13
  import { validateWrite } from "./write-validation.ts";
14
14
  import type { ChangeReceipt, PlannedChange, WriteIssue, WriteReceipt, WriteRequest } from "./write-types.ts";
15
15
  export const MUTATION_LOCK = "mutation.lock";
@@ -223,7 +223,7 @@ export function applyWrite(home: string, request: WriteRequest, options: { env?:
223
223
  if (request.log !== false) {
224
224
  const op = request.operation;
225
225
  const type = op.op === "link" ? "CONNECT" : op.op === "move" ? "ROUTE" : ["supersede", "tidy"].includes(op.op) ? "RETHINK" : "EXTRACT";
226
- const source = op.op === "move" || op.op === "link" ? notePath(op.from) : op.op === "supersede" ? notePath(op.old) : op.op === "delete" ? notePath(op.path) : `vmem:${op.op}`;
226
+ const source = op.op === "move" ? moveSourcePath(op.from, notes) : op.op === "link" ? notePath(op.from) : op.op === "supersede" ? notePath(op.old) : op.op === "delete" ? notePath(op.path) : `vmem:${op.op}`;
227
227
  const dest = op.op === "move" || op.op === "link" ? notePath(op.to) : op.op === "supersede" ? notePath(op.next) : op.op === "delete" ? undefined : changes.find(change => change.after !== null)?.path;
228
228
  const event: LogEvent = request.log ?? { type, project: "-", source, ...(dest ? { dest } : {}), note: op.op };
229
229
  try { result.log = appendLog(home, { vault: request.vault, event, channel: request.channel }, { env: options.env, binding: { vault, denyPaths: config.denyPaths ?? [], piiPatterns: config.piiPatterns ?? [], dev: Number(identity.dev), ino: Number(identity.ino) } }); }