@medicine-wheel/app 0.13.0 → 0.14.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/app/api/ceremonies/route.ts +65 -5
- package/app/api/diary/[id]/route.ts +31 -0
- package/app/api/diary/route.ts +130 -0
- package/app/api/narrative/beats/[id]/route.ts +55 -0
- package/app/api/narrative/beats/route.ts +2 -0
- package/app/nodes/page.tsx +2 -2
- package/lib/ceremony-response.ts +11 -0
- package/lib/jsonl-store.ts +3 -0
- package/lib/store.ts +33 -0
- package/package.json +26 -24
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { parseLimit } from "@/lib/api-paging";
|
|
3
3
|
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
4
|
-
import { ceremonyBelongsToEpisode } from "@/lib/ceremony-response";
|
|
4
|
+
import { ceremonyBelongsToEpisode, ceremonyEpisodePath } from "@/lib/ceremony-response";
|
|
5
|
+
|
|
6
|
+
/** Episode directory names are `YYYY-MM-DD-episode-NNN-slug`; nothing else may bind. */
|
|
7
|
+
const EPISODE_PATH = /^\d{4}-\d{2}-\d{2}-episode-\d{3,}-[a-z0-9-]+$/;
|
|
5
8
|
|
|
6
9
|
export async function GET(request: Request) {
|
|
7
10
|
try {
|
|
@@ -9,6 +12,8 @@ export async function GET(request: Request) {
|
|
|
9
12
|
const direction = searchParams.get("direction");
|
|
10
13
|
const type = searchParams.get("type");
|
|
11
14
|
const episodePath = searchParams.get("episode_path");
|
|
15
|
+
const circleId = searchParams.get("circle_id");
|
|
16
|
+
const closes = searchParams.get("closes");
|
|
12
17
|
|
|
13
18
|
const limit = parseLimit(searchParams.get("limit"));
|
|
14
19
|
if (limit instanceof NextResponse) return limit;
|
|
@@ -41,6 +46,15 @@ export async function GET(request: Request) {
|
|
|
41
46
|
);
|
|
42
47
|
}
|
|
43
48
|
|
|
49
|
+
if (circleId) {
|
|
50
|
+
ceremonies = ceremonies.filter((c) => c.circle_id === circleId);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// `?closes=<opening id>` answers "is this ceremony closed, and by which record".
|
|
54
|
+
if (closes) {
|
|
55
|
+
ceremonies = ceremonies.filter((c) => c.closes === closes);
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
const matched = ceremonies.length;
|
|
45
59
|
if (limit !== null && ceremonies.length > limit) {
|
|
46
60
|
ceremonies = ceremonies.slice(0, limit);
|
|
@@ -53,7 +67,7 @@ export async function GET(request: Request) {
|
|
|
53
67
|
// `total` is the whole store, `matched` what the filters selected. When
|
|
54
68
|
// count < matched the caller holds a page and can now see that it does.
|
|
55
69
|
total,
|
|
56
|
-
...(direction || type || episodePath ? { matched } : {}),
|
|
70
|
+
...(direction || type || episodePath || circleId || closes ? { matched } : {}),
|
|
57
71
|
truncated: ceremonies.length < matched,
|
|
58
72
|
});
|
|
59
73
|
} catch (error: unknown) {
|
|
@@ -66,7 +80,47 @@ export async function POST(request: Request) {
|
|
|
66
80
|
try {
|
|
67
81
|
const store = await createProvider();
|
|
68
82
|
const body = await request.json();
|
|
69
|
-
|
|
83
|
+
|
|
84
|
+
// Typed episode binding (0.14.0). A caller may still pass the legacy JSON
|
|
85
|
+
// string in `research_context`; it is lifted into the typed fields so every
|
|
86
|
+
// reader sees one shape. When both are given the typed fields win.
|
|
87
|
+
const legacy = ceremonyEpisodePath(body);
|
|
88
|
+
const episodePath: string | undefined =
|
|
89
|
+
typeof body.episode_path === "string" && body.episode_path ? body.episode_path : legacy;
|
|
90
|
+
if (episodePath !== undefined && !EPISODE_PATH.test(episodePath)) {
|
|
91
|
+
return NextResponse.json(
|
|
92
|
+
{ error: `Invalid episode_path: ${episodePath}`, hint: "YYYY-MM-DD-episode-NNN-slug" },
|
|
93
|
+
{ status: 400 },
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
const fromName = episodePath ? Number(episodePath.match(/-episode-(\d+)-/)?.[1]) : NaN;
|
|
97
|
+
const episodeNumber: number | undefined =
|
|
98
|
+
typeof body.episode_number === "number" ? body.episode_number : Number.isFinite(fromName) ? fromName : undefined;
|
|
99
|
+
|
|
100
|
+
if (body.closes !== undefined) {
|
|
101
|
+
if (typeof body.closes !== "string" || !body.closes) {
|
|
102
|
+
return NextResponse.json({ error: "closes must be a ceremony id" }, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
if (!(await store.getCeremony(body.closes))) {
|
|
105
|
+
return NextResponse.json(
|
|
106
|
+
{ error: `Cannot close a ceremony that does not exist: ${body.closes}` },
|
|
107
|
+
{ status: 404 },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (body.circle_id !== undefined) {
|
|
112
|
+
if (typeof body.circle_id !== "string" || !body.circle_id) {
|
|
113
|
+
return NextResponse.json({ error: "circle_id must be a node id" }, { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
const circle = await store.getNode(body.circle_id);
|
|
116
|
+
if (!circle) {
|
|
117
|
+
return NextResponse.json(
|
|
118
|
+
{ error: `Cannot hold a ceremony in a circle that does not exist: ${body.circle_id}`, hint: "Create the circle node first on /nodes." },
|
|
119
|
+
{ status: 404 },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
70
124
|
const ceremony = {
|
|
71
125
|
id: body.id || crypto.randomUUID(),
|
|
72
126
|
type: body.type,
|
|
@@ -74,10 +128,16 @@ export async function POST(request: Request) {
|
|
|
74
128
|
participants: body.participants ?? [],
|
|
75
129
|
medicines_used: body.medicines_used ?? [],
|
|
76
130
|
intentions: body.intentions ?? [],
|
|
77
|
-
timestamp: new Date().toISOString(),
|
|
131
|
+
timestamp: typeof body.timestamp === "string" && body.timestamp ? body.timestamp : new Date().toISOString(),
|
|
78
132
|
research_context: body.research_context,
|
|
133
|
+
...(Array.isArray(body.relations_honored) ? { relations_honored: body.relations_honored } : {}),
|
|
134
|
+
...(episodePath ? { episode_path: episodePath } : {}),
|
|
135
|
+
...(episodeNumber !== undefined ? { episode_number: episodeNumber } : {}),
|
|
136
|
+
...(typeof body.source === "string" && body.source ? { source: body.source } : {}),
|
|
137
|
+
...(typeof body.closes === "string" ? { closes: body.closes } : {}),
|
|
138
|
+
...(typeof body.circle_id === "string" ? { circle_id: body.circle_id } : {}),
|
|
79
139
|
};
|
|
80
|
-
|
|
140
|
+
|
|
81
141
|
await store.logCeremony(ceremony);
|
|
82
142
|
return NextResponse.json({ success: true, ceremony, provider: detectProvider() }, { status: 201 });
|
|
83
143
|
} catch (error: unknown) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
3
|
+
|
|
4
|
+
type RouteContext = { params: Promise<{ id: string }> };
|
|
5
|
+
|
|
6
|
+
export async function GET(_request: Request, context: RouteContext) {
|
|
7
|
+
const { id } = await context.params;
|
|
8
|
+
try {
|
|
9
|
+
const store = await createProvider();
|
|
10
|
+
const entry = await store.getDiaryEntry(id);
|
|
11
|
+
if (!entry) return NextResponse.json({ error: `No diary entry with id ${id}` }, { status: 404 });
|
|
12
|
+
return NextResponse.json({ entry, provider: detectProvider() });
|
|
13
|
+
} catch (error: unknown) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function DELETE(_request: Request, context: RouteContext) {
|
|
20
|
+
const { id } = await context.params;
|
|
21
|
+
try {
|
|
22
|
+
const store = await createProvider();
|
|
23
|
+
const entry = await store.getDiaryEntry(id);
|
|
24
|
+
if (!entry) return NextResponse.json({ error: `No diary entry with id ${id}` }, { status: 404 });
|
|
25
|
+
await store.deleteDiaryEntry(id);
|
|
26
|
+
return NextResponse.json({ success: true, deleted: { id }, provider: detectProvider() });
|
|
27
|
+
} catch (error: unknown) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { parseLimit } from "@/lib/api-paging";
|
|
3
|
+
import { createProvider, detectProvider } from "@medicine-wheel/storage-provider";
|
|
4
|
+
import type { DiaryEntryFilters, DiaryEntryRecord } from "@medicine-wheel/storage-provider";
|
|
5
|
+
import { createDiaryEntry } from "@medicine-wheel/ceremonial-diary";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The ceremonial diary door (0.14.0).
|
|
9
|
+
*
|
|
10
|
+
* The storage provider has carried diary records since 0.6 (`registerDiaryEntry`,
|
|
11
|
+
* `listDiaryEntries`); no route exposed them, so every consumer that wanted a
|
|
12
|
+
* diary kept its own jsonl beside the wheel. This door reads and writes the
|
|
13
|
+
* same collection the MCP and the provider share.
|
|
14
|
+
*
|
|
15
|
+
* A diary entry is a participant's voice inside a ceremony. It may name the
|
|
16
|
+
* ceremony it belongs to (`ceremony_id`, carried in metadata) and the chronicle
|
|
17
|
+
* episode it writes into (`chronicle`, `chronicle:<episode-folder>`); both are
|
|
18
|
+
* optional — the diary can speak whether or not it writes into an episode.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const PHASES = ["miigwechiwendam", "nindokendaan", "ningwaab", "nindoodam", "migwech"] as const;
|
|
22
|
+
const ENTRY_TYPES = ["intention", "observation", "hypothesis", "data", "synthesis", "action", "reflection", "learning"] as const;
|
|
23
|
+
|
|
24
|
+
type Phase = DiaryEntryRecord["phase"];
|
|
25
|
+
type EntryType = DiaryEntryRecord["entryType"];
|
|
26
|
+
|
|
27
|
+
function ceremonyOf(entry: DiaryEntryRecord): string | undefined {
|
|
28
|
+
const value = entry.metadata?.ceremony_id;
|
|
29
|
+
return typeof value === "string" && value ? value : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function GET(request: Request) {
|
|
33
|
+
try {
|
|
34
|
+
const { searchParams } = new URL(request.url);
|
|
35
|
+
const limit = parseLimit(searchParams.get("limit"));
|
|
36
|
+
if (limit instanceof NextResponse) return limit;
|
|
37
|
+
|
|
38
|
+
const participant = searchParams.get("participant") ?? undefined;
|
|
39
|
+
const phase = searchParams.get("phase") ?? undefined;
|
|
40
|
+
const entryType = searchParams.get("entryType") ?? searchParams.get("entry_type") ?? undefined;
|
|
41
|
+
const chronicle = searchParams.get("chronicle") ?? undefined;
|
|
42
|
+
const ceremonyId = searchParams.get("ceremony_id") ?? undefined;
|
|
43
|
+
const tags = searchParams.getAll("tag");
|
|
44
|
+
|
|
45
|
+
if (phase && !PHASES.includes(phase as Phase)) {
|
|
46
|
+
return NextResponse.json({ error: `Invalid phase: ${phase}`, hint: PHASES.join(", ") }, { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
if (entryType && !ENTRY_TYPES.includes(entryType as EntryType)) {
|
|
49
|
+
return NextResponse.json({ error: `Invalid entryType: ${entryType}`, hint: ENTRY_TYPES.join(", ") }, { status: 400 });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const filters: DiaryEntryFilters = {
|
|
53
|
+
...(participant ? { participant } : {}),
|
|
54
|
+
...(phase ? { phase: phase as Phase } : {}),
|
|
55
|
+
...(entryType ? { entryType: entryType as EntryType } : {}),
|
|
56
|
+
...(tags.length ? { tags } : {}),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const store = await createProvider();
|
|
60
|
+
const all = await store.listDiaryEntries(filters);
|
|
61
|
+
let entries = all;
|
|
62
|
+
if (chronicle) entries = entries.filter((e) => e.chronicle === chronicle);
|
|
63
|
+
if (ceremonyId) entries = entries.filter((e) => ceremonyOf(e) === ceremonyId);
|
|
64
|
+
const matched = entries.length;
|
|
65
|
+
if (limit !== null && entries.length > limit) entries = entries.slice(0, limit);
|
|
66
|
+
|
|
67
|
+
return NextResponse.json({
|
|
68
|
+
entries,
|
|
69
|
+
provider: detectProvider(),
|
|
70
|
+
count: entries.length,
|
|
71
|
+
total: all.length,
|
|
72
|
+
...(chronicle || ceremonyId ? { matched } : {}),
|
|
73
|
+
truncated: entries.length < matched,
|
|
74
|
+
});
|
|
75
|
+
} catch (error: unknown) {
|
|
76
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
77
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function POST(request: Request) {
|
|
82
|
+
try {
|
|
83
|
+
let body: Record<string, unknown>;
|
|
84
|
+
try {
|
|
85
|
+
body = (await request.json()) as Record<string, unknown>;
|
|
86
|
+
} catch {
|
|
87
|
+
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
|
|
88
|
+
}
|
|
89
|
+
const { participant, phase, entryType, content } = body;
|
|
90
|
+
if (typeof participant !== "string" || !participant.trim()) {
|
|
91
|
+
return NextResponse.json({ error: "participant is required" }, { status: 400 });
|
|
92
|
+
}
|
|
93
|
+
if (typeof phase !== "string" || !PHASES.includes(phase as Phase)) {
|
|
94
|
+
return NextResponse.json({ error: `phase must be one of: ${PHASES.join(", ")}` }, { status: 400 });
|
|
95
|
+
}
|
|
96
|
+
if (typeof entryType !== "string" || !ENTRY_TYPES.includes(entryType as EntryType)) {
|
|
97
|
+
return NextResponse.json({ error: `entryType must be one of: ${ENTRY_TYPES.join(", ")}` }, { status: 400 });
|
|
98
|
+
}
|
|
99
|
+
if (typeof content !== "string" || !content.trim()) {
|
|
100
|
+
return NextResponse.json({ error: "content is required" }, { status: 400 });
|
|
101
|
+
}
|
|
102
|
+
const metadata: Record<string, unknown> =
|
|
103
|
+
body.metadata && typeof body.metadata === "object" ? { ...(body.metadata as Record<string, unknown>) } : {};
|
|
104
|
+
const store = await createProvider();
|
|
105
|
+
if (typeof body.ceremony_id === "string" && body.ceremony_id) {
|
|
106
|
+
if (!(await store.getCeremony(body.ceremony_id))) {
|
|
107
|
+
return NextResponse.json(
|
|
108
|
+
{ error: `Cannot write into a ceremony that does not exist: ${body.ceremony_id}` },
|
|
109
|
+
{ status: 404 },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
metadata.ceremony_id = body.ceremony_id;
|
|
113
|
+
}
|
|
114
|
+
const entry = await createDiaryEntry(store, {
|
|
115
|
+
...(typeof body.id === "string" && body.id ? { id: body.id } : {}),
|
|
116
|
+
...(typeof body.timestamp === "string" && body.timestamp ? { timestamp: body.timestamp } : {}),
|
|
117
|
+
participant: participant.trim(),
|
|
118
|
+
phase: phase as Phase,
|
|
119
|
+
entryType: entryType as EntryType,
|
|
120
|
+
content,
|
|
121
|
+
metadata,
|
|
122
|
+
...(typeof body.agent === "string" && body.agent ? { agent: body.agent } : {}),
|
|
123
|
+
...(typeof body.chronicle === "string" && body.chronicle ? { chronicle: body.chronicle } : {}),
|
|
124
|
+
});
|
|
125
|
+
return NextResponse.json({ success: true, entry, provider: detectProvider() }, { status: 201 });
|
|
126
|
+
} catch (error: unknown) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getBeat, witnessBeat } from "@/lib/store";
|
|
4
|
+
|
|
5
|
+
type RouteContext = { params: Promise<{ id: string }> };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One beat, by id — and the witnessing door.
|
|
9
|
+
*
|
|
10
|
+
* In a talking circle a beat is one person's turn. Those who held the circle
|
|
11
|
+
* while it was spoken are its witnesses (`witnesses[]`, 0.14.0). PATCH adds
|
|
12
|
+
* witnesses (a set: repeating a name changes nothing) and may name the
|
|
13
|
+
* `speaker` when the beat was authored without one. Nothing else on a beat is
|
|
14
|
+
* editable here: what was said is what was said.
|
|
15
|
+
*/
|
|
16
|
+
const BeatPatchSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
witnesses: z.array(z.string().trim().min(1)).optional(),
|
|
19
|
+
speaker: z.string().trim().min(1).optional(),
|
|
20
|
+
})
|
|
21
|
+
.strict()
|
|
22
|
+
.refine((patch) => patch.witnesses !== undefined || patch.speaker !== undefined, {
|
|
23
|
+
message: "Provide witnesses to add, a speaker, or both.",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function notFound(id: string) {
|
|
27
|
+
return NextResponse.json({ error: `No beat with id ${id}` }, { status: 404 });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function GET(_request: Request, context: RouteContext) {
|
|
31
|
+
const { id } = await context.params;
|
|
32
|
+
const beat = getBeat(id);
|
|
33
|
+
if (!beat) return notFound(id);
|
|
34
|
+
return NextResponse.json(beat);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function PATCH(request: Request, context: RouteContext) {
|
|
38
|
+
const { id } = await context.params;
|
|
39
|
+
let body: unknown;
|
|
40
|
+
try {
|
|
41
|
+
body = await request.json();
|
|
42
|
+
} catch {
|
|
43
|
+
return NextResponse.json({ error: "Body must be JSON" }, { status: 400 });
|
|
44
|
+
}
|
|
45
|
+
const parsed = BeatPatchSchema.safeParse(body);
|
|
46
|
+
if (!parsed.success) {
|
|
47
|
+
return NextResponse.json(
|
|
48
|
+
{ error: "Invalid patch", issues: parsed.error.issues },
|
|
49
|
+
{ status: 400 },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const beat = witnessBeat(id, parsed.data);
|
|
53
|
+
if (!beat) return notFound(id);
|
|
54
|
+
return NextResponse.json(beat);
|
|
55
|
+
}
|
|
@@ -30,6 +30,8 @@ export async function POST(request: Request) {
|
|
|
30
30
|
parent_beat_id: body.parent_beat_id,
|
|
31
31
|
sub_beats: body.sub_beats,
|
|
32
32
|
origin: body.origin,
|
|
33
|
+
speaker: body.speaker,
|
|
34
|
+
witnesses: Array.isArray(body.witnesses) ? body.witnesses : undefined,
|
|
33
35
|
});
|
|
34
36
|
// Warnings ride on the created beat rather than replacing it, so clients
|
|
35
37
|
// that read the beat back by id keep working while advisory findings stop
|
package/app/nodes/page.tsx
CHANGED
|
@@ -15,9 +15,9 @@ import { toast } from "sonner";
|
|
|
15
15
|
// Storage returns description at the top level; legacy nodes carried it in metadata.
|
|
16
16
|
type NodeRecord = RelationalNode & { description?: string };
|
|
17
17
|
|
|
18
|
-
const NODE_TYPES: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge"];
|
|
18
|
+
const NODE_TYPES: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge", "circle"];
|
|
19
19
|
const DIRECTIONS: DirectionName[] = ["east", "south", "west", "north"];
|
|
20
|
-
const NODE_TYPE_ICONS: Record<NodeType, string> = { human: "👤", land: "🌍", spirit: "✨", ancestor: "🪶", future: "🌱", knowledge: "📚" };
|
|
20
|
+
const NODE_TYPE_ICONS: Record<NodeType, string> = { human: "👤", land: "🌍", spirit: "✨", ancestor: "🪶", future: "🌱", knowledge: "📚", circle: "⭕" };
|
|
21
21
|
const DIRECTION_ICONS: Record<DirectionName, string> = { east: "🌅", south: "🌞", west: "🌄", north: "❄️" };
|
|
22
22
|
|
|
23
23
|
function describeNode(node: NodeRecord): string {
|
package/lib/ceremony-response.ts
CHANGED
|
@@ -47,11 +47,22 @@ export function normalizeCeremonyLog(value: unknown): CeremonyLog | null {
|
|
|
47
47
|
...(typeof ceremony.research_context === "string"
|
|
48
48
|
? { research_context: ceremony.research_context }
|
|
49
49
|
: {}),
|
|
50
|
+
...(Array.isArray(ceremony.relations_honored)
|
|
51
|
+
? { relations_honored: asStringArray(ceremony.relations_honored) }
|
|
52
|
+
: {}),
|
|
53
|
+
...(typeof ceremony.episode_path === "string" ? { episode_path: ceremony.episode_path } : {}),
|
|
54
|
+
...(typeof ceremony.episode_number === "number" ? { episode_number: ceremony.episode_number } : {}),
|
|
55
|
+
...(typeof ceremony.source === "string" ? { source: ceremony.source } : {}),
|
|
56
|
+
...(typeof ceremony.closes === "string" ? { closes: ceremony.closes } : {}),
|
|
57
|
+
...(typeof ceremony.circle_id === "string" ? { circle_id: ceremony.circle_id } : {}),
|
|
50
58
|
};
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
export function ceremonyEpisodePath(value: unknown): string | undefined {
|
|
54
62
|
if (!value || typeof value !== "object") return undefined;
|
|
63
|
+
// Typed binding first (0.14.0), then the legacy JSON string in research_context.
|
|
64
|
+
const typed = (value as Record<string, unknown>).episode_path;
|
|
65
|
+
if (typeof typed === "string" && typed.length > 0) return typed;
|
|
55
66
|
const researchContext = (value as Record<string, unknown>).research_context;
|
|
56
67
|
if (typeof researchContext !== "string") return undefined;
|
|
57
68
|
|
package/lib/jsonl-store.ts
CHANGED
|
@@ -71,6 +71,9 @@ interface StoredBeat {
|
|
|
71
71
|
parent_beat_id?: string;
|
|
72
72
|
sub_beats?: string[];
|
|
73
73
|
origin?: { producer: string; source_ref?: string; method?: string };
|
|
74
|
+
/** Talking circle (0.14.0): who spoke, who witnessed. */
|
|
75
|
+
speaker?: string;
|
|
76
|
+
witnesses?: string[];
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
interface StoredCycle {
|
package/lib/store.ts
CHANGED
|
@@ -175,6 +175,8 @@ export function createBeat(
|
|
|
175
175
|
cycle_id: data.cycle_id,
|
|
176
176
|
parent_beat_id: data.parent_beat_id,
|
|
177
177
|
origin: data.origin ?? existing?.origin ?? { producer: 'rest' },
|
|
178
|
+
...(data.speaker !== undefined ? { speaker: data.speaker } : {}),
|
|
179
|
+
...(data.witnesses !== undefined ? { witnesses: data.witnesses } : {}),
|
|
178
180
|
};
|
|
179
181
|
|
|
180
182
|
// `strictAct` turns an act that contradicts the direction into an error
|
|
@@ -332,3 +334,34 @@ export function seedDemoData() {
|
|
|
332
334
|
|
|
333
335
|
// Auto-seed on import
|
|
334
336
|
seedDemoData();
|
|
337
|
+
|
|
338
|
+
// ── Talking-circle doors on beats (0.14.0) ──
|
|
339
|
+
|
|
340
|
+
/** One beat by id, normalized like the collection reads. */
|
|
341
|
+
export function getBeat(id: string): NarrativeBeat | undefined {
|
|
342
|
+
const raw = store.getBeat(id);
|
|
343
|
+
if (!raw) return undefined;
|
|
344
|
+
return extractBeats([raw])[0];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Add witnesses to a beat (a set: a name already present is not repeated) and,
|
|
349
|
+
* when given, name its speaker. Returns the stored beat, or undefined when
|
|
350
|
+
* there is no such beat. What was said is never rewritten here.
|
|
351
|
+
*/
|
|
352
|
+
export function witnessBeat(
|
|
353
|
+
id: string,
|
|
354
|
+
patch: { witnesses?: string[]; speaker?: string },
|
|
355
|
+
): NarrativeBeat | undefined {
|
|
356
|
+
const raw = store.getBeat(id) as (NarrativeBeat & Record<string, unknown>) | undefined;
|
|
357
|
+
if (!raw) return undefined;
|
|
358
|
+
const witnesses = new Set<string>(Array.isArray(raw.witnesses) ? raw.witnesses : []);
|
|
359
|
+
for (const w of patch.witnesses ?? []) witnesses.add(w);
|
|
360
|
+
const next = {
|
|
361
|
+
...raw,
|
|
362
|
+
...(patch.speaker !== undefined ? { speaker: patch.speaker } : {}),
|
|
363
|
+
...(witnesses.size > 0 ? { witnesses: [...witnesses] } : {}),
|
|
364
|
+
};
|
|
365
|
+
store.createBeat(next as any);
|
|
366
|
+
return extractBeats([next])[0];
|
|
367
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mw": "dist/cli/mw.js",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"src/session-reader",
|
|
32
32
|
"src/ceremony-protocol",
|
|
33
33
|
"src/storage-provider",
|
|
34
|
+
"src/client",
|
|
34
35
|
"src/data-store",
|
|
35
36
|
"src/data-store-postgres",
|
|
36
37
|
"src/graph-viz",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"src/creative-problem-solving",
|
|
48
49
|
"src/community-review",
|
|
49
50
|
"src/consent-lifecycle",
|
|
51
|
+
"src/community-identity",
|
|
50
52
|
"src/fire-keeper",
|
|
51
53
|
"src/transformation-tracker",
|
|
52
54
|
"src/ceremonial-diary",
|
|
@@ -86,29 +88,29 @@
|
|
|
86
88
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
87
89
|
},
|
|
88
90
|
"dependencies": {
|
|
89
|
-
"@medicine-wheel/ceremonial-diary": "^0.
|
|
90
|
-
"@medicine-wheel/ceremony-protocol": "^0.
|
|
91
|
-
"@medicine-wheel/community-review": "^0.
|
|
92
|
-
"@medicine-wheel/consent-lifecycle": "^0.
|
|
93
|
-
"@medicine-wheel/creative-orientation": "^0.
|
|
94
|
-
"@medicine-wheel/data-store": "^0.
|
|
95
|
-
"@medicine-wheel/data-store-postgres": "^0.
|
|
96
|
-
"@medicine-wheel/fire-keeper": "^0.
|
|
97
|
-
"@medicine-wheel/github-ceremony": "^0.
|
|
98
|
-
"@medicine-wheel/graph-viz": "^0.
|
|
99
|
-
"@medicine-wheel/importance-unit": "^0.
|
|
100
|
-
"@medicine-wheel/mcp": "^4.
|
|
101
|
-
"@medicine-wheel/narrative-cluster": "^0.
|
|
102
|
-
"@medicine-wheel/narrative-engine": "^0.
|
|
103
|
-
"@medicine-wheel/ontology-core": "^0.
|
|
104
|
-
"@medicine-wheel/perception-layer": "^0.
|
|
105
|
-
"@medicine-wheel/prompt-decomposition": "^0.
|
|
106
|
-
"@medicine-wheel/relational-index": "^0.
|
|
107
|
-
"@medicine-wheel/relational-query": "^0.
|
|
108
|
-
"@medicine-wheel/session-reader": "^0.
|
|
109
|
-
"@medicine-wheel/storage-provider": "^0.
|
|
110
|
-
"@medicine-wheel/transformation-tracker": "^0.
|
|
111
|
-
"@medicine-wheel/ui-components": "^0.
|
|
91
|
+
"@medicine-wheel/ceremonial-diary": "^0.14.0",
|
|
92
|
+
"@medicine-wheel/ceremony-protocol": "^0.14.0",
|
|
93
|
+
"@medicine-wheel/community-review": "^0.14.0",
|
|
94
|
+
"@medicine-wheel/consent-lifecycle": "^0.14.0",
|
|
95
|
+
"@medicine-wheel/creative-orientation": "^0.14.0",
|
|
96
|
+
"@medicine-wheel/data-store": "^0.14.0",
|
|
97
|
+
"@medicine-wheel/data-store-postgres": "^0.14.0",
|
|
98
|
+
"@medicine-wheel/fire-keeper": "^0.14.0",
|
|
99
|
+
"@medicine-wheel/github-ceremony": "^0.14.0",
|
|
100
|
+
"@medicine-wheel/graph-viz": "^0.14.0",
|
|
101
|
+
"@medicine-wheel/importance-unit": "^0.14.0",
|
|
102
|
+
"@medicine-wheel/mcp": "^4.14.0",
|
|
103
|
+
"@medicine-wheel/narrative-cluster": "^0.14.0",
|
|
104
|
+
"@medicine-wheel/narrative-engine": "^0.14.0",
|
|
105
|
+
"@medicine-wheel/ontology-core": "^0.14.0",
|
|
106
|
+
"@medicine-wheel/perception-layer": "^0.14.0",
|
|
107
|
+
"@medicine-wheel/prompt-decomposition": "^0.14.0",
|
|
108
|
+
"@medicine-wheel/relational-index": "^0.14.0",
|
|
109
|
+
"@medicine-wheel/relational-query": "^0.14.0",
|
|
110
|
+
"@medicine-wheel/session-reader": "^0.14.0",
|
|
111
|
+
"@medicine-wheel/storage-provider": "^0.14.0",
|
|
112
|
+
"@medicine-wheel/transformation-tracker": "^0.14.0",
|
|
113
|
+
"@medicine-wheel/ui-components": "^0.14.0",
|
|
112
114
|
"@neondatabase/serverless": "^0.10.0",
|
|
113
115
|
"@xyflow/react": "^12.3.0",
|
|
114
116
|
"clsx": "^2.1.1",
|