@staix/vault-mem 0.0.0-stage → 0.1.0
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 +12 -0
- package/LICENSE +21 -0
- package/README.md +251 -2
- package/bin/vmem.js +12 -0
- package/package.json +39 -4
- package/src/amem/seam.ts +56 -0
- package/src/analysis-commands.ts +36 -0
- package/src/analysis-input.ts +82 -0
- package/src/analysis-reports.ts +69 -0
- package/src/analysis-types.ts +19 -0
- package/src/analysis-yaml.ts +178 -0
- package/src/cli.ts +101 -0
- package/src/commands-table.ts +88 -0
- package/src/config.ts +99 -0
- package/src/frontmatter.ts +154 -0
- package/src/home.ts +16 -0
- package/src/index.ts +44 -0
- package/src/links.ts +149 -0
- package/src/log-commands.ts +31 -0
- package/src/log-filter.ts +65 -0
- package/src/log-format.ts +57 -0
- package/src/log-types.ts +10 -0
- package/src/mcp-vault.ts +167 -0
- package/src/mcp.ts +110 -0
- package/src/org-commands.ts +31 -0
- package/src/org-promote.ts +259 -0
- package/src/platform.ts +1 -0
- package/src/promote-command.ts +77 -0
- package/src/promote.ts +167 -0
- package/src/read-commands.ts +43 -0
- package/src/registry.ts +170 -0
- package/src/setup.ts +214 -0
- package/src/source.ts +47 -0
- package/src/sync.ts +145 -0
- package/src/tidy.ts +386 -0
- package/src/vault-graph.ts +233 -0
- package/src/vault-index.ts +369 -0
- package/src/vault-lint.ts +135 -0
- package/src/vault-log.ts +229 -0
- package/src/write-commands.ts +93 -0
- package/src/write-operations.ts +181 -0
- package/src/write-types.ts +50 -0
- package/src/write-validation.ts +285 -0
- package/src/writer.ts +259 -0
- package/src/yaml-quoted.ts +141 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { parseYamlWithQuotedEscapes } from "./yaml-quoted.ts";
|
|
3
|
+
|
|
4
|
+
interface FieldSpan { key: string; start: number; end: number }
|
|
5
|
+
export interface FrontmatterDocument {
|
|
6
|
+
readonly source: string;
|
|
7
|
+
readonly body: string;
|
|
8
|
+
readonly data: Record<string, unknown>;
|
|
9
|
+
/** Original delimiters and YAML, including their original line endings. */
|
|
10
|
+
readonly frontmatter: string | null;
|
|
11
|
+
}
|
|
12
|
+
interface ParsedDocument extends FrontmatterDocument {
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
newline: string;
|
|
16
|
+
fields: FieldSpan[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mapping(value: unknown): value is Record<string, unknown> {
|
|
20
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Locate a block mapping's top-level keys without mistaking quoted/flow content for keys. */
|
|
24
|
+
function fieldSpans(yaml: string): FieldSpan[] {
|
|
25
|
+
const fields: FieldSpan[] = [];
|
|
26
|
+
let offset = 0, quote = "", flow = 0;
|
|
27
|
+
let blockIndent: number | null = null;
|
|
28
|
+
for (const line of yaml.match(/[^\n]*\n|[^\n]+$/g) ?? []) {
|
|
29
|
+
if (blockIndent !== null) {
|
|
30
|
+
const indent = line.match(/^[ \t]*/)![0].length;
|
|
31
|
+
if (!line.trim() || indent > blockIndent) { offset += line.length; continue; }
|
|
32
|
+
blockIndent = null;
|
|
33
|
+
}
|
|
34
|
+
if (!quote && !flow && /^[^ \t\r\n#{\[]/.test(line)) {
|
|
35
|
+
let keyQuote = "", colon = -1;
|
|
36
|
+
for (let i = 0; i < line.length; i++) {
|
|
37
|
+
const c = line[i]!;
|
|
38
|
+
if (keyQuote) {
|
|
39
|
+
if (c === "\\" && keyQuote === '"') { i++; continue; }
|
|
40
|
+
if (c === keyQuote) {
|
|
41
|
+
if (c === "'" && line[i + 1] === "'") { i++; continue; }
|
|
42
|
+
keyQuote = "";
|
|
43
|
+
}
|
|
44
|
+
} else if ((c === '"' || c === "'") && i === 0) keyQuote = c;
|
|
45
|
+
else if (c === ":" && /[ \t\r\n]/.test(line[i + 1] ?? "\n")) { colon = i; break; }
|
|
46
|
+
}
|
|
47
|
+
if (colon >= 0) {
|
|
48
|
+
const parsed: unknown = parseYamlWithQuotedEscapes(`${line.slice(0, colon)}: null`);
|
|
49
|
+
if (!mapping(parsed) || Object.keys(parsed).length !== 1) throw new Error("frontmatter: expected string mapping key");
|
|
50
|
+
const key = Object.keys(parsed)[0]!;
|
|
51
|
+
if (fields.some(field => field.key === key)) throw new Error("frontmatter: duplicate key");
|
|
52
|
+
fields.push({ key, start: offset, end: yaml.length });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
let nodeStart = !quote && flow === 0;
|
|
56
|
+
for (let i = 0; i < line.length; i++) {
|
|
57
|
+
const c = line[i]!;
|
|
58
|
+
if (quote) {
|
|
59
|
+
if (c === "\\" && quote === '"') { i++; continue; }
|
|
60
|
+
if (c === quote) {
|
|
61
|
+
if (c === "'" && line[i + 1] === "'") { i++; continue; }
|
|
62
|
+
quote = "";
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (c === "#" && (i === 0 || /\s/.test(line[i - 1]!))) break;
|
|
67
|
+
if ((c === '"' || c === "'") && nodeStart) { quote = c; nodeStart = false; }
|
|
68
|
+
else if (nodeStart && (c === "[" || c === "{")) { flow++; nodeStart = true; }
|
|
69
|
+
else if (flow > 0 && (c === "]" || c === "}")) { flow--; nodeStart = false; }
|
|
70
|
+
else if ((c === ":" && /\s/.test(line[i + 1] ?? "\n")) || (flow > 0 && c === ",") || (nodeStart && c === "-")) nodeStart = true;
|
|
71
|
+
else if (!/\s/.test(c)) nodeStart = false;
|
|
72
|
+
}
|
|
73
|
+
if (!quote && !flow && /(?:^|:[ \t]+|-[ \t]+)[|>](?:[+-][1-9]?|[1-9][+-]?)?[ \t]*(?:#[^\r\n]*)?\r?\n?$/.test(line)) blockIndent = line.match(/^[ \t]*/)![0].length;
|
|
74
|
+
offset += line.length;
|
|
75
|
+
}
|
|
76
|
+
for (let i = 0; i < fields.length; i++) {
|
|
77
|
+
const field = fields[i]!;
|
|
78
|
+
const next = fields[i + 1]?.start ?? yaml.length;
|
|
79
|
+
// Standalone comments/blank lines between fields belong to the untouched document.
|
|
80
|
+
const raw = yaml.slice(field.start, next);
|
|
81
|
+
let end = next;
|
|
82
|
+
const lines = raw.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
83
|
+
for (let j = lines.length - 1; j > 0; j--) {
|
|
84
|
+
if (!/^[ \t]*(?:#[^\r\n]*)?\r?\n?$/.test(lines[j]!)) break;
|
|
85
|
+
end -= lines[j]!.length;
|
|
86
|
+
}
|
|
87
|
+
field.end = end;
|
|
88
|
+
}
|
|
89
|
+
return fields;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Parse semantics with Bun's YAML parser while retaining every source byte for emission. */
|
|
93
|
+
export function parseFrontmatter(source: string): FrontmatterDocument {
|
|
94
|
+
const opening = source.match(/^(?:)?---[ \t]*\r?\n/);
|
|
95
|
+
if (!opening) return { source, body: source, data: {}, frontmatter: null };
|
|
96
|
+
const start = opening[0].length;
|
|
97
|
+
const closing = /^(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/gm;
|
|
98
|
+
closing.lastIndex = start;
|
|
99
|
+
const match = closing.exec(source);
|
|
100
|
+
if (!match) throw new Error("frontmatter: missing closing delimiter");
|
|
101
|
+
const yaml = source.slice(start, match.index);
|
|
102
|
+
let value: unknown;
|
|
103
|
+
try { value = parseYamlWithQuotedEscapes(yaml); }
|
|
104
|
+
catch { throw new Error("frontmatter: invalid YAML"); }
|
|
105
|
+
if (value === null || value === undefined) value = {};
|
|
106
|
+
if (!mapping(value)) throw new Error("frontmatter: expected mapping");
|
|
107
|
+
const end = match.index + match[0].length;
|
|
108
|
+
const document: ParsedDocument = {
|
|
109
|
+
source, body: source.slice(end), data: value, frontmatter: source.slice(0, end),
|
|
110
|
+
start, end: match.index, newline: opening[0].endsWith("\r\n") ? "\r\n" : "\n", fields: fieldSpans(yaml),
|
|
111
|
+
};
|
|
112
|
+
return document;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function canonical(key: string, value: unknown, newline: string): string {
|
|
116
|
+
let encoded: string | undefined;
|
|
117
|
+
try { encoded = JSON.stringify(value); }
|
|
118
|
+
catch { throw new Error("frontmatter: changed values must be JSON-compatible"); }
|
|
119
|
+
if (encoded === undefined || !isDeepStrictEqual(JSON.parse(encoded), value)) throw new Error("frontmatter: changed values must be JSON-compatible");
|
|
120
|
+
return `${JSON.stringify(key)}: ${encoded}${newline}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Emit the entire note. Undefined deletes a key; omitted or semantically equal keys retain their bytes. */
|
|
124
|
+
export function emitFrontmatter(document: FrontmatterDocument, changes: Record<string, unknown> = {}, force: ReadonlySet<string> = new Set()): string {
|
|
125
|
+
const parsed = parseFrontmatter(document.source) as ParsedDocument;
|
|
126
|
+
// `force` re-emits listed keys canonically even when semantically unchanged (escape cleanup).
|
|
127
|
+
const changed = Object.entries(changes).filter(([key, value]) => force.has(key) || !isDeepStrictEqual(parsed.data[key], value));
|
|
128
|
+
if (!changed.length) return document.source;
|
|
129
|
+
const newline = parsed.frontmatter === null ? "\n" : parsed.newline;
|
|
130
|
+
if (parsed.frontmatter === null) {
|
|
131
|
+
const yaml = changed.filter(([, value]) => value !== undefined).map(([key, value]) => canonical(key, value, newline)).join("");
|
|
132
|
+
if (!yaml) return document.source;
|
|
133
|
+
const bom = document.source.startsWith("") ? "" : "";
|
|
134
|
+
return `${bom}---${newline}${yaml}---${newline}${document.source.slice(bom.length)}`;
|
|
135
|
+
}
|
|
136
|
+
let yaml = document.source.slice(parsed.start, parsed.end);
|
|
137
|
+
const hasYamlContent = yaml.split(/\r?\n/).some(line => line.trim() && !line.trimStart().startsWith("#"));
|
|
138
|
+
if ((hasYamlContent && !parsed.fields.length) || Object.keys(parsed.data).some(key => !parsed.fields.some(field => field.key === key))) {
|
|
139
|
+
throw new Error("frontmatter: editing requires a block mapping");
|
|
140
|
+
}
|
|
141
|
+
const replacements: { start: number; end: number; value: string }[] = [];
|
|
142
|
+
let additions = "";
|
|
143
|
+
for (const [key, value] of changed) {
|
|
144
|
+
const field = parsed.fields.find(item => item.key === key);
|
|
145
|
+
const output = value === undefined ? "" : canonical(key, value, newline);
|
|
146
|
+
if (field) replacements.push({ start: field.start, end: field.end, value: output });
|
|
147
|
+
else {
|
|
148
|
+
if (Object.hasOwn(parsed.data, key)) throw new Error("frontmatter: editing requires a block mapping");
|
|
149
|
+
additions += output;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
for (const replacement of replacements.sort((a, b) => b.start - a.start)) yaml = yaml.slice(0, replacement.start) + replacement.value + yaml.slice(replacement.end);
|
|
153
|
+
return document.source.slice(0, parsed.start) + yaml + additions + document.source.slice(parsed.end);
|
|
154
|
+
}
|
package/src/home.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { absolute, readConfig } from "./config.ts";
|
|
4
|
+
export interface ResolvedHome { home: string; source: "VMEM_HOME" | "config" | "default" }
|
|
5
|
+
/**
|
|
6
|
+
* Precedence: VMEM_HOME, else a `home` pointer in the default home's config, else the default
|
|
7
|
+
* home ($AGENTHUB_HOME/vmem or ~/.agenthub/vmem). VMEM_HOME bypasses the pointer entirely.
|
|
8
|
+
* Every returned path is absolute and lexically normalized (no `.`, `..` or trailing slash);
|
|
9
|
+
* symlinks are not resolved.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveHome(env: NodeJS.ProcessEnv = process.env): ResolvedHome {
|
|
12
|
+
if (env.VMEM_HOME?.trim()) return { home: resolve(absolute(env.VMEM_HOME, "VMEM_HOME")), source: "VMEM_HOME" };
|
|
13
|
+
const fallback = join(env.AGENTHUB_HOME?.trim() ? resolve(absolute(env.AGENTHUB_HOME, "AGENTHUB_HOME")) : join(homedir(), ".agenthub"), "vmem");
|
|
14
|
+
const pointer = readConfig(fallback).home;
|
|
15
|
+
return pointer ? { home: resolve(pointer), source: "config" } : { home: fallback, source: "default" };
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export { resolveHome } from "./home.ts";
|
|
2
|
+
export { parseConfig, parseVault, readConfig, CONFIG_FILE, MAX_CONFIG_BYTES } from "./config.ts";
|
|
3
|
+
export type { VaultSpec, VmemConfig } from "./config.ts";
|
|
4
|
+
export { addVault, removeVault, loadRegistry, registryStatus, recoverRegistry, effectivePii, syncDirectory, LOCK_FILE } from "./registry.ts";
|
|
5
|
+
export type { RecoveryReport, LockOwner } from "./registry.ts";
|
|
6
|
+
export { parseFrontmatter, emitFrontmatter } from "./frontmatter.ts";
|
|
7
|
+
export type { FrontmatterDocument } from "./frontmatter.ts";
|
|
8
|
+
export { extractLinks, resolveLinks, normalizeTarget } from "./links.ts";
|
|
9
|
+
export type { Link, LinkNote, ResolvedLink } from "./links.ts";
|
|
10
|
+
export { VaultIndex, MAX_NOTE_BYTES, MAX_RESULTS, MAX_READ_BYTES } from "./vault-index.ts";
|
|
11
|
+
export type { IndexedNote, SearchOptions, SearchResult } from "./vault-index.ts";
|
|
12
|
+
export { applyWrite, sha256, MUTATION_LOCK, MAX_WRITE_BYTES } from "./writer.ts";
|
|
13
|
+
export { validateWrite, screenContent } from "./write-validation.ts";
|
|
14
|
+
export type { WriteOp, WriteRequest, WriteReceipt, WriteIssue, PlannedChange, ValidationContext, ChangeReceipt } from "./write-types.ts";
|
|
15
|
+
export { appendLog, readLog, LOG_LOCK, MAX_LOG_READ_BYTES, MAX_LOG_RESULT_BYTES, LARGE_LOG_LINES } from "./vault-log.ts";
|
|
16
|
+
export { formatLogEvent, LOG_TYPES, AGENT_MARKERS } from "./log-format.ts";
|
|
17
|
+
export type { LogEvent, LogRequest, LogContext, LogReceipt, LogReadOptions, LogReadResult, LogBinding } from "./log-types.ts";
|
|
18
|
+
export { serveMcp, validArguments, FRAME_BYTES, RESULT_BYTES } from "./mcp.ts";
|
|
19
|
+
export type { McpTool, McpHandler, McpServerOptions } from "./mcp.ts";
|
|
20
|
+
export { createVaultMcp, vaultTools, VMEM_TOOL_NAMES } from "./mcp-vault.ts";
|
|
21
|
+
export { runSetup, defaultEntry, SETUP_TARGETS, SERVER_NAME } from "./setup.ts";
|
|
22
|
+
export type { SetupTarget, SetupMode, SetupReport, SetupAction, McpEntry } from "./setup.ts";
|
|
23
|
+
export { COMMANDS, completionDefinitions, completionScript } from "./commands-table.ts";
|
|
24
|
+
export type { CompletionDefinition } from "./commands-table.ts";
|
|
25
|
+
export { promoteFromAmem, listPromotionCandidates, draftNote, stemFor, logProjectFor } from "./promote.ts";
|
|
26
|
+
export type { PromoteRequest, PromoteResult, PromoteMetadata, SeamFactory } from "./promote.ts";
|
|
27
|
+
export type { AmemSeam, AmemCandidate, AmemOptions } from "./amem/seam.ts";
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export { lintVault } from "./vault-lint.ts";
|
|
31
|
+
export { buildGraph, graphNeighbors, graphReport } from "./vault-graph.ts";
|
|
32
|
+
export type { VaultGraph, GraphNeighborhood } from "./vault-graph.ts";
|
|
33
|
+
export type { AnalysisSnapshot, LintCode, LintReport } from "./analysis-types.ts";
|
|
34
|
+
export { writeAnalysisReports } from "./analysis-reports.ts";
|
|
35
|
+
export type { AnalysisRequest } from "./analysis-reports.ts";
|
|
36
|
+
export { promoteToOrg, screenForOrg, dropUnresolvedLinks, lintPromoted } from "./org-promote.ts";
|
|
37
|
+
export type { OrgPromoteRequest, OrgPromoteResult } from "./org-promote.ts";
|
|
38
|
+
export { syncVault, sameRemote } from "./sync.ts";
|
|
39
|
+
export type { SyncResult } from "./sync.ts";
|
|
40
|
+
|
|
41
|
+
export { normalizeSource, sourceReferences } from "./source.ts";
|
|
42
|
+
export type { SourceValue, SourceReference } from "./source.ts";
|
|
43
|
+
export { tidyVault, parseTidyMap, TIDY_RULES } from "./tidy.ts";
|
|
44
|
+
export type { TidyRequest, TidyResult, TidyRule, TidyMap } from "./tidy.ts";
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export interface Link {
|
|
2
|
+
target: string;
|
|
3
|
+
label: string | null;
|
|
4
|
+
heading: string | null;
|
|
5
|
+
block: string | null;
|
|
6
|
+
embed: boolean;
|
|
7
|
+
/** One-based line in the body, excluding frontmatter delimiters and YAML. */
|
|
8
|
+
line: number;
|
|
9
|
+
}
|
|
10
|
+
export interface LinkNote { path: string; title: string; aliases: string[]; body: string }
|
|
11
|
+
export interface ResolvedLink extends Link {
|
|
12
|
+
source: string;
|
|
13
|
+
resolved: string | null;
|
|
14
|
+
reason: "missing-note" | "ambiguous-note" | "missing-heading" | "missing-block" | null;
|
|
15
|
+
}
|
|
16
|
+
export function normalizeTarget(value: string): string { return value.normalize("NFC").toLowerCase(); }
|
|
17
|
+
/** Preserve line positions while hiding fenced code, including unclosed fences. */
|
|
18
|
+
export function proseLines(body: string): string[] {
|
|
19
|
+
let fence: { char: string; size: number } | undefined;
|
|
20
|
+
return body.split(/\r?\n/).map(line => {
|
|
21
|
+
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
22
|
+
if (fence) {
|
|
23
|
+
if (match && match[1]![0] === fence.char && match[1]!.length >= fence.size && !match[2]!.trim()) fence = undefined;
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
if (match && !(match[1]![0] === "`" && match[2]!.includes("`"))) {
|
|
27
|
+
fence = { char: match[1]![0]!, size: match[1]!.length };
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
return line;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function escapedAt(text: string, offset: number): boolean {
|
|
34
|
+
let slashes = 0;
|
|
35
|
+
for (let i = offset - 1; i >= 0 && text[i] === "\\"; i--) slashes++;
|
|
36
|
+
return slashes % 2 === 1;
|
|
37
|
+
}
|
|
38
|
+
/** Mask matched inline code spans across lines, retaining offsets and line numbers. */
|
|
39
|
+
function maskInlineCode(body: string): string {
|
|
40
|
+
const runs = [...body.matchAll(/`+/g)];
|
|
41
|
+
const next = new Map<number, number>(), closing = new Map<number, number>();
|
|
42
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
43
|
+
const size = runs[i]![0].length, match = next.get(size);
|
|
44
|
+
if (match !== undefined) closing.set(i, match);
|
|
45
|
+
next.set(size, i);
|
|
46
|
+
}
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
let offset = 0;
|
|
49
|
+
for (let i = 0; i < runs.length; i++) {
|
|
50
|
+
const run = runs[i]!, start = run.index!;
|
|
51
|
+
// Backslashes escape opening delimiters, but have no escape meaning inside code.
|
|
52
|
+
if (escapedAt(body, start)) continue;
|
|
53
|
+
const endIndex = closing.get(i);
|
|
54
|
+
if (endIndex === undefined) continue; // Unmatched backticks remain literal Markdown.
|
|
55
|
+
const end = runs[endIndex]!.index! + runs[endIndex]![0].length;
|
|
56
|
+
parts.push(body.slice(offset, start), body.slice(start, end).replace(/[^\r\n]/g, " "));
|
|
57
|
+
offset = end;
|
|
58
|
+
i = endIndex;
|
|
59
|
+
}
|
|
60
|
+
parts.push(body.slice(offset));
|
|
61
|
+
return parts.join("");
|
|
62
|
+
}
|
|
63
|
+
export function extractLinks(body: string): Link[] {
|
|
64
|
+
const links: Link[] = [];
|
|
65
|
+
for (const [index, line] of maskInlineCode(proseLines(body).join("\n")).split("\n").entries()) {
|
|
66
|
+
for (const match of line.matchAll(/(!?)\[\[([^\]\n]+)\]\]/g)) {
|
|
67
|
+
if (escapedAt(line, match.index!)) continue;
|
|
68
|
+
const [address, ...labels] = match[2]!.split("|");
|
|
69
|
+
const hash = address!.indexOf("#");
|
|
70
|
+
const target = (hash < 0 ? address! : address!.slice(0, hash)).trim();
|
|
71
|
+
const anchor = hash < 0 ? null : address!.slice(hash + 1).trim();
|
|
72
|
+
links.push({ target, label: labels.length ? labels.join("|") : null,
|
|
73
|
+
heading: anchor && !anchor.startsWith("^") ? anchor : null,
|
|
74
|
+
block: anchor?.startsWith("^") ? anchor.slice(1) : null, embed: match[1] === "!", line: index + 1 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return links;
|
|
78
|
+
}
|
|
79
|
+
/** Rewrite only visible wiki-link note targets; retain aliases, anchors, embeds and code verbatim. */
|
|
80
|
+
export function rewriteLinkTargets(body: string, replace: (target: string) => string | undefined): string {
|
|
81
|
+
const masked = maskInlineCode(proseLines(body).join("\n")).split("\n");
|
|
82
|
+
const lines = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
83
|
+
return lines.map((raw, index) => {
|
|
84
|
+
let output = raw;
|
|
85
|
+
const matches = [...(masked[index] ?? "").matchAll(/(!?)\[\[([^\]\n]+)\]\]/g)];
|
|
86
|
+
for (const match of matches.reverse()) {
|
|
87
|
+
if (escapedAt(masked[index]!, match.index!)) continue;
|
|
88
|
+
const address = match[2]!.split("|")[0]!, hash = address.indexOf("#");
|
|
89
|
+
const target = hash < 0 ? address : address.slice(0, hash);
|
|
90
|
+
const next = replace(target.trim());
|
|
91
|
+
if (next === undefined) continue;
|
|
92
|
+
const start = match.index! + match[1]!.length + 2;
|
|
93
|
+
output = output.slice(0, start) + next + output.slice(start + target.length);
|
|
94
|
+
}
|
|
95
|
+
return output;
|
|
96
|
+
}).join("");
|
|
97
|
+
}
|
|
98
|
+
export function noteKey(value: string): string {
|
|
99
|
+
return normalizeTarget(value.replace(/^\.\//, "").replace(/^notes\//i, "").replace(/\.md$/i, ""));
|
|
100
|
+
}
|
|
101
|
+
function anchorIndex(body: string): { headings: Set<string>; blocks: Set<string> } {
|
|
102
|
+
const lines = proseLines(body), headings = new Set<string>(), blocks = new Set<string>();
|
|
103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
104
|
+
const line = lines[i]!, atx = /^ {0,3}#{1,6}([ \t]+.*|)$/.exec(line);
|
|
105
|
+
if (atx) headings.add(normalizeTarget(atx[1]!.replace(/[ \t]+#+[ \t]*$/, "").trim()));
|
|
106
|
+
if (i > 0 && /^ {0,3}(?:=+|-+)\s*$/.test(line) && lines[i - 1]!.trim()) headings.add(normalizeTarget(lines[i - 1]!.trim()));
|
|
107
|
+
const block = /(?:^|\s)\^([\w-]+)\s*$/.exec(line);
|
|
108
|
+
if (block) blocks.add(normalizeTarget(block[1]!));
|
|
109
|
+
}
|
|
110
|
+
return { headings, blocks };
|
|
111
|
+
}
|
|
112
|
+
type Analysis = { body: string; links?: Link[]; anchors?: ReturnType<typeof anchorIndex> };
|
|
113
|
+
// Per-note memo: callers that pass the same LinkNote object again (old/new validation snapshots) skip re-analysis.
|
|
114
|
+
const memo = new WeakMap<LinkNote, Analysis>();
|
|
115
|
+
/** Additional outgoing links share the real target anchor index but never the body code state. `sources` limits output to those source paths. */
|
|
116
|
+
export function resolveLinks(notes: LinkNote[], additionalLinks?: ReadonlyMap<string, readonly Link[]>, sources?: ReadonlySet<string>): ResolvedLink[] {
|
|
117
|
+
const names = new Map<string, Set<LinkNote>>();
|
|
118
|
+
// Obsidian resolves wiki links by note path only; aliases are display metadata.
|
|
119
|
+
for (const note of notes) {
|
|
120
|
+
const key = noteKey(note.path), candidates = names.get(key) ?? new Set<LinkNote>();
|
|
121
|
+
candidates.add(note); names.set(key, candidates);
|
|
122
|
+
}
|
|
123
|
+
// Each body is read once per call; a memo entry is reused only while that body is unchanged.
|
|
124
|
+
const seen = new Map<LinkNote, Analysis>();
|
|
125
|
+
const analysis = (note: LinkNote): Analysis => {
|
|
126
|
+
let entry = seen.get(note);
|
|
127
|
+
if (entry) return entry;
|
|
128
|
+
const body = note.body;
|
|
129
|
+
entry = memo.get(note);
|
|
130
|
+
if (entry?.body !== body) memo.set(note, entry = { body });
|
|
131
|
+
seen.set(note, entry);
|
|
132
|
+
return entry;
|
|
133
|
+
};
|
|
134
|
+
const anchors = (note: LinkNote) => { const entry = analysis(note); return entry.anchors ??= anchorIndex(entry.body); };
|
|
135
|
+
const result: ResolvedLink[] = [];
|
|
136
|
+
for (const note of notes) {
|
|
137
|
+
if (sources && !sources.has(note.path)) continue;
|
|
138
|
+
const entry = analysis(note);
|
|
139
|
+
for (const link of [...(entry.links ??= extractLinks(entry.body)), ...(additionalLinks?.get(note.path) ?? [])]) {
|
|
140
|
+
const candidates = link.target ? [...(names.get(noteKey(link.target)) ?? [])] : [note];
|
|
141
|
+
const target = candidates.length === 1 ? candidates[0]! : undefined;
|
|
142
|
+
let reason: ResolvedLink["reason"] = candidates.length > 1 ? "ambiguous-note" : target ? null : "missing-note";
|
|
143
|
+
if (target && link.heading && !anchors(target).headings.has(normalizeTarget(link.heading))) reason = "missing-heading";
|
|
144
|
+
if (target && link.block && !anchors(target).blocks.has(normalizeTarget(link.block))) reason = "missing-block";
|
|
145
|
+
result.push({ ...link, source: note.path, resolved: reason ? null : target!.path, reason });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { appendLog, readLog } from "./vault-log.ts";
|
|
2
|
+
import type { LogEvent } from "./log-types.ts";
|
|
3
|
+
export function runLogCommand(args: string[], home: string, env: NodeJS.ProcessEnv = process.env): { text: string; code: number } {
|
|
4
|
+
const command = args[0];
|
|
5
|
+
if (!["tail", "grep", "append"].includes(command ?? "")) throw new Error("log: use tail, grep or append");
|
|
6
|
+
const positional: string[] = [], flags: Record<string, string | boolean> = {};
|
|
7
|
+
const values = command === "append" ? ["--dest"] : ["--limit", "--type"];
|
|
8
|
+
for (let i = 1; i < args.length; i++) {
|
|
9
|
+
const word = args[i]!;
|
|
10
|
+
if (!word.startsWith("--")) { positional.push(word); continue; }
|
|
11
|
+
if (Object.hasOwn(flags, word) || (word !== "--json" && !values.includes(word))) throw new Error("log: unknown or repeated option");
|
|
12
|
+
if (word === "--json") flags[word] = true;
|
|
13
|
+
else {
|
|
14
|
+
const value = args[++i]; if (value === undefined || value.startsWith("--")) throw new Error("log: option value required");
|
|
15
|
+
flags[word] = value;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
let result;
|
|
19
|
+
if (command === "append") {
|
|
20
|
+
if (positional.length !== 5) throw new Error("log append: requires vault, TYPE, project, source and note");
|
|
21
|
+
const [vault, type, project, source, note] = positional as [string, string, string, string, string];
|
|
22
|
+
const event: LogEvent = { type, project, source, note, ...(typeof flags["--dest"] === "string" ? { dest: flags["--dest"] } : {}) };
|
|
23
|
+
result = appendLog(home, { vault, event, channel: "cli" }, { env });
|
|
24
|
+
} else {
|
|
25
|
+
if (positional.length !== (command === "grep" ? 2 : 1)) throw new Error(`log ${command}: requires vault${command === "grep" ? " and pattern" : ""}`);
|
|
26
|
+
const value = flags["--limit"];
|
|
27
|
+
if (value !== undefined && (typeof value !== "string" || !/^[0-9]+$/.test(value))) throw new Error("log limit: expected integer 1..200");
|
|
28
|
+
result = readLog(home, { vault: positional[0]!, ...(value !== undefined ? { limit: Number(value) } : {}), ...(command === "grep" ? { grep: positional[1] } : {}), ...(typeof flags["--type"] === "string" ? { type: flags["--type"] } : {}) });
|
|
29
|
+
}
|
|
30
|
+
return { text: JSON.stringify(result, null, flags["--json"] ? undefined : 2), code: result.ok ? 0 : 1 };
|
|
31
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** A bounded, non-backtracking grep subset: alternatives, anchors, classes and atom * + ?. */
|
|
2
|
+
interface Atom { test: (char: string) => boolean; optional: boolean; repeat: boolean }
|
|
3
|
+
interface Branch { atoms: Atom[]; start: boolean; end: boolean }
|
|
4
|
+
export function compileLogFilter(pattern: string): (line: string) => boolean {
|
|
5
|
+
if (typeof pattern !== "string" || pattern.length > 256) throw new Error("grep pattern exceeds 256 characters");
|
|
6
|
+
const chars = [...pattern], branches: Branch[] = [];
|
|
7
|
+
let current: Branch = { atoms: [], start: false, end: false };
|
|
8
|
+
for (let i = 0; i < chars.length; i++) {
|
|
9
|
+
const char = chars[i]!;
|
|
10
|
+
if (char === "|") { branches.push(current); current = { atoms: [], start: false, end: false }; continue; }
|
|
11
|
+
if (char === "^" && current.atoms.length === 0 && !current.start && !current.end) { current.start = true; continue; }
|
|
12
|
+
if (char === "$" && (i === chars.length - 1 || chars[i + 1] === "|")) { current.end = true; continue; }
|
|
13
|
+
if (current.end || "^$()*+?{}]".includes(char)) throw new Error("unsupported grep syntax");
|
|
14
|
+
let expression: string | undefined, literal: string | undefined;
|
|
15
|
+
if (char === "\\") {
|
|
16
|
+
const escaped = chars[++i];
|
|
17
|
+
if (!escaped) throw new Error("incomplete grep escape");
|
|
18
|
+
if (/^[dDsSwW]$/.test(escaped)) expression = `\\${escaped}`;
|
|
19
|
+
else if ("\\.^$|?*+()[]{}-".includes(escaped)) literal = escaped;
|
|
20
|
+
else throw new Error("unsupported grep escape");
|
|
21
|
+
} else if (char === "[") {
|
|
22
|
+
let value = "[", closed = false;
|
|
23
|
+
while (++i < chars.length) {
|
|
24
|
+
const item = chars[i]!; value += item;
|
|
25
|
+
if (item === "\\") {
|
|
26
|
+
const escaped = chars[++i];
|
|
27
|
+
if (!escaped || (!/^[dDsSwW]$/.test(escaped) && !"\\[]^-".includes(escaped))) throw new Error("unsupported class escape");
|
|
28
|
+
value += escaped;
|
|
29
|
+
} else if (item === "]") { closed = true; break; }
|
|
30
|
+
}
|
|
31
|
+
if (!closed) throw new Error("unterminated grep class");
|
|
32
|
+
expression = value;
|
|
33
|
+
} else if (char === ".") expression = ".";
|
|
34
|
+
else literal = char;
|
|
35
|
+
const matcher = expression === undefined ? undefined : new RegExp(`^(?:${expression})$`, "u");
|
|
36
|
+
const atom: Atom = { test: matcher ? value => matcher.test(value) : value => value === literal, optional: false, repeat: false };
|
|
37
|
+
const quantifier = chars[i + 1];
|
|
38
|
+
if (quantifier && "*+?".includes(quantifier)) {
|
|
39
|
+
i++; atom.optional = quantifier !== "+"; atom.repeat = quantifier !== "?";
|
|
40
|
+
}
|
|
41
|
+
current.atoms.push(atom);
|
|
42
|
+
}
|
|
43
|
+
branches.push(current);
|
|
44
|
+
return (line: string) => {
|
|
45
|
+
const text = [...line];
|
|
46
|
+
for (const branch of branches) {
|
|
47
|
+
const atoms = branch.atoms;
|
|
48
|
+
let active = new Uint8Array(atoms.length + 1), next = new Uint8Array(atoms.length + 1);
|
|
49
|
+
for (let position = 0; position <= text.length; position++) {
|
|
50
|
+
if (!branch.start || position === 0) active[0] = 1;
|
|
51
|
+
// Epsilon transitions only move forward; a single pass computes closure.
|
|
52
|
+
for (let i = 0; i < atoms.length; i++) if (active[i] && atoms[i]!.optional) active[i + 1] = 1;
|
|
53
|
+
if (active[atoms.length] && (!branch.end || position === text.length)) return true;
|
|
54
|
+
if (position === text.length) break;
|
|
55
|
+
next.fill(0);
|
|
56
|
+
for (let i = 0; i < atoms.length; i++) if (active[i] && atoms[i]!.test(text[position]!)) {
|
|
57
|
+
next[i + 1] = 1;
|
|
58
|
+
if (atoms[i]!.repeat) next[i] = 1;
|
|
59
|
+
}
|
|
60
|
+
const previous = active; active = next; next = previous;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { screenContent } from "./write-validation.ts";
|
|
2
|
+
import type { LogContext, LogEvent } from "./log-types.ts";
|
|
3
|
+
import type { WriteIssue } from "./write-types.ts";
|
|
4
|
+
|
|
5
|
+
export const LOG_TYPES = ["INGEST", "ROUTE", "EXTRACT", "CONNECT", "DIGEST", "LEARN", "LINT", "TASK", "GRAPH", "SYNC", "RETHINK", "SOURCE"] as const;
|
|
6
|
+
export const AGENT_MARKERS = ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID", "PI_SESSION_ID"] as const;
|
|
7
|
+
const EVENT_KEYS = new Set(["type", "project", "source", "dest", "note"]);
|
|
8
|
+
const CONTEXT_KEYS = new Set(["vault", "config", "channel", "env", "now", "projectIds"]);
|
|
9
|
+
function error(code: string, message: string): WriteIssue { return { code, severity: "error", message }; }
|
|
10
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
12
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
13
|
+
return prototype === Object.prototype || prototype === null;
|
|
14
|
+
}
|
|
15
|
+
function field(value: unknown): value is string {
|
|
16
|
+
return typeof value === "string" && value.length > 0 && value.trim() === value &&
|
|
17
|
+
!/[\p{Cc}\p{Zl}\p{Zp}]/u.test(value) && !value.includes("|") && !value.includes(" ") && !value.includes(" — ");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Format and screen one prospective log line. This function performs no IO. */
|
|
21
|
+
export function formatLogEvent(event: LogEvent, context: LogContext): { line?: string; issues: WriteIssue[] } {
|
|
22
|
+
if (!record(event) || Object.keys(event).some(key => !EVENT_KEYS.has(key)) ||
|
|
23
|
+
!record(context) || Object.keys(context).some(key => !CONTEXT_KEYS.has(key))) {
|
|
24
|
+
return { issues: [error("L09", "invalid event or context options")] };
|
|
25
|
+
}
|
|
26
|
+
if (!field(event.type) || !LOG_TYPES.some(type => type === event.type) ||
|
|
27
|
+
!field(event.project) || (event.project !== "-" && !/^[a-z0-9][a-z0-9-]*$/.test(event.project)) ||
|
|
28
|
+
!field(event.source) || !field(event.note) || (event.dest !== undefined && !field(event.dest)) ||
|
|
29
|
+
event.source.includes(" → ") || (event.dest !== undefined && event.dest.includes(" → "))) {
|
|
30
|
+
return { issues: [error("L09", "invalid log fields, type, project or separators")] };
|
|
31
|
+
}
|
|
32
|
+
if ((context.channel !== undefined && context.channel !== "cli" && context.channel !== "mcp") ||
|
|
33
|
+
!record(context.vault) || !["personal", "org"].includes(context.vault.kind) ||
|
|
34
|
+
(context.vault.pii !== undefined && context.vault.pii !== "allow" && context.vault.pii !== "deny") ||
|
|
35
|
+
!record(context.config) || !Array.isArray(context.config.vaults) ||
|
|
36
|
+
(context.config.piiPatterns !== undefined && (!Array.isArray(context.config.piiPatterns) || context.config.piiPatterns.some(value => typeof value !== "string"))) ||
|
|
37
|
+
(context.now !== undefined && !(context.now instanceof Date)) ||
|
|
38
|
+
(context.env !== undefined && (!context.env || typeof context.env !== "object" || Array.isArray(context.env) || Object.values(context.env).some(value => value !== undefined && typeof value !== "string"))) ||
|
|
39
|
+
(context.projectIds !== undefined && !(context.projectIds instanceof Set))) {
|
|
40
|
+
return { issues: [error("L09", "invalid log context")] };
|
|
41
|
+
}
|
|
42
|
+
if (event.project !== "-" && context.projectIds !== undefined && !context.projectIds.has(event.project)) {
|
|
43
|
+
return { issues: [error("L09", "project is absent from the configured registry")] };
|
|
44
|
+
}
|
|
45
|
+
const now = context.now ?? new Date();
|
|
46
|
+
if (!(now instanceof Date) || !Number.isFinite(now.getTime()) || now.getFullYear() < 0 || now.getFullYear() > 9999) {
|
|
47
|
+
return { issues: [error("L09", "invalid log timestamp")] };
|
|
48
|
+
}
|
|
49
|
+
const pad = (value: number, size = 2) => String(value).padStart(size, "0");
|
|
50
|
+
const timestamp = `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
51
|
+
const env = context.env ?? process.env;
|
|
52
|
+
const agent = context.channel === "mcp" || AGENT_MARKERS.some(marker => typeof env[marker] === "string" && env[marker]!.length > 0);
|
|
53
|
+
const line = `${timestamp} ${event.type} ${event.project} ${event.source}${event.dest === undefined ? "" : ` → ${event.dest}`} — ${event.note}${agent ? " (vmem:agent)" : ""}\n`;
|
|
54
|
+
const issues = screenContent(line, context.vault, context.config);
|
|
55
|
+
if (Buffer.byteLength(line, "utf8") > 1000) issues.push(error("V09", "log line exceeds 1000 UTF-8 bytes including attribution and newline"));
|
|
56
|
+
return issues.some(issue => issue.severity === "error") ? { issues } : { line, issues };
|
|
57
|
+
}
|
package/src/log-types.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { VaultSpec, VmemConfig } from "./config.ts";
|
|
2
|
+
import type { WriteIssue } from "./write-types.ts";
|
|
3
|
+
export interface LogEvent { type: string; project: string; source: string; dest?: string; note: string }
|
|
4
|
+
export interface LogRequest { vault: string; event: LogEvent; channel?: "cli" | "mcp" }
|
|
5
|
+
export interface LogContext { vault: VaultSpec; config: VmemConfig; channel?: "cli" | "mcp"; env?: NodeJS.ProcessEnv; now?: Date; projectIds?: ReadonlySet<string> }
|
|
6
|
+
export interface LogReceipt { ok: boolean; appended: boolean | null; synced: boolean; bytesWritten: number | null; issues: WriteIssue[]; rolledBack?: boolean }
|
|
7
|
+
export interface LogReadOptions { vault: string; limit?: number; grep?: string; type?: string }
|
|
8
|
+
export interface LogReadResult { ok: boolean; lines: string[]; dataLines: number | null; warnings: string[]; issues: WriteIssue[] }
|
|
9
|
+
|
|
10
|
+
export interface LogBinding { vault: VaultSpec; denyPaths: string[]; piiPatterns: string[]; dev: number; ino: number }
|