Skip to content

Commit 1e01852

Browse files
authored
Merge pull request #1234 from getlarge/issue-1231-typed-zone-schema
fix(mcp): typed Zone schema for entries_map_open (fix empty diary-map zones)
2 parents a57fe09 + d240141 commit 1e01852

6 files changed

Lines changed: 238 additions & 75 deletions

File tree

apps/mcp-host-e2e/src/entry-map.spec.ts

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,30 +28,37 @@ async function seedEntry(content: string, tags: string[]): Promise<string> {
2828
return data.id;
2929
}
3030

31+
// Seeded entries with KNOWN content — the round-trip test asserts these exact
32+
// strings appear in the rendered mosaic, proving entry_ids actually resolved
33+
// (not just that a zone card rendered). The titles double as the assertion.
34+
const INFRA_DRIZZLE = 'Chose Drizzle migrations over raw SQL.';
35+
const INFRA_KETO = 'Adopted Ory Keto for authorization.';
36+
const AUTONOMY = 'Reflected on agent autonomy boundaries.';
37+
3138
test.beforeAll(async () => {
3239
harness = await createMcpTestHarness();
3340
client = createClient({ baseUrl: harness.restApiUrl });
3441
seededEntryIds.push(
35-
await seedEntry('Chose Drizzle migrations over raw SQL.', [
36-
'scope:infra',
37-
'decision',
38-
]),
39-
await seedEntry('Adopted Ory Keto for authorization.', [
40-
'scope:infra',
41-
'scope:auth',
42-
]),
43-
await seedEntry('Reflected on agent autonomy boundaries.', [
44-
'topic:autonomy',
45-
'reflection',
46-
]),
42+
await seedEntry(INFRA_DRIZZLE, ['scope:infra', 'decision']),
43+
await seedEntry(INFRA_KETO, ['scope:infra', 'scope:auth']),
44+
await seedEntry(AUTONOMY, ['topic:autonomy', 'reflection']),
4745
);
4846
});
4947

5048
test.afterAll(async () => {
5149
await harness?.teardown();
5250
});
5351

