@aisystemresources/emdee 0.3.0 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +3 -3
- package/README.md +2 -2
- package/bin/emdee.js +27 -0
- package/package.json +1 -1
- package/skills/emdee-conventions.md +23 -20
- package/skills/emdee-describe-image.md +11 -17
- package/skills/emdee-onboarder.md +1 -1
- package/skills/emdee-summariser.md +1 -1
- package/src/cli/read-commands.ts +25 -0
- package/src/cli/remote-client.ts +25 -1
- package/src/lib/mcp/tools/index.ts +1 -0
- package/src/lib/mcp/tools/lint_vault.ts +59 -0
- package/src/lib/storage/SupabaseStorage.ts +43 -17
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aisystemresources",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "AI System Resources",
|
|
5
|
+
"url": "https://emdee.tech"
|
|
6
|
+
},
|
|
7
|
+
"plugins": [
|
|
8
|
+
{
|
|
9
|
+
"name": "emdee",
|
|
10
|
+
"source": "./",
|
|
11
|
+
"description": "A markdown knowledge graph shared by humans and their AI agents. Bundles the emdee.tech MCP server, the emdee CLI, and 4 Claude Code skills that teach vault conventions + workflows."
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emdee",
|
|
3
|
-
"displayName": "Emdee —
|
|
4
|
-
"description": "
|
|
5
|
-
"version": "0.
|
|
3
|
+
"displayName": "Emdee — Markdown knowledge graph for humans and agents",
|
|
4
|
+
"description": "A markdown knowledge graph shared by humans and their AI agents. Bundles the emdee.tech MCP server, the emdee CLI, and 4 Claude Code skills that teach vault conventions + workflows.",
|
|
5
|
+
"version": "0.3.2",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AI System Resources",
|
|
8
8
|
"url": "https://emdee.tech"
|
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Emdee
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A markdown knowledge graph shared by humans and their AI agents. Files you can edit like Obsidian, a vault you can share like Google Drive, and a live MCP endpoint every agent (Claude Code, Claude.ai, Cursor, Codex) reads and writes to natively. One vault, three surfaces — with the plain markdown you wrote as the only source of truth.
|
|
4
4
|
|
|
5
5
|
## Why
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Every knowledge tool picks two of three: human-friendly, team-shareable, agent-native. Notion is human-friendly and shareable, but its blocks aren't markdown and agents can't natively touch them. Obsidian is human-friendly and agent-touchable (they're just markdown files), but private-by-default — sharing is a paid add-on or a git repo. Google Drive is shareable but not markdown-native. Emdee is all three: the markdown a human edits is the exact bytes an agent reads through MCP and a teammate reads through a share link — no hidden index, no parallel summaries, no schema gymnastics. Build up a working journal that survives across sessions and travels with the people you invite in.
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
package/bin/emdee.js
CHANGED
|
@@ -664,6 +664,33 @@ program
|
|
|
664
664
|
shellRead("list-summary-drift", opts, extra);
|
|
665
665
|
});
|
|
666
666
|
|
|
667
|
+
program
|
|
668
|
+
.command("lint-doc")
|
|
669
|
+
.description("Audit one doc for quality defects (missing preamble, asymmetric edges, sibling assocs, etc).")
|
|
670
|
+
.requiredOption("--path <path>", "Vault doc path")
|
|
671
|
+
.option("-d, --docs <dir>", "docs directory (local mode)")
|
|
672
|
+
.option("--remote", "Route through emdee.tech")
|
|
673
|
+
.option("--json", "Machine-parseable output")
|
|
674
|
+
.action((opts) => {
|
|
675
|
+
const extra = argsFromOpts(opts, { path: "--path", remote: "--remote", json: "--json" });
|
|
676
|
+
shellRead("lint-doc", opts, extra);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
program
|
|
680
|
+
.command("lint-vault")
|
|
681
|
+
.description("Batch-lint every doc in the vault. Returns aggregated warnings by code + per-doc punch list.")
|
|
682
|
+
.option("--prefix <p>", "Path prefix filter (scope to a subtree)")
|
|
683
|
+
.option("--limit <n>", "Max docs in the punch list (default: all)")
|
|
684
|
+
.option("-d, --docs <dir>", "docs directory (local mode)")
|
|
685
|
+
.option("--remote", "Route through emdee.tech")
|
|
686
|
+
.option("--json", "Machine-parseable output")
|
|
687
|
+
.action((opts) => {
|
|
688
|
+
const extra = argsFromOpts(opts, {
|
|
689
|
+
prefix: "--prefix", limit: "--limit", remote: "--remote", json: "--json",
|
|
690
|
+
});
|
|
691
|
+
shellRead("lint-vault", opts, extra);
|
|
692
|
+
});
|
|
693
|
+
|
|
667
694
|
// -----------------------------------------------------------------------
|
|
668
695
|
// Deferred parity gap closers (post-SPRINT-091): 4 remaining tool verbs.
|
|
669
696
|
// -----------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
name: emdee-conventions
|
|
3
3
|
description: |
|
|
4
4
|
Use whenever reading or writing an EMDEE vault. Covers the 5-node OS layer,
|
|
5
|
-
doc shape (H1 + blockquote + relationship sections), edge discipline,
|
|
6
|
-
selection (
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
doc shape (H1 + blockquote + relationship sections), edge discipline, CLI
|
|
6
|
+
tool selection (atomic multi-side writes), and path conventions (UPPERCASE
|
|
7
|
+
filenames, lowercase folders, hub-next-to-folder). Load this once and every
|
|
8
|
+
subsequent vault operation lands correctly the first time.
|
|
9
9
|
---
|
|
10
10
|
|
|
11
11
|
# EMDEE conventions — always-loaded
|
|
12
12
|
|
|
13
|
-
You are working inside an Emdee vault. Every doc is plain markdown. Humans browse it through a Next.js renderer at [emdee.tech](https://emdee.tech);
|
|
13
|
+
You are working inside an Emdee vault. Every doc is plain markdown. Humans browse it through a Next.js renderer at [emdee.tech](https://emdee.tech); you read and write the same files through the `emdee` CLI. The vault is the source of truth — anything you say must trace back to a file the user wrote.
|
|
14
14
|
|
|
15
15
|
## The 5-node OS layer (virtual system nodes)
|
|
16
16
|
|
|
17
|
-
Every vault has exactly 5 canonical top-level docs plus one owner node. **These 5 are virtual** — they appear in every read (`emdee list`, `
|
|
17
|
+
Every vault has exactly 5 canonical top-level docs plus one owner node. **These 5 are virtual** — they appear in every read (`emdee list`, `emdee get-doc`, etc.) without being on disk. Edit them and your version wins.
|
|
18
18
|
|
|
19
19
|
| Node | Purpose |
|
|
20
20
|
|---|---|
|
|
@@ -50,13 +50,13 @@ Every vault has exactly 5 canonical top-level docs plus one owner node. **These
|
|
|
50
50
|
Freeform content.
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Rules the lint enforces (see `
|
|
53
|
+
Rules the lint enforces (see `emdee lint-doc --path X` or `emdee patch-section --gate-on <code>`):
|
|
54
54
|
|
|
55
55
|
- **One parent per doc.** `## Child of` should have exactly one bullet. Multiple parents → demote secondaries to `## Associated with`.
|
|
56
56
|
- **No sibling associations.** Docs that share a parent are already related through it. `## Associated with` is for cross-tree links (project↔person, sprint↔learning).
|
|
57
57
|
- **Reciprocal edges.** If A's `## Parent of` lists `[[B]]`, B's `## Child of` must list `[[A]]`. Asymmetric edges fire `asymmetric_parent_edge` / `asymmetric_child_edge` warnings.
|
|
58
58
|
- **First wiki-link = declared edge.** `* [[TARGET]] — prose about the link`. The bullet's leading link is the edge; other links in the bullet are inline mentions only.
|
|
59
|
-
- **Sibling order is derived, never declared.** `
|
|
59
|
+
- **Sibling order is derived, never declared.** `emdee get-neighbors` returns `prev_sibling` / `next_sibling` computed from the parent's `## Parent of` order. Do NOT add `[[next-node]]` / `[[prev-node]]` bullets.
|
|
60
60
|
|
|
61
61
|
## Path conventions
|
|
62
62
|
|
|
@@ -66,22 +66,25 @@ Rules the lint enforces (see `lint_doc(path)` or `emdee patch-section --gate-on
|
|
|
66
66
|
|
|
67
67
|
## Tool selection
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Everything runs through the `emdee` CLI. `--remote` routes through the authenticated cloud vault at emdee.tech; omit for local mode.
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
|
-
emdee list --remote #
|
|
73
|
-
emdee get-doc --path X --remote --full #
|
|
72
|
+
emdee list --remote # top-level vault paths
|
|
73
|
+
emdee get-doc --path X --remote --full # doc body + per-section hashes
|
|
74
|
+
emdee search --query "keyword" --remote
|
|
74
75
|
```
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
Reads: `list`, `list-docs`, `get-doc`, `get-summary`, `get-neighbors`, `get-context`, `read-doc-section`, `search`, `list-summary-drift`, `drift-batch`, `lint-doc`, `get-image`.
|
|
78
|
+
|
|
79
|
+
Writes: `patch-section`, `patch-preamble`, `append-section`, `append-doc`, `create-child`, `add-association`, `move-doc`, `rename-doc`, `trash-doc`, `restore-doc`, `delete-doc`, `write-doc`, `write-doc-preview`, `distill-doc`, `materialize-subgroup`, `split-doc`.
|
|
77
80
|
|
|
78
81
|
For writes, always prefer the atomic multi-side variants over raw section patches:
|
|
79
82
|
|
|
80
83
|
| Instead of… | Use… |
|
|
81
84
|
|---|---|
|
|
82
|
-
| Two `
|
|
83
|
-
| Two `
|
|
84
|
-
| Three `
|
|
85
|
+
| Two `patch-section` calls (child's Child of + parent's Parent of) | `emdee create-child` |
|
|
86
|
+
| Two `patch-section` calls on both docs' Associated with | `emdee add-association` |
|
|
87
|
+
| Three `patch-section` calls to reparent | `emdee move-doc` |
|
|
85
88
|
| Search+replace across every doc that references a title | `emdee rename-doc` |
|
|
86
89
|
|
|
87
90
|
The atomic variants take care of edge discipline (hard-refuse on sibling-assoc-redundant, would-duplicate-hierarchy) and rollback semantics.
|
|
@@ -95,12 +98,12 @@ Every destructive write (`patch-section`, `patch-preamble`, `move-doc`) requires
|
|
|
95
98
|
## HARD RULES you'll trip if you're not careful
|
|
96
99
|
|
|
97
100
|
1. **Any Supabase `.select()` reading > 1000 rows must paginate.** Use `.range(offset, offset + PAGE - 1)` in a loop. `.range()` alone doesn't lift the server-side 1000-row cap.
|
|
98
|
-
2. **`
|
|
99
|
-
3. **
|
|
101
|
+
2. **`patch-section` on a `## Child of` requires patching the new parent's `## Parent of` in the same turn.** Otherwise you leave an asymmetric edge. Or just use `move-doc`.
|
|
102
|
+
3. **Any change to a tool's behaviour (CLI verb or MCP surface) requires an e2e spec in the same PR.** Test end-to-end via a real `ToolContext` + temp filesystem.
|
|
100
103
|
4. **Migrations touch `supabase/migrations/**` and are NEVER auto-merged.** Human review required.
|
|
101
104
|
|
|
102
105
|
## When in doubt
|
|
103
106
|
|
|
104
|
-
- `emdee lint-doc --path X` — surface every warning code the doc trips
|
|
105
|
-
- `emdee get-doc --path X` — see current state including per-section hashes
|
|
106
|
-
- Read the source of truth: `edmund/projects/emdee_os/LEARNINGS.md
|
|
107
|
+
- `emdee lint-doc --path X --remote` — surface every warning code the doc trips
|
|
108
|
+
- `emdee get-doc --path X --remote` — see current state including per-section hashes
|
|
109
|
+
- Read the source of truth: `emdee get-doc --path "edmund/projects/emdee_os/LEARNINGS.md" --remote --full`
|
|
@@ -9,7 +9,7 @@ description: |
|
|
|
9
9
|
|
|
10
10
|
# emdee-describe-image — rename + summarise uploaded images
|
|
11
11
|
|
|
12
|
-
When you see an EMDEE image doc that hasn't been described yet, run this workflow.
|
|
12
|
+
When you see an EMDEE image doc that hasn't been described yet, run this workflow. Pure `emdee` CLI — no MCP calls. Every command routes through the authenticated cloud vault via `--remote`.
|
|
13
13
|
|
|
14
14
|
## Trigger patterns
|
|
15
15
|
|
|
@@ -22,17 +22,11 @@ Any vault doc where:
|
|
|
22
22
|
|
|
23
23
|
### 1. Read the image
|
|
24
24
|
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
Or via CLI (when SPRINT-091 chunk 4 lands `get-image`):
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
emdee get-image --path <PATH> --remote
|
|
25
|
+
```bash
|
|
26
|
+
emdee get-image --doc-path <PATH> --out /tmp/img.png --remote
|
|
33
27
|
```
|
|
34
28
|
|
|
35
|
-
|
|
29
|
+
Then read `/tmp/img.png` — you'll see it as a visual content block and can describe what's in it.
|
|
36
30
|
|
|
37
31
|
### 2. Compose
|
|
38
32
|
|
|
@@ -43,11 +37,11 @@ The MCP tool returns the image as a visual content block — you can see it.
|
|
|
43
37
|
|
|
44
38
|
### 3. Rename
|
|
45
39
|
|
|
46
|
-
```
|
|
40
|
+
```bash
|
|
47
41
|
emdee rename-doc --old-path <PATH> --new-title <TITLE> --remote
|
|
48
42
|
```
|
|
49
43
|
|
|
50
|
-
|
|
44
|
+
Atomically:
|
|
51
45
|
- Rewrites the H1
|
|
52
46
|
- Moves the file (default: same folder, `<TITLE>.md`)
|
|
53
47
|
- Updates every `[[<old title>]]` wiki-link across the vault
|
|
@@ -56,13 +50,13 @@ The tool atomically:
|
|
|
56
50
|
|
|
57
51
|
Fetch the fresh preamble hash:
|
|
58
52
|
|
|
59
|
-
```
|
|
53
|
+
```bash
|
|
60
54
|
emdee get-doc --path <NEW-PATH> --remote --json
|
|
61
55
|
```
|
|
62
56
|
|
|
63
57
|
Take `preamble.content_hash` from the response, then:
|
|
64
58
|
|
|
65
|
-
```
|
|
59
|
+
```bash
|
|
66
60
|
emdee patch-preamble --path <NEW-PATH> \
|
|
67
61
|
--body "> <your 15-30 word summary>" \
|
|
68
62
|
--expected-hash <hash> --remote
|
|
@@ -72,8 +66,8 @@ emdee patch-preamble --path <NEW-PATH> \
|
|
|
72
66
|
|
|
73
67
|
If several images need describing:
|
|
74
68
|
|
|
75
|
-
```
|
|
76
|
-
emdee list-docs --prefix "IMAGES/" --remote | \
|
|
69
|
+
```bash
|
|
70
|
+
emdee list-docs --prefix "IMAGES/" --remote --format text | \
|
|
77
71
|
xargs -I{} emdee get-summary --path {} --remote --format text
|
|
78
72
|
```
|
|
79
73
|
|
|
@@ -81,7 +75,7 @@ Filter for `_description pending_` in the output, then run the 4-step workflow p
|
|
|
81
75
|
|
|
82
76
|
## Failure modes
|
|
83
77
|
|
|
84
|
-
- **Title collision** — `
|
|
78
|
+
- **Title collision** — `rename-doc` returns `title_conflict`. Pick a more specific title (add a subject qualifier).
|
|
85
79
|
- **`_description pending_` was already replaced** — someone else already ran this. `emdee get-doc` will show the new summary; skip.
|
|
86
80
|
- **Image is illegible** — set the summary to `> Illegible / unable to describe from image alone.` and flag it in the user-facing report so they can annotate manually.
|
|
87
81
|
|
|
@@ -107,7 +107,7 @@ Load the conventions skill (if not already loaded) and let it take over.
|
|
|
107
107
|
## What to avoid
|
|
108
108
|
|
|
109
109
|
- **Don't dump the full conventions in step 2.** The user is trying to see one thing work. Ship them from zero → seeing their first graph node in under 5 minutes. Depth comes after.
|
|
110
|
-
- **Don't force `
|
|
110
|
+
- **Don't force `create-child` before they know what to create.** The step-3 question ("what do you want to track first?") is critical. If they say "I don't know yet," don't create a doc — instead, suggest they browse [emdee.tech](https://emdee.tech) in incognito to see the public demo vault. Come back when they have an answer.
|
|
111
111
|
- **Don't reference SPRINTs / LOGS / LEARNINGS in onboarding.** Those are for later. Zero-to-one first.
|
|
112
112
|
|
|
113
113
|
## Success signal
|
|
@@ -9,7 +9,7 @@ description: |
|
|
|
9
9
|
|
|
10
10
|
# emdee-summariser — refresh drifting doc summaries
|
|
11
11
|
|
|
12
|
-
Docs in EMDEE carry a one-line `> blockquote` summary right below the H1. That summary is what routing sees — search
|
|
12
|
+
Docs in EMDEE carry a one-line `> blockquote` summary right below the H1. That summary is what routing sees — `emdee search`, `emdee get-summary`, cheap enumeration all pivot on it. When the body drifts (new sections, refined framing, changed scope), the summary should catch up. This skill runs that refresh.
|
|
13
13
|
|
|
14
14
|
## Trigger patterns
|
|
15
15
|
|
package/src/cli/read-commands.ts
CHANGED
|
@@ -25,6 +25,8 @@ import { search } from "../lib/mcp/tools/search";
|
|
|
25
25
|
import { readDocSection } from "../lib/mcp/tools/read_doc_section";
|
|
26
26
|
import { listDocs } from "../lib/mcp/tools/list_docs";
|
|
27
27
|
import { listSummaryDrift } from "../lib/mcp/tools/list_summary_drift";
|
|
28
|
+
import { lintDoc } from "../lib/mcp/tools/lint_doc";
|
|
29
|
+
import { lintVault } from "../lib/mcp/tools/lint_vault";
|
|
28
30
|
import { getImage } from "../lib/mcp/tools/get_image";
|
|
29
31
|
import { writeFileSync } from "node:fs";
|
|
30
32
|
|
|
@@ -175,6 +177,29 @@ const READ_VERBS: Record<string, ReadVerb> = {
|
|
|
175
177
|
return args;
|
|
176
178
|
},
|
|
177
179
|
},
|
|
180
|
+
"lint-doc": {
|
|
181
|
+
toolName: "lint_doc",
|
|
182
|
+
toolFn: lintDoc as unknown as ToolFn,
|
|
183
|
+
parse: { ...COMMON, path: { type: "string" } },
|
|
184
|
+
buildArgs: (v) => ({ path: asString(v.path) }),
|
|
185
|
+
},
|
|
186
|
+
"lint-vault": {
|
|
187
|
+
toolName: "lint_vault",
|
|
188
|
+
toolFn: lintVault as unknown as ToolFn,
|
|
189
|
+
parse: {
|
|
190
|
+
...COMMON,
|
|
191
|
+
prefix: { type: "string" },
|
|
192
|
+
limit: { type: "string" },
|
|
193
|
+
},
|
|
194
|
+
buildArgs: (v) => {
|
|
195
|
+
const args: Record<string, unknown> = {};
|
|
196
|
+
const prefix = optionalString(v.prefix);
|
|
197
|
+
if (prefix) args.prefix = prefix;
|
|
198
|
+
const limit = optionalString(v.limit);
|
|
199
|
+
if (limit) args.limit = Number(limit);
|
|
200
|
+
return args;
|
|
201
|
+
},
|
|
202
|
+
},
|
|
178
203
|
};
|
|
179
204
|
|
|
180
205
|
function formatReadOutput(result: unknown, wantJson: boolean): string {
|
package/src/cli/remote-client.ts
CHANGED
|
@@ -37,6 +37,9 @@ export async function callTool(
|
|
|
37
37
|
method: "POST",
|
|
38
38
|
headers: {
|
|
39
39
|
"Content-Type": "application/json",
|
|
40
|
+
// MCP streamable-HTTP transport requires BOTH content types in Accept
|
|
41
|
+
// or it 406s with "Client must accept both application/json and
|
|
42
|
+
// text/event-stream". We handle whichever shape the server picks below.
|
|
40
43
|
"Accept": "application/json, text/event-stream",
|
|
41
44
|
Authorization: `Bearer ${creds.access_token}`,
|
|
42
45
|
},
|
|
@@ -54,12 +57,33 @@ export async function callTool(
|
|
|
54
57
|
throw new Error(`remote call failed: ${res.status} ${body}`);
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
const body =
|
|
60
|
+
const body = await parseJsonRpc(res);
|
|
58
61
|
if (body.error) throw new Error(`remote tool error: ${body.error.message}`);
|
|
59
62
|
if (!body.result) throw new Error("remote call returned no result");
|
|
60
63
|
return body.result;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
// The MCP streamable-HTTP transport content-negotiates on the request Accept:
|
|
67
|
+
// when the client offers both JSON and SSE (as required — see 406 above),
|
|
68
|
+
// the server may reply with either. JSON we parse directly; SSE arrives as
|
|
69
|
+
// one or more `event: message\ndata: <json>\n\n` frames — for a single
|
|
70
|
+
// tools/call response we take the first `data:` payload.
|
|
71
|
+
async function parseJsonRpc(res: Response): Promise<JsonRpcResponse> {
|
|
72
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
73
|
+
if (ct.includes("text/event-stream")) {
|
|
74
|
+
const text = await res.text();
|
|
75
|
+
const dataLine = text.split(/\r?\n/).find((l) => l.startsWith("data:"));
|
|
76
|
+
if (!dataLine) throw new Error(`remote call returned SSE with no data frame: ${text.slice(0, 200)}`);
|
|
77
|
+
const payload = dataLine.slice("data:".length).trim();
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(payload) as JsonRpcResponse;
|
|
80
|
+
} catch (e) {
|
|
81
|
+
throw new Error(`remote call SSE data frame is not valid JSON: ${(e as Error).message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return (await res.json()) as JsonRpcResponse;
|
|
85
|
+
}
|
|
86
|
+
|
|
63
87
|
/**
|
|
64
88
|
* Convenience: unwrap the MCP text envelope back to a string. Tools that
|
|
65
89
|
* return JSON encode it inside `content[0].text`; tools that return plain
|
|
@@ -17,6 +17,7 @@ export { renameDoc } from "./rename_doc";
|
|
|
17
17
|
export { patchPreamble } from "./patch_preamble";
|
|
18
18
|
export { appendDoc } from "./append_doc";
|
|
19
19
|
export { lintDoc } from "./lint_doc";
|
|
20
|
+
export { lintVault } from "./lint_vault";
|
|
20
21
|
export { distillDoc } from "./distill_doc";
|
|
21
22
|
export { createChild } from "./create_child";
|
|
22
23
|
export { addAssociation } from "./add_association";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { loadVaultIndex } from "./vault";
|
|
2
|
+
import { lintDocContent, type LintWarning } from "./lint";
|
|
3
|
+
import { buildLintVaultContext } from "./lint_doc";
|
|
4
|
+
import type { ToolContext } from "./types";
|
|
5
|
+
|
|
6
|
+
// SPRINT-101: batch lint the caller's entire vault in one shot.
|
|
7
|
+
//
|
|
8
|
+
// Reuses the vault index's in-memory `content` (already fetched by
|
|
9
|
+
// `loadVaultIndex`) so we run every per-doc + cross-doc rule without a
|
|
10
|
+
// single extra Storage/cache round-trip. On a ~1500-doc vault this is
|
|
11
|
+
// milliseconds, not seconds.
|
|
12
|
+
//
|
|
13
|
+
// Response shape:
|
|
14
|
+
// { scanned, with_warnings, warnings_by_code, docs: [{path, warnings[]}] }
|
|
15
|
+
//
|
|
16
|
+
// Prefix filter is optional — pass `prefix: "edmund/projects/emdee_os/"`
|
|
17
|
+
// to lint a subtree instead of the whole vault. Useful when a hygiene
|
|
18
|
+
// pass is scoped to one area and you don't want a punch list of every
|
|
19
|
+
// unrelated drift.
|
|
20
|
+
|
|
21
|
+
function json(value: unknown) {
|
|
22
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function lintVault(ctx: ToolContext, args: Record<string, unknown>): Promise<unknown> {
|
|
26
|
+
const prefix = typeof args.prefix === "string" && args.prefix.length > 0 ? args.prefix : null;
|
|
27
|
+
const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : null;
|
|
28
|
+
|
|
29
|
+
const index = await loadVaultIndex(ctx);
|
|
30
|
+
|
|
31
|
+
const perDoc: Array<{ path: string; warnings: LintWarning[] }> = [];
|
|
32
|
+
const byCode: Record<string, number> = {};
|
|
33
|
+
let scanned = 0;
|
|
34
|
+
|
|
35
|
+
for (const doc of index.docs) {
|
|
36
|
+
if (prefix && !doc.path.startsWith(prefix)) continue;
|
|
37
|
+
scanned++;
|
|
38
|
+
const lintCtx = buildLintVaultContext(index, doc.path);
|
|
39
|
+
const result = lintDocContent(doc.content, lintCtx);
|
|
40
|
+
if (result.warnings.length === 0) continue;
|
|
41
|
+
perDoc.push({ path: doc.path, warnings: result.warnings });
|
|
42
|
+
for (const w of result.warnings) {
|
|
43
|
+
byCode[w.code] = (byCode[w.code] ?? 0) + 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Sort docs with most warnings first — top of the punch list is where
|
|
48
|
+
// the operator's attention buys the most cleanup per unit of effort.
|
|
49
|
+
perDoc.sort((a, b) => b.warnings.length - a.warnings.length);
|
|
50
|
+
const docs = limit ? perDoc.slice(0, limit) : perDoc;
|
|
51
|
+
|
|
52
|
+
return json({
|
|
53
|
+
scanned,
|
|
54
|
+
with_warnings: perDoc.length,
|
|
55
|
+
warnings_total: Object.values(byCode).reduce((a, b) => a + b, 0),
|
|
56
|
+
warnings_by_code: byCode,
|
|
57
|
+
docs,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -134,18 +134,28 @@ export class SupabaseStorage implements VaultStorage {
|
|
|
134
134
|
if (!folder || folder.includes("/")) {
|
|
135
135
|
return this.walkFolder(folder);
|
|
136
136
|
}
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
137
|
+
const admin = adminClient();
|
|
138
|
+
// HARD RULE 6: PostgREST caps `.select()` at 1000 rows server-side.
|
|
139
|
+
// Vaults > 1000 docs silently truncate without an explicit range loop.
|
|
140
|
+
const PAGE = 1000;
|
|
141
|
+
const rows: Array<{ file_path: string; updated_at: string }> = [];
|
|
142
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
143
|
+
const { data, error } = await admin
|
|
144
|
+
.from(CACHE_TABLE)
|
|
145
|
+
.select("file_path, updated_at")
|
|
146
|
+
.eq("namespace", folder)
|
|
147
|
+
.order("file_path", { ascending: true })
|
|
148
|
+
.range(offset, offset + PAGE - 1);
|
|
149
|
+
if (error) return this.walkFolder(folder);
|
|
150
|
+
const page = (data ?? []) as Array<{ file_path: string; updated_at: string }>;
|
|
151
|
+
rows.push(...page);
|
|
152
|
+
if (page.length < PAGE) break;
|
|
147
153
|
}
|
|
148
|
-
return
|
|
154
|
+
return rows.map((r) => ({
|
|
155
|
+
path: `${folder}/${r.file_path}`,
|
|
156
|
+
content: "",
|
|
157
|
+
updatedAt: r.updated_at,
|
|
158
|
+
}));
|
|
149
159
|
});
|
|
150
160
|
}
|
|
151
161
|
|
|
@@ -186,13 +196,29 @@ export class SupabaseStorage implements VaultStorage {
|
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
const admin = adminClient();
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
199
|
+
// HARD RULE 6: PostgREST caps `.select()` at 1000 rows server-side.
|
|
200
|
+
// Vaults > 1000 docs silently truncate without an explicit range loop.
|
|
201
|
+
const PAGE = 1000;
|
|
202
|
+
const rows: Array<{ file_path: string; content: string; updated_at: string }> = [];
|
|
203
|
+
let queryError: unknown = null;
|
|
204
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
205
|
+
const { data, error } = await admin
|
|
206
|
+
.from(CACHE_TABLE)
|
|
207
|
+
.select("file_path, content, updated_at")
|
|
208
|
+
.eq("namespace", folder)
|
|
209
|
+
.order("file_path", { ascending: true })
|
|
210
|
+
.range(offset, offset + PAGE - 1);
|
|
211
|
+
if (error) {
|
|
212
|
+
queryError = error;
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
const page = (data ?? []) as Array<{ file_path: string; content: string; updated_at: string }>;
|
|
216
|
+
rows.push(...page);
|
|
217
|
+
if (page.length < PAGE) break;
|
|
218
|
+
}
|
|
193
219
|
|
|
194
|
-
if (!
|
|
195
|
-
return
|
|
220
|
+
if (!queryError && rows.length > 0) {
|
|
221
|
+
return rows.map((r) => ({
|
|
196
222
|
path: `${folder}/${r.file_path}`,
|
|
197
223
|
content: r.content,
|
|
198
224
|
updatedAt: r.updated_at,
|