@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,262 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { validatePath, loadVaultIndex } from "./vault";
|
|
3
|
+
import type { ToolContext } from "./types";
|
|
4
|
+
import type { DocNode } from "../../../core/indexer";
|
|
5
|
+
import { validateArgs } from "./validate_args";
|
|
6
|
+
|
|
7
|
+
const ARG_SPEC = { allowed: ["path"], required: ["path"] } as const;
|
|
8
|
+
|
|
9
|
+
function json(value: unknown) {
|
|
10
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const FENCE_RE = /^\s*(?:```|~~~)/;
|
|
14
|
+
const H2_RE = /^##\s+(.+?)\s*$/;
|
|
15
|
+
const H3_RE = /^###\s+(.+?)\s*$/;
|
|
16
|
+
const H1_RE = /^#\s+(.+?)\s*$/;
|
|
17
|
+
|
|
18
|
+
interface SectionLoc {
|
|
19
|
+
heading: string;
|
|
20
|
+
level: 2 | 3;
|
|
21
|
+
start_line: number;
|
|
22
|
+
end_line: number;
|
|
23
|
+
body_verbatim: string;
|
|
24
|
+
content_hash: string;
|
|
25
|
+
char_count: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Walk the source doc and emit a flat list of H2 + H3 sections — both are
|
|
30
|
+
* extraction boundaries for distill_doc. The body of each section runs up
|
|
31
|
+
* to the next heading of equal or shallower depth.
|
|
32
|
+
*/
|
|
33
|
+
function parseSectionsForDistill(content: string): SectionLoc[] {
|
|
34
|
+
const lines = content.split("\n");
|
|
35
|
+
const headings: Array<{ heading: string; level: 2 | 3; line: number }> = [];
|
|
36
|
+
let inFence = false;
|
|
37
|
+
for (let i = 0; i < lines.length; i++) {
|
|
38
|
+
if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
|
|
39
|
+
if (inFence) continue;
|
|
40
|
+
const h2 = lines[i].match(H2_RE);
|
|
41
|
+
if (h2) { headings.push({ heading: h2[1].trim(), level: 2, line: i }); continue; }
|
|
42
|
+
const h3 = lines[i].match(H3_RE);
|
|
43
|
+
if (h3) headings.push({ heading: h3[1].trim(), level: 3, line: i });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const out: SectionLoc[] = [];
|
|
47
|
+
for (let i = 0; i < headings.length; i++) {
|
|
48
|
+
const h = headings[i];
|
|
49
|
+
// End the body when we hit the next heading of equal-or-shallower depth.
|
|
50
|
+
let end = lines.length;
|
|
51
|
+
for (let j = i + 1; j < headings.length; j++) {
|
|
52
|
+
if (headings[j].level <= h.level) { end = headings[j].line; break; }
|
|
53
|
+
}
|
|
54
|
+
const body_verbatim = lines.slice(h.line + 1, end).join("\n").replace(/^\s*\n+/, "").replace(/\n+\s*$/, "");
|
|
55
|
+
out.push({
|
|
56
|
+
heading: h.heading,
|
|
57
|
+
level: h.level,
|
|
58
|
+
start_line: h.line,
|
|
59
|
+
end_line: end,
|
|
60
|
+
body_verbatim,
|
|
61
|
+
content_hash: hashBody(body_verbatim),
|
|
62
|
+
char_count: body_verbatim.length,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hashBody(body: string): string {
|
|
69
|
+
return createHash("sha256").update(body, "utf8").digest("hex").slice(0, 16);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Pull a named H2 section's body out of a doc. Used to extract the
|
|
74
|
+
* BRAIN/PATTERN/INFO rubric content from the live docs so the distill
|
|
75
|
+
* semantics stay in sync with whatever the canonical filters say today.
|
|
76
|
+
* Returns null when the doc lacks that section.
|
|
77
|
+
*/
|
|
78
|
+
function extractH2Section(content: string, headingLower: string): string | null {
|
|
79
|
+
const lines = content.split("\n");
|
|
80
|
+
let inFence = false;
|
|
81
|
+
let startBody = -1;
|
|
82
|
+
let endBody = lines.length;
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
|
|
85
|
+
if (inFence) continue;
|
|
86
|
+
const m = lines[i].match(H2_RE);
|
|
87
|
+
if (!m) continue;
|
|
88
|
+
const h = m[1].trim().toLowerCase();
|
|
89
|
+
if (startBody === -1 && h === headingLower) {
|
|
90
|
+
startBody = i + 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (startBody !== -1) { endBody = i; break; }
|
|
94
|
+
}
|
|
95
|
+
if (startBody === -1) return null;
|
|
96
|
+
return lines.slice(startBody, endBody).join("\n").trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Extract a doc's H1 + blockquote summary (preamble region). Used to
|
|
101
|
+
* convey the BRAIN / PROJECTS — PATTERN charter alongside the CONTEXT
|
|
102
|
+
* section — together they form the rubric.
|
|
103
|
+
*/
|
|
104
|
+
function extractPreambleSummary(content: string): string {
|
|
105
|
+
const lines = content.split("\n");
|
|
106
|
+
let inFence = false;
|
|
107
|
+
let h1Idx = -1;
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
|
|
110
|
+
if (inFence) continue;
|
|
111
|
+
if (H1_RE.test(lines[i])) { h1Idx = i; break; }
|
|
112
|
+
}
|
|
113
|
+
if (h1Idx === -1) return "";
|
|
114
|
+
|
|
115
|
+
let firstH2Idx = lines.length;
|
|
116
|
+
inFence = false;
|
|
117
|
+
for (let i = h1Idx + 1; i < lines.length; i++) {
|
|
118
|
+
if (FENCE_RE.test(lines[i])) { inFence = !inFence; continue; }
|
|
119
|
+
if (inFence) continue;
|
|
120
|
+
if (H2_RE.test(lines[i])) { firstH2Idx = i; break; }
|
|
121
|
+
}
|
|
122
|
+
return lines.slice(h1Idx, firstH2Idx).join("\n").trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findByTitle(docs: DocNode[], title: string): DocNode | undefined {
|
|
126
|
+
const target = title.toLowerCase();
|
|
127
|
+
return docs.find((d) => d.title.toLowerCase() === target);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const PLAN_INSTRUCTIONS = `You are constructing a distillation plan for a notes doc. The plan will be reviewed by a human, then executed via the \`split_doc\` MCP tool which atomically rewrites the source and creates the new extract docs.
|
|
131
|
+
|
|
132
|
+
STRICT RULES — failure to follow these breaks the trust contract with the user, who exports these notes to other people:
|
|
133
|
+
|
|
134
|
+
1. **Verbatim body.** Every extract's \`body_verbatim\` field MUST be a copy-paste of the exact bytes from \`source.sections[i].body_verbatim\`. Do NOT reword, paraphrase, polish, summarize, or "improve" the prose. Same em-dashes, same code fences, same tables, same trailing whitespace. If you can't extract a concept without rewriting it to stand alone, DO NOT extract — add it to \`flagged_for_manual_split\` instead.
|
|
135
|
+
|
|
136
|
+
2. **Title and summary are the only things you author.** The H1 title and the \`> blockquote\` summary on the new doc are the only LLM-authored strings. Mark both clearly in the plan's \`notes\` field as "review: LLM-drafted." Everything else is the user's own words.
|
|
137
|
+
|
|
138
|
+
3. **Section-level only.** Only propose extractions at H2 or H3 section boundaries (matching exactly one \`source.sections[i].heading\`). If a concept sits mid-paragraph or spans multiple sub-paragraphs without a clean heading, add it to \`flagged_for_manual_split\` with the reason.
|
|
139
|
+
|
|
140
|
+
4. **Idempotency.** For every extract, check \`vault_context.existing_titles\` (case-insensitive). If the title already exists, set \`already_exists: { path: "<existing-path>" }\` and DO NOT propose body_verbatim for that extract — the source will simply get a wiki-link stub pointing at the existing doc instead.
|
|
141
|
+
|
|
142
|
+
5. **Branch placement is a hint, not a decision.** The user makes the final call. Provide your best guess in \`suggested_child_of\` and \`suggested_associated_with\` using the rubrics from \`vault_context\`. Explain your reasoning briefly in \`notes\`.
|
|
143
|
+
|
|
144
|
+
6. **Source rewrite.** Construct \`source_rewrite_summary\` describing what the source doc will look like after extraction — typically the narrative scaffolding stays, the extracted sections become single-line wiki-link bullets. The actual rewritten content will be assembled by the executor (you or the user) when calling \`split_doc\`.
|
|
145
|
+
|
|
146
|
+
7. **Confidence scoring.** \`high\` = clean section boundary, clear concept, obvious branch fit. \`medium\` = readable but judgment call on title or branch. \`low\` = the section feels extractable but you're unsure — flag your doubt in \`notes\` for the reviewer.
|
|
147
|
+
|
|
148
|
+
8. **Don't extract everything.** Narrative content, session-specific observations, personal AHAs, and "(to be expanded)" stubs should usually stay in the source — they're not standalone concepts. Surface them in \`keep_in_source_headings\`. The goal is to extract knowledge POINTS that other docs can wiki-link to, not to evacuate the source.
|
|
149
|
+
|
|
150
|
+
Now: read \`source\` and \`vault_context\`, then produce a plan in the shape of \`plan_template.schema_hint\`.`;
|
|
151
|
+
|
|
152
|
+
const EXAMPLE_EXTRACT = {
|
|
153
|
+
title: "PAS Formula",
|
|
154
|
+
summary: "> Problem → Agitate → Solution. The emotional arc that great copy follows: name the problem, twist the knife, then ride in with the solution. Makes the prospect feel the pain before offering the relief.",
|
|
155
|
+
suggested_path: "concepts/PAS-FORMULA.md",
|
|
156
|
+
suggested_child_of: ["[[PROJECTS — PATTERN]]"],
|
|
157
|
+
suggested_associated_with: ["[[GBI_Day4]]", "[[GBI]]"],
|
|
158
|
+
source_section_heading: "#3 — How to Write the Body: The PAS Formula",
|
|
159
|
+
body_verbatim: "<copy-paste of source.sections[i].body_verbatim verbatim — same bytes, do not reword>",
|
|
160
|
+
confidence: "high",
|
|
161
|
+
notes: "review: LLM-drafted title + summary. Branch placement: PROJECTS — PATTERN since this is a cross-instance technical pattern for copywriting. Associated with both the day note (GBI_Day4) and the event itself (GBI).",
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const SCHEMA_HINT = JSON.stringify(
|
|
165
|
+
{
|
|
166
|
+
source_path: "string — same as input path",
|
|
167
|
+
source_rewrite_summary: "string — one paragraph describing the post-extraction source state",
|
|
168
|
+
extracts: [
|
|
169
|
+
{
|
|
170
|
+
title: "string",
|
|
171
|
+
summary: "string — must start with `> `",
|
|
172
|
+
suggested_path: "string — vault-relative, ends in .md",
|
|
173
|
+
suggested_child_of: ["array of `[[Title]]` wiki-link strings"],
|
|
174
|
+
suggested_associated_with: ["array of `[[Title]]` wiki-link strings"],
|
|
175
|
+
source_section_heading: "string — exact match to source.sections[i].heading",
|
|
176
|
+
body_verbatim: "string — VERBATIM from source.sections[i].body_verbatim",
|
|
177
|
+
confidence: "high | medium | low",
|
|
178
|
+
notes: "string — reasoning, flags for review",
|
|
179
|
+
"already_exists?": { path: "string — set ONLY if title already exists in vault" },
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
keep_in_source_headings: ["array of section headings to leave in the source"],
|
|
183
|
+
flagged_for_manual_split: [
|
|
184
|
+
{ section_heading: "string", reason: "string" },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
null,
|
|
188
|
+
2
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read-only intake helper for splitting a notes doc into knowledge nodes.
|
|
193
|
+
*
|
|
194
|
+
* Returns everything the calling LLM needs to construct a defensible
|
|
195
|
+
* split plan without paying for vault lookups one-tool-call-at-a-time:
|
|
196
|
+
* the source content with section boundaries marked, the existing vault
|
|
197
|
+
* title set (collision check), the BRAIN / PROJECTS — PATTERN / LEARNINGS
|
|
198
|
+
* rubrics quoted from the live canonical docs, and a plan template.
|
|
199
|
+
*
|
|
200
|
+
* Does NOT make any LLM calls server-side and does NOT write anything.
|
|
201
|
+
* The calling agent (Claude Chat, ChatGPT, Doubao — whichever) constructs
|
|
202
|
+
* the plan in its response. The user reviews. Then split_doc executes.
|
|
203
|
+
*/
|
|
204
|
+
export async function distillDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
|
|
205
|
+
const argErr = validateArgs(args, ARG_SPEC);
|
|
206
|
+
if (argErr) return json(argErr);
|
|
207
|
+
const rel = String(args.path);
|
|
208
|
+
validatePath(rel);
|
|
209
|
+
|
|
210
|
+
const index = await loadVaultIndex(ctx);
|
|
211
|
+
const source = index.docs.find((d) => d.path === rel);
|
|
212
|
+
if (!source) return json({ error: "doc_not_found", path: rel });
|
|
213
|
+
|
|
214
|
+
const sections = parseSectionsForDistill(source.content);
|
|
215
|
+
const existing_titles = index.docs.map((d) => d.title);
|
|
216
|
+
|
|
217
|
+
// Pull the rubrics from the canonical docs by title (not path) so the
|
|
218
|
+
// distillation semantics survive any future renames of those docs.
|
|
219
|
+
const brain = findByTitle(index.docs, "BRAIN");
|
|
220
|
+
const projectsPattern = findByTitle(index.docs, "PROJECTS — PATTERN");
|
|
221
|
+
|
|
222
|
+
const brain_charter = brain
|
|
223
|
+
? `${extractPreambleSummary(brain.content)}\n\n${extractH2Section(brain.content, "context") ?? ""}`.trim()
|
|
224
|
+
: "(BRAIN.md not found — falling back: BRAIN holds cross-domain personal operating principles that pass operating-principle / cross-context / actionable.)";
|
|
225
|
+
|
|
226
|
+
const projects_pattern_rules = projectsPattern
|
|
227
|
+
? `${extractPreambleSummary(projectsPattern.content)}\n\n${extractH2Section(projectsPattern.content, "context") ?? ""}`.trim()
|
|
228
|
+
: "(PROJECTS — PATTERN doc not found — falling back: cross-project technical/business patterns observed in ≥2 projects.)";
|
|
229
|
+
|
|
230
|
+
const learnings_filter = "(three-test filter: reusable, non-obvious in retrospect, has a directive.)";
|
|
231
|
+
|
|
232
|
+
// Branches that currently host a PATTERN.md — informs the calling LLM
|
|
233
|
+
// about where else extracts could go beyond PROJECTS — PATTERN.
|
|
234
|
+
const branches_with_pattern = index.docs
|
|
235
|
+
.filter((d) => /(^|\/)PATTERN\.md$/.test(d.path) && d.path !== "PATTERN.md")
|
|
236
|
+
.map((d) => ({
|
|
237
|
+
branch: d.path.replace(/\/PATTERN\.md$/, "") || "(root)",
|
|
238
|
+
pattern_path: d.path,
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
return json({
|
|
242
|
+
source: {
|
|
243
|
+
path: source.path,
|
|
244
|
+
title: source.title,
|
|
245
|
+
h1: source.title,
|
|
246
|
+
content: source.content,
|
|
247
|
+
sections,
|
|
248
|
+
},
|
|
249
|
+
vault_context: {
|
|
250
|
+
existing_titles,
|
|
251
|
+
branches_with_pattern,
|
|
252
|
+
brain_charter,
|
|
253
|
+
projects_pattern_rules,
|
|
254
|
+
learnings_filter,
|
|
255
|
+
},
|
|
256
|
+
plan_template: {
|
|
257
|
+
instructions: PLAN_INSTRUCTIONS,
|
|
258
|
+
example_extract: EXAMPLE_EXTRACT,
|
|
259
|
+
schema_hint: SCHEMA_HINT,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPRINT-055 (SIG-004): filename casing enforcement helpers.
|
|
2
|
+
//
|
|
3
|
+
// EMDEE's on-disk filenames are all-caps (e.g. CLAUDE.md, SPRINT-029.md). The
|
|
4
|
+
// H1 display title is free-form; wiki-links are case-insensitive so titles
|
|
5
|
+
// don't break links no matter their case. Filenames are the durable identifier
|
|
6
|
+
// — judgment-free to constrain, lintable, prevents typo-driven duplicates
|
|
7
|
+
// (Foo.md vs FOO.md vs foo.md as three different files).
|
|
8
|
+
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
// Allowed in a normalised filename: ASCII uppercase letters, digits, dot,
|
|
12
|
+
// hyphen, underscore. Dot is allowed for sub-extensions like FOO.test.md;
|
|
13
|
+
// the final `.md` is asserted separately.
|
|
14
|
+
const UPPERCASE_BASENAME_RE = /^[A-Z0-9._-]+\.md$/;
|
|
15
|
+
|
|
16
|
+
export function isUppercaseFilename(filepath: string): boolean {
|
|
17
|
+
const base = path.basename(filepath);
|
|
18
|
+
return UPPERCASE_BASENAME_RE.test(base);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalise a single filename component (no directory) to the project's
|
|
23
|
+
* canonical shape: uppercase, spaces → hyphens, ASCII-only, collapse repeated
|
|
24
|
+
* hyphens. Strips a trailing `.md` if the caller passed one; appends a fresh
|
|
25
|
+
* `.md` always.
|
|
26
|
+
*
|
|
27
|
+
* Used by create_child + image upload code paths where filename derivation
|
|
28
|
+
* is driven by a title/slug rather than a literal user-provided path.
|
|
29
|
+
*/
|
|
30
|
+
export function normalizeBasename(input: string): string {
|
|
31
|
+
// Strip optional trailing `.md` (case-insensitive) so it doesn't get
|
|
32
|
+
// double-uppercased and we re-append exactly one.
|
|
33
|
+
const stripped = input.replace(/\.md$/i, "");
|
|
34
|
+
const upper = stripped
|
|
35
|
+
.toUpperCase()
|
|
36
|
+
// Whitespace → hyphen
|
|
37
|
+
.replace(/\s+/g, "-")
|
|
38
|
+
// Anything not [A-Z0-9._-] → drop
|
|
39
|
+
.replace(/[^A-Z0-9._-]/g, "")
|
|
40
|
+
// Collapse runs of hyphens
|
|
41
|
+
.replace(/-+/g, "-")
|
|
42
|
+
// Strip leading/trailing hyphens or dots
|
|
43
|
+
.replace(/^[-.]+|[-.]+$/g, "");
|
|
44
|
+
// Fall back to "DOC" if the input was all-non-ASCII.
|
|
45
|
+
const safe = upper || "DOC";
|
|
46
|
+
return `${safe}.md`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Apply normalizeBasename to the filename portion of a full vault path,
|
|
51
|
+
* preserving the directory. Returns the corrected path.
|
|
52
|
+
*
|
|
53
|
+
* normalizeFilenameInPath("images/photo-12-57.md")
|
|
54
|
+
* → "images/PHOTO-12-57.md"
|
|
55
|
+
* normalizeFilenameInPath("foo bar")
|
|
56
|
+
* → "FOO-BAR.md"
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeFilenameInPath(filepath: string): string {
|
|
59
|
+
const dir = path.dirname(filepath);
|
|
60
|
+
const base = path.basename(filepath);
|
|
61
|
+
const normalized = normalizeBasename(base);
|
|
62
|
+
return dir === "." ? normalized : `${dir}/${normalized}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// SPRINT-018 Phase 4: multi-hop context loader. Given a focal doc, walk
|
|
2
|
+
// the edge graph up to `hops` away and return the focal + neighbourhood
|
|
3
|
+
// with bodies inlined within a token budget.
|
|
4
|
+
//
|
|
5
|
+
// Why not a SQL recursive CTE: loadVaultIndex already gives us the full
|
|
6
|
+
// edge set in both local and cloud modes (Phase 3 wired cloud's edges
|
|
7
|
+
// from doc_edges). BFS in JS is mode-agnostic — no new SQL migration
|
|
8
|
+
// needed, and the post-Phase-3 cloud path still benefits from the
|
|
9
|
+
// materialized table because the index load itself is cheap.
|
|
10
|
+
|
|
11
|
+
import { loadVaultIndex } from "./vault";
|
|
12
|
+
import { hashBody } from "./sections";
|
|
13
|
+
import type { ToolContext } from "./types";
|
|
14
|
+
import type { DocNode } from "../../../core/indexer";
|
|
15
|
+
|
|
16
|
+
function json(value: unknown) {
|
|
17
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type ContextKind = "focal" | "parent" | "child" | "assoc";
|
|
21
|
+
|
|
22
|
+
interface ContextNode {
|
|
23
|
+
path: string;
|
|
24
|
+
title: string;
|
|
25
|
+
summary: string;
|
|
26
|
+
hop: number;
|
|
27
|
+
kind: ContextKind;
|
|
28
|
+
content?: string;
|
|
29
|
+
truncated?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ContextResult {
|
|
33
|
+
focal: ContextNode;
|
|
34
|
+
neighbors: ContextNode[];
|
|
35
|
+
doc_content_hash: string;
|
|
36
|
+
budget: { used: number; limit: number; dropped_paths: string[] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_HOPS = 2;
|
|
40
|
+
const MAX_HOPS = 3;
|
|
41
|
+
const DEFAULT_BUDGET = 8000;
|
|
42
|
+
// rough char-to-token ratio used elsewhere in the spec
|
|
43
|
+
const CHARS_PER_TOKEN = 4;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* BFS the edge graph from `focalPath` out to `maxHops`. For each visited
|
|
47
|
+
* doc record its hop depth and the relationship kind that brought us
|
|
48
|
+
* there (parent / child / assoc) — when multiple paths lead to the same
|
|
49
|
+
* doc, the first arrival wins (shortest hop, and within a hop the kind
|
|
50
|
+
* priority parent < child < assoc via the edge iteration order below).
|
|
51
|
+
*/
|
|
52
|
+
function buildNeighborhood(
|
|
53
|
+
focalPath: string,
|
|
54
|
+
edges: Array<{ from: string; to: string; kind: "hierarchy" | "assoc" }>,
|
|
55
|
+
maxHops: number,
|
|
56
|
+
includeAssociates: boolean,
|
|
57
|
+
): Map<string, { hop: number; kind: ContextKind }> {
|
|
58
|
+
// For each doc, what edges leave/enter it. We pre-classify directed
|
|
59
|
+
// hierarchy edges as "child" (focal points away) or "parent" (focal
|
|
60
|
+
// points back), and assoc edges as undirected pair-mates.
|
|
61
|
+
interface Neighbor { other: string; kind: "parent" | "child" | "assoc"; }
|
|
62
|
+
const adj = new Map<string, Neighbor[]>();
|
|
63
|
+
const push = (from: string, n: Neighbor) => {
|
|
64
|
+
const arr = adj.get(from) ?? [];
|
|
65
|
+
arr.push(n);
|
|
66
|
+
adj.set(from, arr);
|
|
67
|
+
};
|
|
68
|
+
for (const e of edges) {
|
|
69
|
+
if (e.kind === "hierarchy") {
|
|
70
|
+
// from = parent, to = child.
|
|
71
|
+
push(e.from, { other: e.to, kind: "child" });
|
|
72
|
+
push(e.to, { other: e.from, kind: "parent" });
|
|
73
|
+
} else {
|
|
74
|
+
if (!includeAssociates) continue;
|
|
75
|
+
// Assoc in our Edge[] (post-Phase-3 dedup) is single-row lo→hi;
|
|
76
|
+
// treat as undirected.
|
|
77
|
+
push(e.from, { other: e.to, kind: "assoc" });
|
|
78
|
+
push(e.to, { other: e.from, kind: "assoc" });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const visited = new Map<string, { hop: number; kind: ContextKind }>();
|
|
83
|
+
visited.set(focalPath, { hop: 0, kind: "focal" });
|
|
84
|
+
|
|
85
|
+
let frontier: string[] = [focalPath];
|
|
86
|
+
for (let hop = 1; hop <= maxHops; hop++) {
|
|
87
|
+
const next: string[] = [];
|
|
88
|
+
for (const path of frontier) {
|
|
89
|
+
const neighbors = adj.get(path) ?? [];
|
|
90
|
+
// Stable order: parents first, then children, then assocs (mirrors
|
|
91
|
+
// the spec's `(hop ASC, kind ASC, position ASC)`).
|
|
92
|
+
const sorted = neighbors.slice().sort((a, b) => kindRank(a.kind) - kindRank(b.kind));
|
|
93
|
+
for (const n of sorted) {
|
|
94
|
+
if (visited.has(n.other)) continue;
|
|
95
|
+
visited.set(n.other, { hop, kind: n.kind });
|
|
96
|
+
next.push(n.other);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (next.length === 0) break;
|
|
100
|
+
frontier = next;
|
|
101
|
+
}
|
|
102
|
+
return visited;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function kindRank(k: "parent" | "child" | "assoc"): number {
|
|
106
|
+
if (k === "parent") return 0;
|
|
107
|
+
if (k === "child") return 1;
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function getContext(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
|
|
112
|
+
const focalPath = String(args.path);
|
|
113
|
+
const hops = Math.max(1, Math.min(MAX_HOPS, Number(args.hops ?? DEFAULT_HOPS)));
|
|
114
|
+
const budget = Math.max(0, Number(args.budget_tokens ?? DEFAULT_BUDGET));
|
|
115
|
+
const includeFull = args.include_full === undefined ? true : Boolean(args.include_full);
|
|
116
|
+
const includeAssociates = args.include_associates === undefined ? true : Boolean(args.include_associates);
|
|
117
|
+
|
|
118
|
+
const idx = await loadVaultIndex(ctx);
|
|
119
|
+
const focal = idx.docs.find((d) => d.path === focalPath);
|
|
120
|
+
if (!focal) throw new Error(`no such doc: ${focalPath}`);
|
|
121
|
+
|
|
122
|
+
// SPRINT-024 Phase 1: hash-conditional short-circuit. The hash is over the
|
|
123
|
+
// FOCAL doc's raw content — neighbourhood changes don't bust the cache,
|
|
124
|
+
// matching `get_doc`'s scope. Callers chasing a structural change must
|
|
125
|
+
// refetch unconditionally.
|
|
126
|
+
const docHash = hashBody(focal.content);
|
|
127
|
+
const expected = args.expected_content_hash !== undefined ? String(args.expected_content_hash) : "";
|
|
128
|
+
if (expected && expected === docHash) {
|
|
129
|
+
return json({ unchanged: true, path: focal.path, doc_content_hash: docHash });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const visited = buildNeighborhood(focalPath, idx.edges, hops, includeAssociates);
|
|
133
|
+
const byPath = new Map<string, DocNode>(idx.docs.map((d) => [d.path, d]));
|
|
134
|
+
|
|
135
|
+
// Order: focal, then by (hop ASC, kind ASC, path ASC) for determinism.
|
|
136
|
+
const sortedEntries = [...visited.entries()]
|
|
137
|
+
.filter(([p]) => byPath.has(p))
|
|
138
|
+
.sort((a, b) => {
|
|
139
|
+
if (a[0] === focalPath) return -1;
|
|
140
|
+
if (b[0] === focalPath) return 1;
|
|
141
|
+
const ha = a[1].hop;
|
|
142
|
+
const hb = b[1].hop;
|
|
143
|
+
if (ha !== hb) return ha - hb;
|
|
144
|
+
const ka = kindRank(a[1].kind as "parent" | "child" | "assoc");
|
|
145
|
+
const kb = kindRank(b[1].kind as "parent" | "child" | "assoc");
|
|
146
|
+
if (ka !== kb) return ka - kb;
|
|
147
|
+
return a[0].localeCompare(b[0]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Budget walk: focal + hop-1 docs get full body when include_full,
|
|
151
|
+
// hop-2+ get summary-only. As budget exhausts, body is dropped first;
|
|
152
|
+
// if even the summary-line overflows, the node is dropped entirely
|
|
153
|
+
// and surfaced in dropped_paths.
|
|
154
|
+
const limit = budget;
|
|
155
|
+
let used = 0;
|
|
156
|
+
const dropped: string[] = [];
|
|
157
|
+
let focalNode: ContextNode | null = null;
|
|
158
|
+
const neighbors: ContextNode[] = [];
|
|
159
|
+
|
|
160
|
+
for (const [p, meta] of sortedEntries) {
|
|
161
|
+
const doc = byPath.get(p)!;
|
|
162
|
+
const wantBody = includeFull && meta.hop <= 1;
|
|
163
|
+
// Always reserve cost for the summary line so dropped nodes are rare.
|
|
164
|
+
const summaryCost = Math.ceil((doc.summary.length + doc.title.length + p.length) / CHARS_PER_TOKEN);
|
|
165
|
+
if (used + summaryCost > limit && p !== focalPath) {
|
|
166
|
+
dropped.push(p);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const node: ContextNode = {
|
|
170
|
+
path: doc.path,
|
|
171
|
+
title: doc.title,
|
|
172
|
+
summary: doc.summary,
|
|
173
|
+
hop: meta.hop,
|
|
174
|
+
kind: meta.kind,
|
|
175
|
+
};
|
|
176
|
+
used += summaryCost;
|
|
177
|
+
|
|
178
|
+
if (wantBody) {
|
|
179
|
+
const bodyCost = Math.ceil(doc.content.length / CHARS_PER_TOKEN);
|
|
180
|
+
if (used + bodyCost <= limit) {
|
|
181
|
+
node.content = doc.content;
|
|
182
|
+
used += bodyCost;
|
|
183
|
+
} else {
|
|
184
|
+
// Out of budget for the body — flag truncation and leave the
|
|
185
|
+
// summary-only entry. Focal is the one exception: we always try
|
|
186
|
+
// to ship its body, truncating if necessary so the caller at
|
|
187
|
+
// least sees the head of the doc.
|
|
188
|
+
if (p === focalPath) {
|
|
189
|
+
const remaining = Math.max(0, limit - used);
|
|
190
|
+
const charBudget = remaining * CHARS_PER_TOKEN;
|
|
191
|
+
if (charBudget > 0) {
|
|
192
|
+
node.content = doc.content.slice(0, charBudget);
|
|
193
|
+
node.truncated = true;
|
|
194
|
+
used += Math.ceil(node.content.length / CHARS_PER_TOKEN);
|
|
195
|
+
} else {
|
|
196
|
+
node.truncated = true;
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
node.truncated = true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (p === focalPath) focalNode = node;
|
|
205
|
+
else neighbors.push(node);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Edge case: the focal somehow got dropped (shouldn't happen — we
|
|
209
|
+
// skip the drop branch when p === focalPath above — but be defensive).
|
|
210
|
+
if (!focalNode) {
|
|
211
|
+
focalNode = {
|
|
212
|
+
path: focal.path,
|
|
213
|
+
title: focal.title,
|
|
214
|
+
summary: focal.summary,
|
|
215
|
+
hop: 0,
|
|
216
|
+
kind: "focal",
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const result: ContextResult = {
|
|
221
|
+
focal: focalNode,
|
|
222
|
+
neighbors,
|
|
223
|
+
doc_content_hash: docHash,
|
|
224
|
+
budget: { used, limit, dropped_paths: dropped },
|
|
225
|
+
};
|
|
226
|
+
return json(result);
|
|
227
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { validatePath, readVaultFile } from "./vault";
|
|
2
|
+
import { extractPreamble } from "./patch_preamble";
|
|
3
|
+
import { parseSections, extractBody, hashBody, sectionId } from "./sections";
|
|
4
|
+
import { deriveTitle, deriveSummary } from "../../../core/indexer";
|
|
5
|
+
import type { ToolContext } from "./types";
|
|
6
|
+
import { SYSTEM_NODES, SYSTEM_NODE_PATHS, systemNodeContent } from "../../system-nodes";
|
|
7
|
+
|
|
8
|
+
// Re-export sectionId so historic call sites (`import { sectionId } from "./get_doc"`)
|
|
9
|
+
// keep compiling without an audit-the-world rename.
|
|
10
|
+
export { sectionId } from "./sections";
|
|
11
|
+
|
|
12
|
+
function json(value: unknown) {
|
|
13
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function text(value: string) {
|
|
17
|
+
return { content: [{ type: "text" as const, text: value }] };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Return doc metadata. SPRINT-018 Phase 5: the body is opt-in via
|
|
22
|
+
* `full=true`. The default response is light — title + summary +
|
|
23
|
+
* preamble + section headings.
|
|
24
|
+
*
|
|
25
|
+
* SPRINT-024 Phase 1: every response now carries `doc_content_hash`
|
|
26
|
+
* (sha256 first 16 hex of the raw file content). Pass it back via
|
|
27
|
+
* `expected_content_hash` on the next get_doc; if the doc hasn't
|
|
28
|
+
* changed we return `{ unchanged: true, path, doc_content_hash }` and
|
|
29
|
+
* skip the section-parse / preamble work entirely. Cheaper than fetching
|
|
30
|
+
* the doc just to discover nothing moved.
|
|
31
|
+
*
|
|
32
|
+
* SPRINT-038 v1: read the file directly via `readVaultFile` instead of
|
|
33
|
+
* pulling the full vault index. Title + summary are derived locally with
|
|
34
|
+
* the same primitives the indexer uses, so the response shape is
|
|
35
|
+
* byte-identical to the prior `loadVaultIndex` path. The cold-start win
|
|
36
|
+
* is avoiding the `listWithContent` cascade for a known-path lookup.
|
|
37
|
+
*/
|
|
38
|
+
export async function getDoc(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
|
|
39
|
+
const rel = String(args.path);
|
|
40
|
+
validatePath(rel);
|
|
41
|
+
let content = await readVaultFile(ctx, rel);
|
|
42
|
+
if (content === null) {
|
|
43
|
+
// Virtual system nodes are never stored per-user — serve their canonical
|
|
44
|
+
// content so MCP callers can read EMDEE/VAULT/SHARED/GRAVEYARD/IMAGES.
|
|
45
|
+
if (SYSTEM_NODE_PATHS.has(rel)) {
|
|
46
|
+
const node = SYSTEM_NODES.find((n) => n.path === rel)!;
|
|
47
|
+
content = systemNodeContent(node);
|
|
48
|
+
} else {
|
|
49
|
+
throw new Error(`no such doc: ${rel}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const docHash = hashBody(content);
|
|
54
|
+
|
|
55
|
+
const expected = args.expected_content_hash !== undefined ? String(args.expected_content_hash) : "";
|
|
56
|
+
if (expected && expected === docHash) {
|
|
57
|
+
return json({ unchanged: true, path: rel, doc_content_hash: docHash });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const full = Boolean(args.full);
|
|
61
|
+
|
|
62
|
+
// Plaintext mode short-circuit: bare markdown, no envelope.
|
|
63
|
+
// full=true → raw file. full=false → H1 + summary + section headings.
|
|
64
|
+
if (args.format === "text") {
|
|
65
|
+
if (full) return text(content);
|
|
66
|
+
const title = deriveTitle(rel, content);
|
|
67
|
+
const summary = deriveSummary(content);
|
|
68
|
+
const headings = parseSections(content).map((s) => `## ${s.heading}`);
|
|
69
|
+
const parts = [`# ${title}`];
|
|
70
|
+
if (summary) parts.push(`> ${summary}`);
|
|
71
|
+
if (headings.length) parts.push(headings.join("\n"));
|
|
72
|
+
return text(parts.join("\n\n"));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sections = parseSections(content).map((s, idx) => ({
|
|
76
|
+
id: sectionId(s.heading, idx),
|
|
77
|
+
heading: s.heading,
|
|
78
|
+
content_hash: hashBody(extractBody(content, s)),
|
|
79
|
+
}));
|
|
80
|
+
const preamble = extractPreamble(content);
|
|
81
|
+
const payload: Record<string, unknown> = {
|
|
82
|
+
path: rel,
|
|
83
|
+
title: deriveTitle(rel, content),
|
|
84
|
+
summary: deriveSummary(content),
|
|
85
|
+
doc_content_hash: docHash,
|
|
86
|
+
preamble: preamble ?? undefined,
|
|
87
|
+
sections,
|
|
88
|
+
};
|
|
89
|
+
if (full) payload.content = content;
|
|
90
|
+
return json(payload);
|
|
91
|
+
}
|