54-
/** Build the `args` for entries_map_open with a fully-formed map of one zone. */
52+
/**
53+
* The map an interpreting agent would push to `entries_map_open`. It uses the
54+
* CANONICAL contract field names from EntryMapZoneSchema (snake_case
55+
* `entry_ids`, `why`, `territory`) — the same schema the server validates this
56+
* payload against at the tool boundary. If a field name here drifts from the
57+
* schema, the round-trip fails: the server rejects it, or the mosaic renders
58+
* empty and the content assertion below fails. (The empty-zones bug was an
59+
* opaque `Array(Unknown)` schema + a fixture that hand-wrote a field name the
60+
* app didn't read; this fixture is validated by the real server instead.)
61+
*/
5562
function mapArgs(): string {
5663
return JSON.stringify({
5764
diary_id: harness.privateDiaryId,
@@ -66,11 +73,7 @@ function mapArgs(): string {
6673
label: 'Infra decisions',
6774
why: 'Database and authorization choices.',
6875
territory: 'scope:infra',
69-
entryIds: seededEntryIds.slice(0, 2),
70-
provenance: {
71-
basis: 'tag:scope:infra',
72-
searches: [{ tags: ['scope:infra'] }],
73-
},
76+
entry_ids: seededEntryIds.slice(0, 2), // the two scope:infra entries
7477
},
7578
],
7679
},
@@ -110,28 +113,53 @@ test('mounts the diary map app and serves its resource', async ({ page }) => {
110113
await expect(frame.locator('.zone-card')).toContainText('Infra decisions');
111114
});
112115

113-
test('focusing a zone shows its entry mosaic with a legible count', async ({
116+
/**
117+
* The full contract round-trip — the test that would have caught the
118+
* empty-zones bug. It is deliberately NOT self-confirming: instead of asserting
119+
* "a mosaic element exists", it asserts the mosaic renders the EXACT CONTENT of
120+
* the seeded entries the zone's `entry_ids` point at. That only passes if:
121+
* 1. the agent map (canonical entry_ids) survives `entries_map_open`'s
122+
* server-side TypeBox validation,
123+
* 2. the app parser reads `entry_ids`,
124+
* 3. the adapter calls `entries_list` with those ids over the host bridge, and
125+
* 4. diary-ui renders the resolved entries.
126+
* A field-name drift anywhere in that chain leaves the mosaic empty → fail.
127+
*/
128+
test('focusing a zone resolves its entry_ids to the real seeded entries', async ({
114129
page,
115130
}) => {
116131
await openMap(page);
117132
const frame = appFrame(page);
118133

134+
// Agent → app: click the zone the agent labeled.
119135
await frame.locator('.zone-card', { hasText: 'Infra decisions' }).click();
120136

121-
// "What am I looking at": header states the zone + a showing-N count.
137+
// "What am I looking at": header + an honest "showing N of M" count.
122138
await expect(frame.locator('.view-title')).toContainText('Infra decisions');
123-
await expect(frame.locator('.view-count')).toContainText('showing');
124-
// The mosaic resolves the two seeded infra entries by id.
125-
await expect(frame.locator('.mosaic')).toBeVisible();
139+
await expect(frame.locator('.view-count')).toContainText('showing 2 of 2');
140+
141+
// The PROOF: the two scope:infra entries' actual content is on screen,
142+
// meaning entry_ids round-tripped all the way to rendered cards.
143+
const mosaic = frame.locator('.mosaic');
144+
await expect(mosaic).toContainText(INFRA_DRIZZLE);
145+
await expect(mosaic).toContainText(INFRA_KETO);
146+
// The autonomy entry is NOT in this zone, so it must not appear.
147+
await expect(mosaic).not.toContainText(AUTONOMY);
126148
});
127149

150+
/**
151+
* Curation round-trip: saving a zone materializes it as an unpinned draft
152+
* context pack (pack id resolved from the create CID via packs_provenance),
153+
* then the affordance offers to validate (pin) it.
154+
*/
128155
test('saving a zone materializes an unpinned draft pack', async ({ page }) => {
129156
await openMap(page);
130157
const frame = appFrame(page);
131158

132159
await frame.locator('.zone-card', { hasText: 'Infra decisions' }).click();
133160
await frame.locator('.pivot.save', { hasText: 'Save this zone' }).click();
134161

135-
// After save the button advances to the validate affordance.
162+
// After save the button advances to the validate affordance (no error).
136163
await expect(frame.locator('.pivot.save')).toContainText('validate');
164+
await expect(frame.locator('.next-steps-error')).toHaveCount(0);
137165
});

apps/mcp-server/src/entry-explore-app.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,13 @@ export function registerEntryExploreApp(fastify: FastifyInstance): void {
120120
'Open the interactive MoltNet diary map app — a human-first way to make ' +
121121
'sense of a large diary. Use it when a user wants to understand what is ' +
122122
'in their diary, discover knowledge zones, or be reminded of past ' +
123-
'decisions/research. After opening, interpret the diary by sampling with ' +
124-
'entries_list/diary_tags/entries_search, then push a map of labeled zones ' +
125-
'to the app for the user to explore and curate into draft packs.',
123+
'decisions/research. Workflow: sample the diary with ' +
124+
'diary_tags/entries_list/entries_search, group entries into 3-8 labeled ' +
125+
'zones, and pass them in map.zones. Each zone MUST set entry_ids to the ' +
126+
'REAL entry UUIDs (the id field from your entries_list/entries_search ' +
127+
'results) — these populate the zone mosaic; a zone with empty entry_ids ' +
128+
'renders blank. Do not put titles or tags in entry_ids. The user then ' +
129+
'explores zones and curates them into draft packs.',
126130
inputSchema: EntryMapOpenSchema,
127131
outputSchema: EntryMapOpenOutputSchema,
128132
_meta: createMcpAppToolMeta(ENTRY_MAP_APP_RESOURCE_URI),

apps/mcp-server/src/schemas/entry-explore-schemas.ts

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,115 @@
11
import type { Static } from '@sinclair/typebox';
22
import { Type } from '@sinclair/typebox';
33

4+
/**
5+
* One search that contributed entries to a zone — recorded for reproducibility
6+
* and carried into the pack `params` when a zone is saved. snake_case is the
7+
* canonical wire shape (the app maps it to its camelCase internal type).
8+
*/
9+
export const EntryMapZoneSearchSchema = Type.Object({
10+
query: Type.Optional(Type.String()),
11+
tags: Type.Optional(Type.Array(Type.String())),
12+
exclude_tags: Type.Optional(Type.Array(Type.String())),
13+
entry_types: Type.Optional(Type.Array(Type.String())),
14+
weights: Type.Optional(
15+
Type.Object({
16+
relevance: Type.Optional(Type.Number()),
17+
recency: Type.Optional(Type.Number()),
18+
importance: Type.Optional(Type.Number()),
19+
}),
20+
),
21+
});
22+
export type EntryMapZoneSearch = Static<typeof EntryMapZoneSearchSchema>;
23+
24+
export const EntryMapZoneProvenanceSchema = Type.Object({
25+
basis: Type.Optional(
26+
Type.String({
27+
description: 'Human-language selection basis, e.g. "tag:auth + recent".',
28+
}),
29+
),
30+
searches: Type.Optional(
31+
Type.Array(EntryMapZoneSearchSchema, {
32+
description:
33+
'The searches that produced this zone (for reproducibility).',
34+
}),
35+
),
36+
});
37+
38+
/**
39+
* A single agent-interpreted knowledge zone in the diary map.
40+
*
41+
* This schema is the CONTRACT with the interpreting agent: it must be explicit
42+
* enough that the model knows exactly which fields to send (especially that
43+
* `entry_ids` carries REAL entry UUIDs, not labels). A prior opaque
44+
* `Array(Unknown)` let the agent invent field names (`anchorEntries`, `summary`)
45+
* that the app silently dropped, rendering every zone with zero entries.
46+
*/
47+
export const EntryMapZoneSchema = Type.Object({
48+
id: Type.String({
49+
description: 'Stable zone id (any unique slug, e.g. "infra-decisions").',
50+
}),
51+
label: Type.String({
52+
description: 'Short human-language zone title shown on the card.',
53+
}),
54+
why: Type.Optional(
55+
Type.String({
56+
description:
57+
'One sentence explaining why these entries belong together (the card subtitle).',
58+
}),
59+
),
60+
entry_ids: Type.Array(Type.String(), {
61+
description:
62+
'REQUIRED. The actual entry UUIDs that belong to this zone — copy them ' +
63+
'verbatim from the `id` field of entries_list / entries_search results. ' +
64+
"These populate the zone's entry mosaic; an empty array renders an empty " +
65+
'zone. Do NOT put titles, tags, or labels here.',
66+
}),
67+
territory: Type.Optional(
68+
Type.String({
69+
description:
70+
'Optional governing tag/namespace for the zone, e.g. "scope:infra".',
71+
}),
72+
),
73+
tags: Type.Optional(
74+
Type.Array(Type.String(), {
75+
description: 'Optional representative tags for the zone.',
76+
}),
77+
),
78+
size: Type.Optional(
79+
Type.Integer({
80+
description:
81+
'Optional approximate total entries in this zone (may exceed entry_ids.length when sampled).',
82+
}),
83+
),
84+
provenance: Type.Optional(EntryMapZoneProvenanceSchema),
85+
});
86+
export type EntryMapZone = Static<typeof EntryMapZoneSchema>;
87+
88+
export const EntryMapDataSchema = Type.Object(
89+
{
90+
diaryName: Type.Optional(Type.String()),
91+
totalEntries: Type.Optional(Type.Integer()),
92+
sampledEntries: Type.Optional(Type.Integer()),
93+
overview: Type.Optional(
94+
Type.String({
95+
description:
96+
"1-3 sentence orientation shown on first paint ('your diary has N zones …').",
97+
}),
98+
),
99+
zones: Type.Optional(
100+
Type.Array(EntryMapZoneSchema, {
101+
description: 'The interpreted knowledge zones, 3-8 recommended.',
102+
}),
103+
),
104+
},
105+
{
106+
description:
107+
'Fully-formed diary map (overview + typed zones) for the app to render ' +
108+
'immediately. Each zone MUST include real entry_ids (entry UUIDs). Omit ' +
109+
'the whole map to open in the "waiting for interpretation" state.',
110+
},
111+
);
112+
4113
/**
5114
* Input for `entries_map_open` — the thin opener that mounts the diary map MCP
6115
* app. The tool is intentionally deterministic: it echoes these inputs into a
@@ -29,25 +138,7 @@ export const EntryMapOpenSchema = Type.Object({
29138
'Optional one-sentence framing of what the user wants to find or be reminded of.',
30139
}),
31140
),
32-
map: Type.Optional(
33-
Type.Object(
34-
{
35-
diaryName: Type.Optional(Type.String()),
36-
totalEntries: Type.Optional(Type.Integer()),
37-
sampledEntries: Type.Optional(Type.Integer()),
38-
overview: Type.Optional(Type.String()),
39-
zones: Type.Optional(Type.Array(Type.Unknown())),
40-
},
41-
{
42-
additionalProperties: true,
43-
description:
44-
'Optional fully-formed diary map (overview + labeled zones) for the app to ' +
45-
'render immediately. Pass this when you have already interpreted the diary ' +
46-
'and want first paint to show zones without a follow-up push. Omit it to ' +
47-
'have the app open in its "waiting for interpretation" state.',
48-
},
49-
),
50-
),
141+
map: Type.Optional(EntryMapDataSchema),
51142
});
52143
export type EntryMapOpenInput = Static<typeof EntryMapOpenSchema>;
53144

@@ -62,7 +153,7 @@ export const EntryMapOpenOutputSchema = Type.Object({
62153
totalEntries: Type.Optional(Type.Integer()),
63154
sampledEntries: Type.Optional(Type.Integer()),
64155
overview: Type.Optional(Type.String()),
65-
zones: Type.Optional(Type.Array(Type.Unknown())),
156+
zones: Type.Optional(Type.Array(EntryMapZoneSchema)),
66157
tools: Type.Array(Type.String()),
67158
});
68159
export type EntryMapOpenOutput = Static<typeof EntryMapOpenOutputSchema>;

docs/understand/knowledge-factory.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,17 @@ The primary path is **agent-curated**: an agent runs discovery against the diary
5151

5252
Supersession chains work at pack level too: a new pack can point at the prior one via `supersedes_pack_id`, which lets you track "the architecture pack evolved as we re-scanned the codebase" as first-class lineage.
5353

54-
How to discover candidate entries and assemble a good pack by hand is in [Context Packs](../use/context-packs). This page stays on the _why_; that one is the _how_.
54+
### The diary map: one way to explore and curate
55+
56+
Curation needs a discovery step, and there is more than one way to do it — `entries_search`/`diary_tags` directly, the explore skill's tag inventory, the console's filter bar, or, for a human who can't hold a 2,000-entry diary in their head, the **diary map** MCP app (`entries_map_open`). It is a _human-first_ surface for the same agent-curated path above:
57+
58+
1. The client agent samples the diary (`diary_tags` + `entries_list`/`entries_search`) and interprets it into a handful of labeled **zones** — each zone is a set of real entry ids grouped by a theme, with the search provenance that produced it.
59+
2. The human browses zones, reads the representative entries, and refines.
60+
3. **Saving a zone materializes it as an unpinned draft `custom` pack** — the zone's entry ids become the pack selection, and its `provenance.searches` are written into the pack `params`, so the bundle is reproducible from how it was found. Validating the zone pins the pack.
61+
62+
So the map is not a separate subsystem: it is a visual, in-chat way to drive the Condense step, ending in exactly the same content-addressed `custom` pack an agent would build by hand. The interpretation (which zones exist, which entries belong) stays in the client agent — the server only retrieves and packs (no server-side LLM). The agent passes zones to the app through a typed contract; each zone **must** carry the real entry UUIDs (`entry_ids`) so they resolve to content, not just labels.
63+
64+
How to discover candidate entries and assemble a good pack by hand is in [Context Packs](../use/context-packs). The diary map's tool contract and host display behavior are in the [MCP server reference](../reference/mcp-server#mcp-apps). This page stays on the _why_; those are the _how_.
5565

5666
## Surface
5767

0 commit comments

Comments
 (0)