@aisystemresources/emdee 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/README.md +85 -0
- package/bin/emdee.js +196 -0
- package/package.json +78 -0
- package/src/cli/read-commands.ts +64 -0
- package/src/core/indexer.ts +346 -0
- package/src/core/parseEdges.ts +106 -0
- package/src/core/resolveLink.ts +136 -0
- package/src/core/siblings.ts +65 -0
- package/src/core/syncDocEdges.ts +427 -0
- package/src/lib/cache/bust.ts +44 -0
- package/src/lib/cache/invalidation.ts +28 -0
- package/src/lib/mcp/activity.ts +254 -0
- package/src/lib/mcp/tools/add_association.ts +311 -0
- package/src/lib/mcp/tools/append_doc.ts +70 -0
- package/src/lib/mcp/tools/append_section.ts +121 -0
- package/src/lib/mcp/tools/create_child.ts +319 -0
- package/src/lib/mcp/tools/delete_doc.ts +68 -0
- package/src/lib/mcp/tools/distill_doc.ts +262 -0
- package/src/lib/mcp/tools/filename.ts +63 -0
- package/src/lib/mcp/tools/get_context.ts +227 -0
- package/src/lib/mcp/tools/get_doc.ts +91 -0
- package/src/lib/mcp/tools/get_image.ts +62 -0
- package/src/lib/mcp/tools/get_neighbors.ts +96 -0
- package/src/lib/mcp/tools/get_summary.ts +18 -0
- package/src/lib/mcp/tools/index.ts +26 -0
- package/src/lib/mcp/tools/lint.ts +552 -0
- package/src/lib/mcp/tools/lint_doc.ts +76 -0
- package/src/lib/mcp/tools/lint_gate.ts +49 -0
- package/src/lib/mcp/tools/list_docs.ts +18 -0
- package/src/lib/mcp/tools/list_summary_drift.ts +95 -0
- package/src/lib/mcp/tools/materialize_subgroup.ts +274 -0
- package/src/lib/mcp/tools/move_doc.ts +439 -0
- package/src/lib/mcp/tools/patch_preamble.ts +145 -0
- package/src/lib/mcp/tools/patch_section.ts +113 -0
- package/src/lib/mcp/tools/read_doc_section.ts +70 -0
- package/src/lib/mcp/tools/rename_doc.ts +167 -0
- package/src/lib/mcp/tools/restore_doc.ts +41 -0
- package/src/lib/mcp/tools/search.ts +36 -0
- package/src/lib/mcp/tools/sections.ts +130 -0
- package/src/lib/mcp/tools/split_doc.ts +129 -0
- package/src/lib/mcp/tools/trash_doc.ts +116 -0
- package/src/lib/mcp/tools/types.ts +7 -0
- package/src/lib/mcp/tools/upload_image.ts +77 -0
- package/src/lib/mcp/tools/validate_args.ts +36 -0
- package/src/lib/mcp/tools/vault.ts +430 -0
- package/src/lib/mcp/tools/write_doc.ts +81 -0
- package/src/lib/mcp/tools/write_doc_preview.ts +59 -0
- package/src/lib/owner/identity.ts +65 -0
- package/src/lib/storage/FilesystemStorage.ts +88 -0
- package/src/lib/storage/SupabaseStorage.ts +358 -0
- package/src/lib/storage/VaultStorage.ts +35 -0
- package/src/lib/storage/index.ts +41 -0
- package/src/lib/supabase/admin.ts +16 -0
- package/src/lib/supabase/client.ts +8 -0
- package/src/lib/supabase/oauth.ts +296 -0
- package/src/lib/supabase/server.ts +22 -0
- package/src/lib/system-nodes.ts +68 -0
- package/src/lib/trash/state.ts +88 -0
- package/src/mcp/server.ts +380 -0
- package/templates/types/CONCEPT.md +21 -0
- package/templates/types/HACKATHON.md +28 -0
- package/templates/types/NOVEL/CHARACTERS.md +29 -0
- package/templates/types/NOVEL/DRAFT.md +15 -0
- package/templates/types/NOVEL/EDITS.md +19 -0
- package/templates/types/NOVEL/INBOX.md +15 -0
- package/templates/types/NOVEL/INSTRUCTIONS.md +32 -0
- package/templates/types/NOVEL/LEARNINGS.md +9 -0
- package/templates/types/NOVEL/OUTBOX.md +15 -0
- package/templates/types/NOVEL/PLOT.md +27 -0
- package/templates/types/NOVEL/WORLDBUILDING.md +23 -0
- package/templates/types/NOVEL.md +32 -0
- package/templates/types/PERSON.md +27 -0
- package/templates/types/PROJECT/BRAND.md +23 -0
- package/templates/types/PROJECT/BUILD.md +9 -0
- package/templates/types/PROJECT/IDEAS.md +11 -0
- package/templates/types/PROJECT/INBOX.md +15 -0
- package/templates/types/PROJECT/INSTRUCTIONS.md +23 -0
- package/templates/types/PROJECT/LEARNINGS.md +9 -0
- package/templates/types/PROJECT/LOGS.md +9 -0
- package/templates/types/PROJECT/OUTBOX.md +15 -0
- package/templates/types/PROJECT/SPRINT.md +56 -0
- package/templates/types/PROJECT.md +26 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { adminClient } from "../supabase/admin";
|
|
2
|
+
|
|
3
|
+
export type ActionKind =
|
|
4
|
+
| "read"
|
|
5
|
+
| "write"
|
|
6
|
+
| "delete"
|
|
7
|
+
| "rename"
|
|
8
|
+
| "search"
|
|
9
|
+
| "lint"
|
|
10
|
+
| "other";
|
|
11
|
+
|
|
12
|
+
export interface ActivityRouting {
|
|
13
|
+
/** Tool name as exposed by the MCP server. */
|
|
14
|
+
tool_name: string;
|
|
15
|
+
/** Classification for the visual pulse colour mapping. */
|
|
16
|
+
action_kind: ActionKind;
|
|
17
|
+
/** Extract the focal doc path from the tool's args. Return null for
|
|
18
|
+
* tools without a focal (search, list_docs). */
|
|
19
|
+
doc_path: (args: unknown) => string | null;
|
|
20
|
+
/** Optional: extract supplementary info to store in args_summary jsonb. */
|
|
21
|
+
args_summary?: (args: unknown) => Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function asRecord(args: unknown): Record<string, unknown> {
|
|
25
|
+
return args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function strField(args: unknown, key: string): string | null {
|
|
29
|
+
const v = asRecord(args)[key];
|
|
30
|
+
return typeof v === "string" ? v : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const ACTIVITY_ROUTING: Record<string, ActivityRouting> = {
|
|
34
|
+
// ── Reads ───────────────────────────────────────────────────────────────
|
|
35
|
+
get_doc: {
|
|
36
|
+
tool_name: "get_doc",
|
|
37
|
+
action_kind: "read",
|
|
38
|
+
doc_path: (a) => strField(a, "path"),
|
|
39
|
+
},
|
|
40
|
+
get_summary: {
|
|
41
|
+
tool_name: "get_summary",
|
|
42
|
+
action_kind: "read",
|
|
43
|
+
doc_path: (a) => strField(a, "path"),
|
|
44
|
+
},
|
|
45
|
+
get_neighbors: {
|
|
46
|
+
tool_name: "get_neighbors",
|
|
47
|
+
action_kind: "read",
|
|
48
|
+
doc_path: (a) => strField(a, "path"),
|
|
49
|
+
},
|
|
50
|
+
get_context: {
|
|
51
|
+
tool_name: "get_context",
|
|
52
|
+
action_kind: "read",
|
|
53
|
+
doc_path: (a) => strField(a, "path"),
|
|
54
|
+
args_summary: (a) => {
|
|
55
|
+
const r = asRecord(a);
|
|
56
|
+
const out: Record<string, unknown> = {};
|
|
57
|
+
if (typeof r.hops === "number") out.hops = r.hops;
|
|
58
|
+
if (typeof r.budget_tokens === "number") out.budget_tokens = r.budget_tokens;
|
|
59
|
+
return out;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
read_doc_section: {
|
|
63
|
+
tool_name: "read_doc_section",
|
|
64
|
+
action_kind: "read",
|
|
65
|
+
doc_path: (a) => strField(a, "path"),
|
|
66
|
+
args_summary: (a) => {
|
|
67
|
+
const r = asRecord(a);
|
|
68
|
+
const out: Record<string, unknown> = {};
|
|
69
|
+
if (typeof r.heading === "string") out.heading = r.heading;
|
|
70
|
+
if (typeof r.section_id === "string") out.section_id = r.section_id;
|
|
71
|
+
return out;
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
// ── Searches ────────────────────────────────────────────────────────────
|
|
75
|
+
list_docs: {
|
|
76
|
+
tool_name: "list_docs",
|
|
77
|
+
action_kind: "search",
|
|
78
|
+
doc_path: () => null,
|
|
79
|
+
},
|
|
80
|
+
search: {
|
|
81
|
+
tool_name: "search",
|
|
82
|
+
action_kind: "search",
|
|
83
|
+
doc_path: () => null,
|
|
84
|
+
args_summary: (a) => {
|
|
85
|
+
const r = asRecord(a);
|
|
86
|
+
const out: Record<string, unknown> = {};
|
|
87
|
+
if (typeof r.query === "string") out.query = r.query;
|
|
88
|
+
if (typeof r.limit === "number") out.limit = r.limit;
|
|
89
|
+
return out;
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
// ── Writes ──────────────────────────────────────────────────────────────
|
|
93
|
+
write_doc: {
|
|
94
|
+
tool_name: "write_doc",
|
|
95
|
+
action_kind: "write",
|
|
96
|
+
doc_path: (a) => strField(a, "path"),
|
|
97
|
+
},
|
|
98
|
+
// Preview is non-destructive — treat as a read.
|
|
99
|
+
write_doc_preview: {
|
|
100
|
+
tool_name: "write_doc_preview",
|
|
101
|
+
action_kind: "read",
|
|
102
|
+
doc_path: (a) => strField(a, "path"),
|
|
103
|
+
},
|
|
104
|
+
append_doc: {
|
|
105
|
+
tool_name: "append_doc",
|
|
106
|
+
action_kind: "write",
|
|
107
|
+
doc_path: (a) => strField(a, "path"),
|
|
108
|
+
},
|
|
109
|
+
append_section: {
|
|
110
|
+
tool_name: "append_section",
|
|
111
|
+
action_kind: "write",
|
|
112
|
+
doc_path: (a) => strField(a, "path"),
|
|
113
|
+
args_summary: (a) => {
|
|
114
|
+
const r = asRecord(a);
|
|
115
|
+
return typeof r.heading === "string" ? { heading: r.heading } : {};
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
patch_section: {
|
|
119
|
+
tool_name: "patch_section",
|
|
120
|
+
action_kind: "write",
|
|
121
|
+
doc_path: (a) => strField(a, "path"),
|
|
122
|
+
args_summary: (a) => {
|
|
123
|
+
const r = asRecord(a);
|
|
124
|
+
return typeof r.heading === "string" ? { heading: r.heading } : {};
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
patch_preamble: {
|
|
128
|
+
tool_name: "patch_preamble",
|
|
129
|
+
action_kind: "write",
|
|
130
|
+
doc_path: (a) => strField(a, "path"),
|
|
131
|
+
},
|
|
132
|
+
// ── Deletes / Renames ───────────────────────────────────────────────────
|
|
133
|
+
delete_doc: {
|
|
134
|
+
tool_name: "delete_doc",
|
|
135
|
+
action_kind: "delete",
|
|
136
|
+
doc_path: (a) => strField(a, "path"),
|
|
137
|
+
},
|
|
138
|
+
rename_doc: {
|
|
139
|
+
tool_name: "rename_doc",
|
|
140
|
+
action_kind: "rename",
|
|
141
|
+
doc_path: (a) => strField(a, "new_path") ?? strField(a, "old_path"),
|
|
142
|
+
args_summary: (a) => {
|
|
143
|
+
const r = asRecord(a);
|
|
144
|
+
return typeof r.old_path === "string" ? { old_path: r.old_path } : {};
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
// ── Compound atomic writes ──────────────────────────────────────────────
|
|
148
|
+
split_doc: {
|
|
149
|
+
tool_name: "split_doc",
|
|
150
|
+
action_kind: "write",
|
|
151
|
+
doc_path: (a) => strField(a, "source_path"),
|
|
152
|
+
args_summary: (a) => {
|
|
153
|
+
const r = asRecord(a);
|
|
154
|
+
const extracts = Array.isArray(r.extracts) ? r.extracts.length : undefined;
|
|
155
|
+
return extracts !== undefined ? { extracts } : {};
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
distill_doc: {
|
|
159
|
+
tool_name: "distill_doc",
|
|
160
|
+
action_kind: "read",
|
|
161
|
+
doc_path: (a) => strField(a, "path"),
|
|
162
|
+
},
|
|
163
|
+
materialize_subgroup: {
|
|
164
|
+
tool_name: "materialize_subgroup",
|
|
165
|
+
action_kind: "write",
|
|
166
|
+
doc_path: (a) => strField(a, "source_path"),
|
|
167
|
+
args_summary: (a) => {
|
|
168
|
+
const r = asRecord(a);
|
|
169
|
+
return typeof r.subgroup_heading === "string"
|
|
170
|
+
? { subgroup_heading: r.subgroup_heading }
|
|
171
|
+
: {};
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
// ── Lint / quality ──────────────────────────────────────────────────────
|
|
175
|
+
lint_doc: {
|
|
176
|
+
tool_name: "lint_doc",
|
|
177
|
+
action_kind: "lint",
|
|
178
|
+
doc_path: (a) => strField(a, "path"),
|
|
179
|
+
},
|
|
180
|
+
// ── Compound atomic writes (SPRINT-019) ─────────────────────────────────
|
|
181
|
+
create_child: {
|
|
182
|
+
tool_name: "create_child",
|
|
183
|
+
action_kind: "write",
|
|
184
|
+
doc_path: (a) => strField(a, "parent_path"),
|
|
185
|
+
args_summary: (a) => {
|
|
186
|
+
const r = asRecord(a);
|
|
187
|
+
const out: Record<string, unknown> = {};
|
|
188
|
+
if (typeof r.title === "string") out.title = r.title;
|
|
189
|
+
if (typeof r.child_path === "string") out.child_path = r.child_path;
|
|
190
|
+
return out;
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
add_association: {
|
|
194
|
+
tool_name: "add_association",
|
|
195
|
+
action_kind: "write",
|
|
196
|
+
doc_path: (a) => strField(a, "a_path"),
|
|
197
|
+
args_summary: (a) => {
|
|
198
|
+
const r = asRecord(a);
|
|
199
|
+
return typeof r.b_path === "string" ? { b_path: r.b_path } : {};
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Fire-and-forget insert. Logging failure must never block the tool call,
|
|
206
|
+
* so we swallow errors with a console.warn. If the path resolves to a
|
|
207
|
+
* shared doc (__shared__/<owner>/...), the row is logged against the
|
|
208
|
+
* OWNER'S namespace so the owner sees the pulse — the caller's clerk_id
|
|
209
|
+
* is still recorded so they can tell self-vs-other.
|
|
210
|
+
*
|
|
211
|
+
* Caller must guard on ctx.mode === "cloud" before invoking — local-dev
|
|
212
|
+
* stdio sessions have no clerk_id and aren't worth logging.
|
|
213
|
+
*/
|
|
214
|
+
export async function logMcpActivity(
|
|
215
|
+
callerNamespace: string,
|
|
216
|
+
clerkId: string,
|
|
217
|
+
toolName: string,
|
|
218
|
+
args: unknown,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
const routing = ACTIVITY_ROUTING[toolName];
|
|
221
|
+
if (!routing) return; // unknown tool — don't log
|
|
222
|
+
|
|
223
|
+
const rawPath = routing.doc_path(args);
|
|
224
|
+
// Resolve shared-doc owner namespace.
|
|
225
|
+
let targetNs = callerNamespace;
|
|
226
|
+
let docPath = rawPath;
|
|
227
|
+
if (rawPath && rawPath.startsWith("__shared__/")) {
|
|
228
|
+
const parts = rawPath.split("/");
|
|
229
|
+
if (parts.length >= 3) {
|
|
230
|
+
targetNs = parts[1]; // <owner_id>
|
|
231
|
+
docPath = parts.slice(2).join("/");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let argsSummary: Record<string, unknown> | undefined;
|
|
236
|
+
if (routing.args_summary) {
|
|
237
|
+
const s = routing.args_summary(args);
|
|
238
|
+
if (s && Object.keys(s).length > 0) argsSummary = s;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const { error } = await adminClient().from("mcp_activity").insert({
|
|
243
|
+
namespace: targetNs,
|
|
244
|
+
clerk_id: clerkId,
|
|
245
|
+
tool_name: routing.tool_name,
|
|
246
|
+
doc_path: docPath,
|
|
247
|
+
action_kind: routing.action_kind,
|
|
248
|
+
args_summary: argsSummary,
|
|
249
|
+
});
|
|
250
|
+
if (error) console.warn("[mcp_activity] insert failed:", error.message);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.warn("[mcp_activity] insert threw:", (e as Error).message);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
|
|
4
|
+
import { lintDocContent } from "./lint";
|
|
5
|
+
import { buildLintVaultContext } from "./lint_doc";
|
|
6
|
+
import { evaluateLintGate, type LintFix } from "./lint_gate";
|
|
7
|
+
import type { LintWarning, LintVaultContext } from "./lint";
|
|
8
|
+
import { resolveWikiLink } from "../../../core/resolveLink";
|
|
9
|
+
import type { ToolContext } from "./types";
|
|
10
|
+
import { validateArgs } from "./validate_args";
|
|
11
|
+
|
|
12
|
+
const ARG_SPEC = {
|
|
13
|
+
allowed: ["a_path", "b_path", "label", "gate_on_warnings"],
|
|
14
|
+
required: ["a_path", "b_path"],
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
const H1_RE = /^#\s+(.+?)\s*$/m;
|
|
18
|
+
const H2_RE = /^##\s+(.+?)\s*$/;
|
|
19
|
+
const FENCE_RE = /^\s*(?:```|~~~)/;
|
|
20
|
+
|
|
21
|
+
function deriveTitle(content: string, fallbackPath: string): string {
|
|
22
|
+
const m = content.match(H1_RE);
|
|
23
|
+
if (m) return m[1].trim();
|
|
24
|
+
return path.basename(fallbackPath, ".md");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function hashBody(body: string): string {
|
|
28
|
+
return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SectionLoc {
|
|
32
|
+
heading: string;
|
|
33
|
+
headingLineIdx: number;
|
|
34
|
+
bodyStartLineIdx: number;
|
|
35
|
+
bodyEndLineIdx: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseSections(content: string): SectionLoc[] {
|
|
39
|
+
const lines = content.split("\n");
|
|
40
|
+
const sections: SectionLoc[] = [];
|
|
41
|
+
let inFence = false;
|
|
42
|
+
for (let i = 0; i < lines.length; i++) {
|
|
43
|
+
if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
|
|
44
|
+
if (inFence) continue;
|
|
45
|
+
const m = lines[i].match(H2_RE);
|
|
46
|
+
if (!m) continue;
|
|
47
|
+
if (sections.length > 0) sections[sections.length - 1].bodyEndLineIdx = i;
|
|
48
|
+
sections.push({ heading: m[1].trim(), headingLineIdx: i, bodyStartLineIdx: i + 1, bodyEndLineIdx: lines.length });
|
|
49
|
+
}
|
|
50
|
+
return sections;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function findSection(sections: SectionLoc[], heading: string): SectionLoc | undefined {
|
|
54
|
+
const target = heading.trim().toLowerCase();
|
|
55
|
+
return sections.find((s) => s.heading.toLowerCase() === target);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractBody(content: string, loc: SectionLoc): string {
|
|
59
|
+
return content.split("\n")
|
|
60
|
+
.slice(loc.bodyStartLineIdx, loc.bodyEndLineIdx)
|
|
61
|
+
.join("\n")
|
|
62
|
+
.replace(/^\s*\n+/, "")
|
|
63
|
+
.replace(/\n+\s*$/, "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Insert `* [[<otherTitle>]] — <label>` into the doc's `## Associated
|
|
68
|
+
* with` section. Creates the section if missing. Returns { newContent,
|
|
69
|
+
* alreadyPresent } so the caller can short-circuit on idempotent retry.
|
|
70
|
+
*/
|
|
71
|
+
function patchAssociatedWith(content: string, otherTitle: string, label?: string): {
|
|
72
|
+
newContent: string;
|
|
73
|
+
alreadyPresent: boolean;
|
|
74
|
+
assocBody: string;
|
|
75
|
+
} {
|
|
76
|
+
const lines = content.split("\n");
|
|
77
|
+
const sections = parseSections(content);
|
|
78
|
+
const assoc = findSection(sections, "Associated with");
|
|
79
|
+
const bullet = label ? `* [[${otherTitle}]] — ${label}` : `* [[${otherTitle}]]`;
|
|
80
|
+
|
|
81
|
+
if (!assoc) {
|
|
82
|
+
const sep = content.endsWith("\n") ? "" : "\n";
|
|
83
|
+
const newContent = content + sep + `\n## Associated with\n\n${bullet}\n`;
|
|
84
|
+
return { newContent, alreadyPresent: false, assocBody: bullet };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Idempotency: existing bullet with this target (alias-tolerant).
|
|
88
|
+
const sectionLines = lines.slice(assoc.headingLineIdx, assoc.bodyEndLineIdx);
|
|
89
|
+
const escaped = otherTitle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
90
|
+
const existsRe = new RegExp(`^\\s*[-*]\\s+\\[\\[${escaped}(\\|[^\\]]+)?\\]\\]`, "i");
|
|
91
|
+
if (sectionLines.some((l) => existsRe.test(l))) {
|
|
92
|
+
return {
|
|
93
|
+
newContent: content,
|
|
94
|
+
alreadyPresent: true,
|
|
95
|
+
assocBody: extractBody(content, assoc),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const newSectionLines = [...sectionLines];
|
|
100
|
+
while (newSectionLines.length > 1 && newSectionLines[newSectionLines.length - 1].trim() === "") newSectionLines.pop();
|
|
101
|
+
newSectionLines.push("", bullet, "");
|
|
102
|
+
const newContent = [
|
|
103
|
+
...lines.slice(0, assoc.headingLineIdx),
|
|
104
|
+
...newSectionLines,
|
|
105
|
+
...lines.slice(assoc.bodyEndLineIdx),
|
|
106
|
+
].join("\n");
|
|
107
|
+
const newSections = parseSections(newContent);
|
|
108
|
+
const newAssoc = findSection(newSections, "Associated with");
|
|
109
|
+
return {
|
|
110
|
+
newContent,
|
|
111
|
+
alreadyPresent: false,
|
|
112
|
+
assocBody: newAssoc ? extractBody(newContent, newAssoc) : "",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function fixesFromWarnings(warnings: LintWarning[], codes: string[]): LintFix[] {
|
|
117
|
+
const codeSet = new Set(codes);
|
|
118
|
+
return warnings.filter((w) => codeSet.has(w.code)).map((w) => ({
|
|
119
|
+
code: w.code,
|
|
120
|
+
line: w.line ?? null,
|
|
121
|
+
fix_suggestion: w.suggestion,
|
|
122
|
+
original_message: w.message,
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function json(value: unknown) {
|
|
127
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Atomic two-sided assoc. Patches both docs' `## Associated with` to
|
|
132
|
+
* mention the other (with optional shared label, same on both bullets).
|
|
133
|
+
*
|
|
134
|
+
* Hard-gates internally on `associate_duplicates_hierarchy` and
|
|
135
|
+
* `sibling_assoc_redundant` for both sides — these are the exact codes
|
|
136
|
+
* this tool exists to prevent. A pair already linked via Child of /
|
|
137
|
+
* Parent of, or sharing a parent (i.e. siblings), gets refused with a
|
|
138
|
+
* structured `would_duplicate_hierarchy` error.
|
|
139
|
+
*
|
|
140
|
+
* Idempotent: if both docs already declare the other in `## Associated
|
|
141
|
+
* with`, returns ok with `a_updated: false, b_updated: false` and
|
|
142
|
+
* doesn't touch either file.
|
|
143
|
+
*
|
|
144
|
+
* Label drift caveat: if the first call passes label X and the second
|
|
145
|
+
* passes label Y, the second is a no-op (idempotency check fires on
|
|
146
|
+
* presence-of-bullet alone). Use a future `update_association_label` for
|
|
147
|
+
* label edits.
|
|
148
|
+
*/
|
|
149
|
+
export async function addAssociation(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
|
|
150
|
+
const argErr = validateArgs(args, ARG_SPEC);
|
|
151
|
+
if (argErr) return json(argErr);
|
|
152
|
+
const aPath = String(args.a_path ?? "");
|
|
153
|
+
const bPath = String(args.b_path ?? "");
|
|
154
|
+
const label = args.label !== undefined ? String(args.label).trim() : "";
|
|
155
|
+
const gateCodes = Array.isArray(args.gate_on_warnings)
|
|
156
|
+
? (args.gate_on_warnings as unknown[]).filter((c): c is string => typeof c === "string")
|
|
157
|
+
: [];
|
|
158
|
+
|
|
159
|
+
if (!aPath) return json({ error: "a_path required" });
|
|
160
|
+
if (!bPath) return json({ error: "b_path required" });
|
|
161
|
+
if (aPath === bPath) return json({ error: "self_association", path: aPath });
|
|
162
|
+
validatePath(aPath);
|
|
163
|
+
validatePath(bPath);
|
|
164
|
+
|
|
165
|
+
const aContent = await readVaultFile(ctx, aPath);
|
|
166
|
+
if (aContent === null) return json({ error: "a_not_found", path: aPath });
|
|
167
|
+
const bContent = await readVaultFile(ctx, bPath);
|
|
168
|
+
if (bContent === null) return json({ error: "b_not_found", path: bPath });
|
|
169
|
+
|
|
170
|
+
const aTitle = deriveTitle(aContent, aPath);
|
|
171
|
+
const bTitle = deriveTitle(bContent, bPath);
|
|
172
|
+
|
|
173
|
+
const index = await loadVaultIndex(ctx);
|
|
174
|
+
const aCtx: LintVaultContext = buildLintVaultContext(index, aPath);
|
|
175
|
+
const bCtx: LintVaultContext = buildLintVaultContext(index, bPath);
|
|
176
|
+
|
|
177
|
+
// Build proposed contents (insert bullet on each side).
|
|
178
|
+
const aPatch = patchAssociatedWith(aContent, bTitle, label || undefined);
|
|
179
|
+
const bPatch = patchAssociatedWith(bContent, aTitle, label || undefined);
|
|
180
|
+
|
|
181
|
+
// Idempotent short-circuit: if both already present, nothing to do.
|
|
182
|
+
if (aPatch.alreadyPresent && bPatch.alreadyPresent) {
|
|
183
|
+
return json({
|
|
184
|
+
ok: true,
|
|
185
|
+
a_path: aPath,
|
|
186
|
+
b_path: bPath,
|
|
187
|
+
label: label || undefined,
|
|
188
|
+
a_updated: false,
|
|
189
|
+
b_updated: false,
|
|
190
|
+
a_associated_with_hash: hashBody(aPatch.assocBody),
|
|
191
|
+
b_associated_with_hash: hashBody(bPatch.assocBody),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Hard-gate: lint each proposed side. If associate_duplicates_hierarchy
|
|
196
|
+
// or sibling_assoc_redundant fires for the assoc target we just added,
|
|
197
|
+
// refuse with a structured error.
|
|
198
|
+
const HARD_CODES = ["associate_duplicates_hierarchy", "sibling_assoc_redundant"] as const;
|
|
199
|
+
const checkSide = (
|
|
200
|
+
side: "a" | "b",
|
|
201
|
+
proposed: string,
|
|
202
|
+
selfCtx: LintVaultContext,
|
|
203
|
+
targetTitle: string,
|
|
204
|
+
targetPath: string,
|
|
205
|
+
): { error: "would_duplicate_hierarchy"; side: "a" | "b"; reason: string; existing_edge: { from: string; to: string; kind: string; shared_parent_path?: string }; fix_suggestion: string } | null => {
|
|
206
|
+
const { warnings } = lintDocContent(proposed, selfCtx);
|
|
207
|
+
const hit = warnings.find((w) =>
|
|
208
|
+
HARD_CODES.includes(w.code as typeof HARD_CODES[number]) &&
|
|
209
|
+
w.title && w.title.toLowerCase() === targetTitle.toLowerCase()
|
|
210
|
+
);
|
|
211
|
+
if (!hit) return null;
|
|
212
|
+
if (hit.code === "associate_duplicates_hierarchy") {
|
|
213
|
+
// Determine which direction (parent_of / child_of) from the linking doc's perspective.
|
|
214
|
+
const selfDoc = index.docs.find((d) => d.path === selfCtx.selfPath);
|
|
215
|
+
const isParent = selfDoc?.children.some((l) => resolveWikiLink(index, l.title, selfCtx.selfPath)?.path === targetPath);
|
|
216
|
+
const isChild = selfDoc?.parents.some((l) => resolveWikiLink(index, l.title, selfCtx.selfPath)?.path === targetPath);
|
|
217
|
+
return {
|
|
218
|
+
error: "would_duplicate_hierarchy",
|
|
219
|
+
side,
|
|
220
|
+
reason: "associate_duplicates_hierarchy",
|
|
221
|
+
existing_edge: {
|
|
222
|
+
from: selfCtx.selfPath,
|
|
223
|
+
to: targetPath,
|
|
224
|
+
kind: isParent ? "parent_of" : isChild ? "child_of" : "hierarchy",
|
|
225
|
+
},
|
|
226
|
+
fix_suggestion: hit.suggestion,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// sibling_assoc_redundant: pull the shared parent from the warning message format.
|
|
230
|
+
const targetInfo = selfCtx.resolveTarget(targetTitle);
|
|
231
|
+
const sharedParent = targetInfo?.declaredParents.find((p) => selfCtx.selfDeclaredParents.includes(p));
|
|
232
|
+
return {
|
|
233
|
+
error: "would_duplicate_hierarchy",
|
|
234
|
+
side,
|
|
235
|
+
reason: "sibling_assoc_redundant",
|
|
236
|
+
existing_edge: {
|
|
237
|
+
from: selfCtx.selfPath,
|
|
238
|
+
to: targetPath,
|
|
239
|
+
kind: "shared_parent",
|
|
240
|
+
...(sharedParent ? { shared_parent_path: sharedParent } : {}),
|
|
241
|
+
},
|
|
242
|
+
fix_suggestion: hit.suggestion,
|
|
243
|
+
};
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
if (!aPatch.alreadyPresent) {
|
|
247
|
+
const aHardFail = checkSide("a", aPatch.newContent, aCtx, bTitle, bPath);
|
|
248
|
+
if (aHardFail) return json(aHardFail);
|
|
249
|
+
}
|
|
250
|
+
if (!bPatch.alreadyPresent) {
|
|
251
|
+
const bHardFail = checkSide("b", bPatch.newContent, bCtx, aTitle, aPath);
|
|
252
|
+
if (bHardFail) return json(bHardFail);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Soft gate: caller-provided codes.
|
|
256
|
+
if (gateCodes.length > 0) {
|
|
257
|
+
if (!aPatch.alreadyPresent) {
|
|
258
|
+
const aGate = evaluateLintGate(aPatch.newContent, gateCodes, aCtx);
|
|
259
|
+
if (!aGate.ok) return json({ error: "lint_gate_failed", side: "a", fixes: aGate.fixes, original_warnings: aGate.original_warnings });
|
|
260
|
+
}
|
|
261
|
+
if (!bPatch.alreadyPresent) {
|
|
262
|
+
const bGate = evaluateLintGate(bPatch.newContent, gateCodes, bCtx);
|
|
263
|
+
if (!bGate.ok) return json({ error: "lint_gate_failed", side: "b", fixes: bGate.fixes, original_warnings: bGate.original_warnings });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Write A then B. Partial failure path documents the asymmetry; retry
|
|
268
|
+
// is idempotent because each side's existing-bullet check fires on the
|
|
269
|
+
// re-run.
|
|
270
|
+
let aWritten = aPatch.alreadyPresent;
|
|
271
|
+
if (!aPatch.alreadyPresent) {
|
|
272
|
+
try {
|
|
273
|
+
await writeVaultFile(ctx, aPath, aPatch.newContent);
|
|
274
|
+
aWritten = true;
|
|
275
|
+
} catch (err) {
|
|
276
|
+
return json({
|
|
277
|
+
error: "partial_write",
|
|
278
|
+
a_written: false,
|
|
279
|
+
b_written: false,
|
|
280
|
+
message: (err as Error).message,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let bWritten = bPatch.alreadyPresent;
|
|
286
|
+
if (!bPatch.alreadyPresent) {
|
|
287
|
+
try {
|
|
288
|
+
await writeVaultFile(ctx, bPath, bPatch.newContent);
|
|
289
|
+
bWritten = true;
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return json({
|
|
292
|
+
error: "partial_write",
|
|
293
|
+
a_written: aWritten,
|
|
294
|
+
b_written: false,
|
|
295
|
+
message: (err as Error).message,
|
|
296
|
+
retry_hint: "Re-run add_association with the same args; the A-side write is idempotent (already-declared bullets are detected and skipped).",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return json({
|
|
302
|
+
ok: true,
|
|
303
|
+
a_path: aPath,
|
|
304
|
+
b_path: bPath,
|
|
305
|
+
label: label || undefined,
|
|
306
|
+
a_updated: !aPatch.alreadyPresent,
|
|
307
|
+
b_updated: !bPatch.alreadyPresent,
|
|
308
|
+
a_associated_with_hash: hashBody(aPatch.assocBody),
|
|
309
|
+
b_associated_with_hash: hashBody(bPatch.assocBody),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { validatePath, readVaultFile, writeVaultFile, loadVaultIndex } from "./vault";
|
|
3
|
+
import { evaluateLintGate } from "./lint_gate";
|
|
4
|
+
import { buildLintVaultContext } from "./lint_doc";
|
|
5
|
+
import type { ToolContext } from "./types";
|
|
6
|
+
import { validateArgs } from "./validate_args";
|
|
7
|
+
|
|
8
|
+
const CROSS_DOC_CODES = new Set([
|
|
9
|
+
"asymmetric_parent_edge",
|
|
10
|
+
"asymmetric_child_edge",
|
|
11
|
+
"sibling_assoc_redundant",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const ARG_SPEC = {
|
|
15
|
+
allowed: ["path", "body", "gate_on_warnings"],
|
|
16
|
+
required: ["path", "body"],
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
function parseGateCodes(raw: unknown): string[] {
|
|
20
|
+
if (!Array.isArray(raw)) return [];
|
|
21
|
+
return raw.filter((c): c is string => typeof c === "string");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function json(value: unknown) {
|
|
25
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hashBody(body: string): string {
|
|
29
|
+
return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Append content to the very end of a doc — after every existing section.
|
|
34
|
+
*
|
|
35
|
+
* Separate from `append_section`, which lands inside a named section's body
|
|
36
|
+
* (which is *mid-doc* whenever that section isn't last). For chronological
|
|
37
|
+
* note-taking — LOGS entries, daily notes, anywhere new content should land
|
|
38
|
+
* at the bottom of the page regardless of section structure — this is the
|
|
39
|
+
* right primitive. The body may include its own `##` headings to introduce
|
|
40
|
+
* new sections at the end.
|
|
41
|
+
*/
|
|
42
|
+
export async function appendDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
|
|
43
|
+
const argErr = validateArgs(args, ARG_SPEC);
|
|
44
|
+
if (argErr) return json(argErr);
|
|
45
|
+
const rel = String(args.path);
|
|
46
|
+
validatePath(rel);
|
|
47
|
+
const body = String(args.body ?? "");
|
|
48
|
+
if (!body.trim()) throw new Error("body required");
|
|
49
|
+
|
|
50
|
+
const content = await readVaultFile(ctx, rel);
|
|
51
|
+
if (content === null) return json({ error: "doc_not_found", path: rel });
|
|
52
|
+
|
|
53
|
+
// Normalise trailing whitespace so the new content starts on its own
|
|
54
|
+
// paragraph, regardless of whether the existing doc ended cleanly.
|
|
55
|
+
const trimmed = content.replace(/\s+$/, "");
|
|
56
|
+
const newContent = trimmed + "\n\n" + body.replace(/\s+$/, "") + "\n";
|
|
57
|
+
|
|
58
|
+
const gateCodes = parseGateCodes(args.gate_on_warnings);
|
|
59
|
+
if (gateCodes.length > 0) {
|
|
60
|
+
const needsVault = gateCodes.some((c) => CROSS_DOC_CODES.has(c));
|
|
61
|
+
const vaultCtx = needsVault ? buildLintVaultContext(await loadVaultIndex(ctx), rel) : undefined;
|
|
62
|
+
const gate = evaluateLintGate(newContent, gateCodes, vaultCtx);
|
|
63
|
+
if (!gate.ok) {
|
|
64
|
+
return json({ error: "lint_gate_failed", fixes: gate.fixes, original_warnings: gate.original_warnings });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await writeVaultFile(ctx, rel, newContent);
|
|
69
|
+
return json({ ok: true, content_hash: hashBody(body.trim()) });
|
|
70
|
+
}
|