@medicine-wheel/app 0.17.2 → 0.17.4
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 +2 -2
- package/app/episodes/[id]/page.tsx +80 -61
- package/app/episodes/page.tsx +102 -10
- package/components/episode-lineage-web.tsx +247 -0
- package/lib/episode-lineage.ts +165 -0
- package/package.json +27 -27
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ Ceremony lifecycle protocol — manages ceremony state, four-phase transitions (
|
|
|
95
95
|
- **Dependencies:** `@medicine-wheel/ontology-core`
|
|
96
96
|
|
|
97
97
|
### [@medicine-wheel/fire-keeper](src/fire-keeper)
|
|
98
|
-
Fire Keeper coordination agent — tends the ceremony fire
|
|
98
|
+
Fire Keeper coordination agent — tends the ceremony fire through gating conditions (consent, ceremony phase, trajectory confidence), permission tier escalation, and human decision points; it evaluates, gates, routes, and escalates. It does not measure relational accountability; people judge that (#155).
|
|
99
99
|
|
|
100
100
|
- **Dependencies:** `@medicine-wheel/ontology-core`, `@medicine-wheel/ceremony-protocol`
|
|
101
101
|
|
|
@@ -130,7 +130,7 @@ Four-source epistemic dimensional indexing — Land, Dream, Code, Vision travers
|
|
|
130
130
|
- **Dependencies:** `@medicine-wheel/ontology-core`
|
|
131
131
|
|
|
132
132
|
### [@medicine-wheel/transformation-tracker](src/transformation-tracker)
|
|
133
|
-
Research transformation tracking — Wilson
|
|
133
|
+
Research transformation tracking — built on the lesson Wilson quotes from his co-researchers, "If research doesn't change you as a person, then you haven't done it right" (2008, p. 135); the validity check is ours. Tracks researcher growth, community impact, relational shifts, reciprocity balance, and seven-generation sustainability.
|
|
134
134
|
|
|
135
135
|
- **Dependencies:** `@medicine-wheel/ontology-core`, `@medicine-wheel/ceremony-protocol`, `zod`
|
|
136
136
|
|
|
@@ -12,14 +12,15 @@
|
|
|
12
12
|
* that filtered by episode long before any page asked them
|
|
13
13
|
* (`components/episode-holdings.tsx`). What is open, from
|
|
14
14
|
* `?kind=attention&parent_id=<id>`. What it relates to, from
|
|
15
|
-
* `/api/nodes/[id]/web
|
|
16
|
-
*
|
|
15
|
+
* `/api/nodes/[id]/web`: its lineage first — each relation to another episode
|
|
16
|
+
* read from this episode's end ("continues from", "continued by") with the
|
|
17
|
+
* words that say why it holds — then everything else it touches
|
|
18
|
+
* (`lib/episode-lineage.ts`). Ref jgwill/medicine-wheel#153, #156.
|
|
17
19
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* box reads as "this episode touches nothing" when the truth is "the edge was
|
|
20
|
+
* Containment links that live only in `metadata.parent_id` were never written
|
|
21
|
+
* as edges, so an episode can show a parent with no relation behind it. The
|
|
22
|
+
* page names that rather than rendering an empty box, because an empty box
|
|
23
|
+
* reads as "this episode touches nothing" when the truth is "the edge was
|
|
23
24
|
* never written".
|
|
24
25
|
*/
|
|
25
26
|
|
|
@@ -29,6 +30,13 @@ import Link from "next/link";
|
|
|
29
30
|
import type { RelationalNode, RelationalEdge } from "@/lib/types";
|
|
30
31
|
import { EpisodeFilesPanel } from "@/components/episode-files-panel";
|
|
31
32
|
import { EpisodeHoldings } from "@/components/episode-holdings";
|
|
33
|
+
import {
|
|
34
|
+
chronicleKey,
|
|
35
|
+
episodeFolderOfNode,
|
|
36
|
+
relationsOf,
|
|
37
|
+
type EpisodeRelation,
|
|
38
|
+
type LineageEdge,
|
|
39
|
+
} from "@/lib/episode-lineage";
|
|
32
40
|
|
|
33
41
|
// `description` lives on the store's node, not on ontology-core's — the same
|
|
34
42
|
// widening `app/nodes/page.tsx` declares as `NodeRecord`. Named EpisodeNode
|
|
@@ -50,31 +58,39 @@ function metaString(node: EpisodeNode | null, key: string): string | null {
|
|
|
50
58
|
return typeof value === "string" && value ? value : null;
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
61
|
+
/** One relation, as read from this episode, with the words that say why it holds. */
|
|
62
|
+
function RelationRow({
|
|
63
|
+
relation,
|
|
64
|
+
node,
|
|
65
|
+
episode = false,
|
|
66
|
+
}: {
|
|
67
|
+
relation: EpisodeRelation;
|
|
68
|
+
node?: EpisodeNode;
|
|
69
|
+
episode?: boolean;
|
|
70
|
+
}) {
|
|
71
|
+
const name = node?.name ?? relation.other;
|
|
72
|
+
return (
|
|
73
|
+
<li className="p-3 border rounded-lg bg-card">
|
|
74
|
+
<p className="text-sm">
|
|
75
|
+
<span className="text-muted-foreground">{relation.label}</span>{" "}
|
|
76
|
+
{episode ? (
|
|
77
|
+
<Link href={`/episodes/${encodeURIComponent(relation.other)}`} className="font-medium hover:underline">
|
|
78
|
+
{name}
|
|
79
|
+
</Link>
|
|
80
|
+
) : (
|
|
81
|
+
<span className="font-medium">{name}</span>
|
|
82
|
+
)}
|
|
83
|
+
</p>
|
|
84
|
+
{!episode && (
|
|
85
|
+
<span className="font-mono text-xs text-muted-foreground block break-all">{relation.other}</span>
|
|
86
|
+
)}
|
|
87
|
+
{relation.descriptions.map((said) => (
|
|
88
|
+
<p key={said} className="text-sm text-muted-foreground mt-1">
|
|
89
|
+
{said}
|
|
90
|
+
</p>
|
|
91
|
+
))}
|
|
92
|
+
</li>
|
|
93
|
+
);
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
function EpisodeLink({ node, label }: { node: EpisodeNode | null; label: string }) {
|
|
@@ -140,10 +156,18 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
140
156
|
}, [load]);
|
|
141
157
|
|
|
142
158
|
const parentId = metaString(episode, "parent_id");
|
|
143
|
-
const folder = episode ?
|
|
159
|
+
const folder = episode ? episodeFolderOfNode(episode) : null;
|
|
144
160
|
const neighbours = (web?.nodes ?? []).filter((n) => n.id !== id);
|
|
145
161
|
const parentHasEdge = Boolean(parentId && neighbours.some((n) => n.id === parentId));
|
|
146
162
|
|
|
163
|
+
// Each relation read from this episode's end, with the words that say why it
|
|
164
|
+
// holds; episodes first (the lineage), then everything else it touches.
|
|
165
|
+
const nodeById = new Map(neighbours.map((n) => [n.id, n]));
|
|
166
|
+
const relations = relationsOf(id, (web?.edges ?? []) as LineageEdge[]);
|
|
167
|
+
const isEpisodeId = (other: string) => nodeById.get(other)?.metadata?.kind === "chronicle_episode";
|
|
168
|
+
const lineage = relations.filter((r) => isEpisodeId(r.other));
|
|
169
|
+
const elsewhere = relations.filter((r) => !isEpisodeId(r.other));
|
|
170
|
+
|
|
147
171
|
if (loading) {
|
|
148
172
|
return <div className="p-6 max-w-4xl mx-auto text-sm text-muted-foreground">Loading…</div>;
|
|
149
173
|
}
|
|
@@ -231,7 +255,24 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
231
255
|
|
|
232
256
|
<section>
|
|
233
257
|
<h2 className="text-sm font-semibold mb-2">
|
|
234
|
-
|
|
258
|
+
Lineage {lineage.length > 0 && `(${lineage.length})`}
|
|
259
|
+
</h2>
|
|
260
|
+
{lineage.length === 0 ? (
|
|
261
|
+
<p className="text-sm text-muted-foreground">
|
|
262
|
+
No relation to another episode has been woven yet.
|
|
263
|
+
</p>
|
|
264
|
+
) : (
|
|
265
|
+
<ul className="space-y-2">
|
|
266
|
+
{lineage.map((r) => (
|
|
267
|
+
<RelationRow key={`${r.other}-${r.label}`} relation={r} node={nodeById.get(r.other)} episode />
|
|
268
|
+
))}
|
|
269
|
+
</ul>
|
|
270
|
+
)}
|
|
271
|
+
</section>
|
|
272
|
+
|
|
273
|
+
<section>
|
|
274
|
+
<h2 className="text-sm font-semibold mb-2">
|
|
275
|
+
Also related {elsewhere.length > 0 && `(${elsewhere.length})`}
|
|
235
276
|
</h2>
|
|
236
277
|
|
|
237
278
|
{parentId && !parentHasEdge && (
|
|
@@ -242,37 +283,15 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
242
283
|
</p>
|
|
243
284
|
)}
|
|
244
285
|
|
|
245
|
-
{
|
|
286
|
+
{elsewhere.length === 0 ? (
|
|
246
287
|
<p className="text-sm text-muted-foreground">
|
|
247
|
-
|
|
288
|
+
Nothing else is related to this episode. {parentId && !parentHasEdge ? "See the note above." : ""}
|
|
248
289
|
</p>
|
|
249
290
|
) : (
|
|
250
291
|
<ul className="space-y-2">
|
|
251
|
-
{
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
(e.from_id === id && e.to_id === n.id) ||
|
|
255
|
-
(e.to_id === id && e.from_id === n.id),
|
|
256
|
-
);
|
|
257
|
-
return (
|
|
258
|
-
<li
|
|
259
|
-
key={n.id}
|
|
260
|
-
className="p-3 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap"
|
|
261
|
-
>
|
|
262
|
-
<span className="min-w-0">
|
|
263
|
-
<span className="text-sm font-medium">{n.name}</span>
|
|
264
|
-
<span className="font-mono text-xs text-muted-foreground block break-all">
|
|
265
|
-
{n.id}
|
|
266
|
-
</span>
|
|
267
|
-
</span>
|
|
268
|
-
{edge && (
|
|
269
|
-
<span className="mw-badge shrink-0" title="relationship_type">
|
|
270
|
-
{edge.from_id === id ? "→" : "←"} {edge.relationship_type}
|
|
271
|
-
</span>
|
|
272
|
-
)}
|
|
273
|
-
</li>
|
|
274
|
-
);
|
|
275
|
-
})}
|
|
292
|
+
{elsewhere.map((r) => (
|
|
293
|
+
<RelationRow key={`${r.other}-${r.label}`} relation={r} node={nodeById.get(r.other)} />
|
|
294
|
+
))}
|
|
276
295
|
</ul>
|
|
277
296
|
)}
|
|
278
297
|
|
package/app/episodes/page.tsx
CHANGED
|
@@ -21,6 +21,10 @@
|
|
|
21
21
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
22
22
|
import Link from "next/link";
|
|
23
23
|
import type { RelationalNode } from "@/lib/types";
|
|
24
|
+
import { EpisodeLineageWeb } from "@/components/episode-lineage-web";
|
|
25
|
+
import { chronicleKey, episodeDateOf, lineageEdges, type LineageEdge } from "@/lib/episode-lineage";
|
|
26
|
+
|
|
27
|
+
type View = "list" | "lineage";
|
|
24
28
|
|
|
25
29
|
// `description` lives on the store's node, not on ontology-core's — the same
|
|
26
30
|
// widening `app/nodes/page.tsx` declares as `NodeRecord`.
|
|
@@ -63,6 +67,24 @@ export default function EpisodesPage() {
|
|
|
63
67
|
const [loading, setLoading] = useState(true);
|
|
64
68
|
const [error, setError] = useState<string | null>(null);
|
|
65
69
|
const [query, setQuery] = useState("");
|
|
70
|
+
const [edges, setEdges] = useState<LineageEdge[]>([]);
|
|
71
|
+
const [edgesFailed, setEdgesFailed] = useState(false);
|
|
72
|
+
const [view, setView] = useState<View>("list");
|
|
73
|
+
|
|
74
|
+
// `?view=lineage` opens on the lineage, so it can be linked to. Read once on
|
|
75
|
+
// mount from the location rather than through useSearchParams, which would
|
|
76
|
+
// need a Suspense boundary around the whole page.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (new URLSearchParams(window.location.search).get("view") === "lineage") setView("lineage");
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
const choose = useCallback((next: View) => {
|
|
82
|
+
setView(next);
|
|
83
|
+
const url = new URL(window.location.href);
|
|
84
|
+
if (next === "lineage") url.searchParams.set("view", "lineage");
|
|
85
|
+
else url.searchParams.delete("view");
|
|
86
|
+
window.history.replaceState(null, "", url);
|
|
87
|
+
}, []);
|
|
66
88
|
|
|
67
89
|
const load = useCallback(async () => {
|
|
68
90
|
setLoading(true);
|
|
@@ -70,9 +92,10 @@ export default function EpisodesPage() {
|
|
|
70
92
|
// limit=all, not the provider's 100-row default. The chronicle holds more
|
|
71
93
|
// episodes than a default page, and a list that silently ends is worse
|
|
72
94
|
// than one that refuses to load.
|
|
73
|
-
const [episodesRes, attentionRes] = await Promise.all([
|
|
95
|
+
const [episodesRes, attentionRes, edgesRes] = await Promise.all([
|
|
74
96
|
fetch("/api/nodes?kind=chronicle_episode&limit=all"),
|
|
75
97
|
fetch("/api/nodes?kind=attention&limit=all"),
|
|
98
|
+
fetch("/api/edges?limit=all").catch(() => null),
|
|
76
99
|
]);
|
|
77
100
|
|
|
78
101
|
if (!episodesRes.ok) throw new Error(`Episodes: ${episodesRes.status}`);
|
|
@@ -90,6 +113,18 @@ export default function EpisodesPage() {
|
|
|
90
113
|
|
|
91
114
|
setEpisodes([...list].sort((a, b) => sortKey(b).localeCompare(sortKey(a))));
|
|
92
115
|
setAttentionCounts(counts);
|
|
116
|
+
|
|
117
|
+
// The relations are a second answer: if they do not come, the list still does.
|
|
118
|
+
const ids = new Set(list.map((e) => e.id));
|
|
119
|
+
if (edgesRes?.ok) {
|
|
120
|
+
const body = await edgesRes.json();
|
|
121
|
+
const all: LineageEdge[] = Array.isArray(body) ? body : (body.edges ?? []);
|
|
122
|
+
setEdges(lineageEdges(all, (id) => ids.has(id)));
|
|
123
|
+
setEdgesFailed(false);
|
|
124
|
+
} else {
|
|
125
|
+
setEdges([]);
|
|
126
|
+
setEdgesFailed(true);
|
|
127
|
+
}
|
|
93
128
|
setError(null);
|
|
94
129
|
} catch (e) {
|
|
95
130
|
setError(e instanceof Error ? e.message : String(e));
|
|
@@ -113,6 +148,30 @@ export default function EpisodesPage() {
|
|
|
113
148
|
);
|
|
114
149
|
}, [episodes, query]);
|
|
115
150
|
|
|
151
|
+
/** Relations to other episodes, per episode. */
|
|
152
|
+
const relationCounts = useMemo(() => {
|
|
153
|
+
const counts: Record<string, number> = {};
|
|
154
|
+
for (const edge of edges) {
|
|
155
|
+
counts[edge.from_id] = (counts[edge.from_id] ?? 0) + 1;
|
|
156
|
+
counts[edge.to_id] = (counts[edge.to_id] ?? 0) + 1;
|
|
157
|
+
}
|
|
158
|
+
return counts;
|
|
159
|
+
}, [edges]);
|
|
160
|
+
|
|
161
|
+
/** The lineage reads oldest first, in the folders' own order. */
|
|
162
|
+
const chronological = useMemo(
|
|
163
|
+
() =>
|
|
164
|
+
[...episodes]
|
|
165
|
+
.sort((a, b) => chronicleKey(a).localeCompare(chronicleKey(b)))
|
|
166
|
+
.map((e) => ({ id: e.id, name: e.name, direction: e.direction, date: episodeDateOf(e) ?? occurredAt(e)?.slice(0, 10) })),
|
|
167
|
+
[episodes],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const matching = useMemo(
|
|
171
|
+
() => (query.trim() ? new Set(visible.map((e) => e.id)) : null),
|
|
172
|
+
[query, visible],
|
|
173
|
+
);
|
|
174
|
+
|
|
116
175
|
/** How many rows are ordered by registration rather than by when they happened. */
|
|
117
176
|
const undatedCount = useMemo(
|
|
118
177
|
() => episodes.filter((e) => !occurredAt(e)).length,
|
|
@@ -128,16 +187,41 @@ export default function EpisodesPage() {
|
|
|
128
187
|
{loading ? "Reading the chronicle…" : `${episodes.length} episodes in the wheel`}
|
|
129
188
|
</p>
|
|
130
189
|
</div>
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
190
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
191
|
+
<div className="flex gap-1" role="group" aria-label="View">
|
|
192
|
+
{(["list", "lineage"] as const).map((v) => (
|
|
193
|
+
<button
|
|
194
|
+
key={v}
|
|
195
|
+
onClick={() => choose(v)}
|
|
196
|
+
aria-pressed={view === v}
|
|
197
|
+
className={`mw-btn ${view === v ? "" : "mw-btn--ghost"}`}
|
|
198
|
+
>
|
|
199
|
+
{v === "list" ? "List" : "Lineage"}
|
|
200
|
+
</button>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
<input
|
|
204
|
+
value={query}
|
|
205
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
206
|
+
placeholder="Filter by name or id"
|
|
207
|
+
className="mw-input max-w-xs"
|
|
208
|
+
aria-label="Filter episodes"
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
138
211
|
</div>
|
|
139
212
|
|
|
140
|
-
{
|
|
213
|
+
{view === "lineage" && !loading && episodes.length > 0 && (
|
|
214
|
+
<div className="mb-6">
|
|
215
|
+
{edgesFailed && (
|
|
216
|
+
<p className="mb-2 text-xs text-muted-foreground">
|
|
217
|
+
The wheel did not answer for its relations, so the lineage shows episodes without arcs.
|
|
218
|
+
</p>
|
|
219
|
+
)}
|
|
220
|
+
<EpisodeLineageWeb episodes={chronological} edges={edges} matches={matching} />
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{view === "list" && undatedCount > 0 && !loading && (
|
|
141
225
|
<p className="mb-4 text-xs text-muted-foreground border rounded-lg p-3 bg-card">
|
|
142
226
|
{undatedCount} of {episodes.length} episodes have no <code>occurred_at</code>, so they
|
|
143
227
|
are ordered by when the wheel recorded them rather than by when they happened. That is
|
|
@@ -163,10 +247,12 @@ export default function EpisodesPage() {
|
|
|
163
247
|
</p>
|
|
164
248
|
)}
|
|
165
249
|
|
|
250
|
+
{view === "list" && (
|
|
166
251
|
<ul className="space-y-2">
|
|
167
252
|
{visible.map((episode) => {
|
|
168
253
|
const number = episodeNumber(episode.id);
|
|
169
254
|
const open = attentionCounts[episode.id] ?? 0;
|
|
255
|
+
const related = relationCounts[episode.id] ?? 0;
|
|
170
256
|
const dated = occurredAt(episode);
|
|
171
257
|
return (
|
|
172
258
|
<li key={episode.id}>
|
|
@@ -184,6 +270,11 @@ export default function EpisodesPage() {
|
|
|
184
270
|
<span className="font-medium truncate">{episode.name}</span>
|
|
185
271
|
</div>
|
|
186
272
|
<div className="flex items-center gap-2 shrink-0">
|
|
273
|
+
{related > 0 && (
|
|
274
|
+
<span className="mw-badge" title={`${related} relations to other episodes`}>
|
|
275
|
+
{related} related
|
|
276
|
+
</span>
|
|
277
|
+
)}
|
|
187
278
|
{open > 0 && (
|
|
188
279
|
<span className="mw-badge" title={`${open} open attention items`}>
|
|
189
280
|
{open} attention
|
|
@@ -213,8 +304,9 @@ export default function EpisodesPage() {
|
|
|
213
304
|
);
|
|
214
305
|
})}
|
|
215
306
|
</ul>
|
|
307
|
+
)}
|
|
216
308
|
|
|
217
|
-
{!loading && visible.length === 0 && episodes.length > 0 && (
|
|
309
|
+
{view === "list" && !loading && visible.length === 0 && episodes.length > 0 && (
|
|
218
310
|
<p className="text-sm text-muted-foreground">Nothing matches “{query}”.</p>
|
|
219
311
|
)}
|
|
220
312
|
</div>
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The chronicle as a lineage: every episode on a spine read left to right, in
|
|
5
|
+
* its direction's band, with the relations between episodes drawn as arcs.
|
|
6
|
+
*
|
|
7
|
+
* Miadi's chronicle opens on this picture (`LineageWeb`), and it answers what a
|
|
8
|
+
* list cannot: which episodes carry a thread forward, which stand alone, where
|
|
9
|
+
* the work keeps returning. The wheel already holds every relation it draws.
|
|
10
|
+
*
|
|
11
|
+
* Tap an episode to see its relations and the words that say why each holds;
|
|
12
|
+
* tap it again, or its name, to open it. Touch first — a phone has no hover.
|
|
13
|
+
* Ref jgwill/medicine-wheel#156.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
17
|
+
import Link from "next/link";
|
|
18
|
+
import { useRouter } from "next/navigation";
|
|
19
|
+
import { isContinuation, relationsOf, type LineageEdge } from "@/lib/episode-lineage";
|
|
20
|
+
|
|
21
|
+
export interface LineageEpisode {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
direction?: string;
|
|
25
|
+
/** `YYYY-MM-DD`, when known: the axis is labelled by month. */
|
|
26
|
+
date?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BANDS = ["east", "south", "west", "north"] as const;
|
|
30
|
+
const BAND_ICON: Record<string, string> = { east: "🌅", south: "🔥", west: "🌊", north: "❄️" };
|
|
31
|
+
const BAND_H = 64;
|
|
32
|
+
const TOP = 28;
|
|
33
|
+
const AXIS = 26;
|
|
34
|
+
const STEP = 15;
|
|
35
|
+
const MARGIN_L = 72;
|
|
36
|
+
const MARGIN_R = 24;
|
|
37
|
+
const H = TOP + BAND_H * BANDS.length + AXIS;
|
|
38
|
+
|
|
39
|
+
function bandOf(direction?: string): number {
|
|
40
|
+
const at = BANDS.indexOf((direction ?? "") as (typeof BANDS)[number]);
|
|
41
|
+
return at === -1 ? 1.5 : at;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function EpisodeLineageWeb({
|
|
45
|
+
episodes,
|
|
46
|
+
edges,
|
|
47
|
+
matches,
|
|
48
|
+
}: {
|
|
49
|
+
/** In chronicle order, oldest first. */
|
|
50
|
+
episodes: LineageEpisode[];
|
|
51
|
+
/** Episode↔episode relations only. */
|
|
52
|
+
edges: LineageEdge[];
|
|
53
|
+
/** When a filter is typed: the episodes it matches. The rest fade. */
|
|
54
|
+
matches?: Set<string> | null;
|
|
55
|
+
}) {
|
|
56
|
+
const router = useRouter();
|
|
57
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
58
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
59
|
+
const [hovered, setHovered] = useState<string | null>(null);
|
|
60
|
+
|
|
61
|
+
const layout = useMemo(() => {
|
|
62
|
+
const position = new Map<string, { x: number; y: number }>();
|
|
63
|
+
episodes.forEach((episode, i) => {
|
|
64
|
+
const x = MARGIN_L + i * STEP;
|
|
65
|
+
// A small stagger so neighbours in one band do not sit on one line.
|
|
66
|
+
const y = TOP + (bandOf(episode.direction) + 0.5) * BAND_H + ((i % 3) - 1) * 13;
|
|
67
|
+
position.set(episode.id, { x, y });
|
|
68
|
+
});
|
|
69
|
+
const width = Math.max(MARGIN_L + (episodes.length - 1) * STEP + MARGIN_R, 360);
|
|
70
|
+
|
|
71
|
+
const months: Array<{ x: number; label: string }> = [];
|
|
72
|
+
let last = "";
|
|
73
|
+
episodes.forEach((episode, i) => {
|
|
74
|
+
const month = episode.date?.slice(0, 7) ?? "";
|
|
75
|
+
if (month && month !== last) {
|
|
76
|
+
months.push({ x: MARGIN_L + i * STEP, label: month });
|
|
77
|
+
last = month;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const degree = new Map<string, number>();
|
|
82
|
+
for (const edge of edges) {
|
|
83
|
+
degree.set(edge.from_id, (degree.get(edge.from_id) ?? 0) + 1);
|
|
84
|
+
degree.set(edge.to_id, (degree.get(edge.to_id) ?? 0) + 1);
|
|
85
|
+
}
|
|
86
|
+
return { position, width, months, degree };
|
|
87
|
+
}, [episodes, edges]);
|
|
88
|
+
|
|
89
|
+
// The newest episodes are where the work is: open scrolled to the right.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
const el = scroller.current;
|
|
92
|
+
if (el) el.scrollLeft = el.scrollWidth;
|
|
93
|
+
}, [layout.width]);
|
|
94
|
+
|
|
95
|
+
const focus = hovered ?? selected;
|
|
96
|
+
const near = useMemo(() => {
|
|
97
|
+
if (!focus) return null;
|
|
98
|
+
const set = new Set([focus]);
|
|
99
|
+
for (const edge of edges) {
|
|
100
|
+
if (edge.from_id === focus) set.add(edge.to_id);
|
|
101
|
+
if (edge.to_id === focus) set.add(edge.from_id);
|
|
102
|
+
}
|
|
103
|
+
return set;
|
|
104
|
+
}, [focus, edges]);
|
|
105
|
+
|
|
106
|
+
const byId = useMemo(() => new Map(episodes.map((e) => [e.id, e])), [episodes]);
|
|
107
|
+
const chosen = selected ? byId.get(selected) : undefined;
|
|
108
|
+
const chosenRelations = useMemo(
|
|
109
|
+
() => (selected ? relationsOf(selected, edges) : []),
|
|
110
|
+
[selected, edges],
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
function open(id: string) {
|
|
114
|
+
router.push(`/episodes/${encodeURIComponent(id)}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function tap(id: string) {
|
|
118
|
+
if (selected === id) open(id);
|
|
119
|
+
else setSelected(id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const faded = (id: string) =>
|
|
123
|
+
(near !== null && !near.has(id)) || (matches != null && !matches.has(id));
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="space-y-3">
|
|
127
|
+
<div ref={scroller} className="overflow-x-auto border rounded-lg bg-card">
|
|
128
|
+
<svg
|
|
129
|
+
width={layout.width}
|
|
130
|
+
height={H}
|
|
131
|
+
viewBox={`0 0 ${layout.width} ${H}`}
|
|
132
|
+
role="img"
|
|
133
|
+
aria-label={`${episodes.length} episodes in chronicle order, with ${edges.length} relations between them`}
|
|
134
|
+
onClick={(event) => {
|
|
135
|
+
if (event.target === event.currentTarget) setSelected(null);
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{BANDS.map((band, i) => (
|
|
139
|
+
<g key={band}>
|
|
140
|
+
<line
|
|
141
|
+
x1={0}
|
|
142
|
+
x2={layout.width}
|
|
143
|
+
y1={TOP + (i + 1) * BAND_H}
|
|
144
|
+
y2={TOP + (i + 1) * BAND_H}
|
|
145
|
+
stroke="var(--mw-border)"
|
|
146
|
+
strokeWidth={1}
|
|
147
|
+
opacity={0.5}
|
|
148
|
+
/>
|
|
149
|
+
<text x={8} y={TOP + i * BAND_H + 16} fontSize={11} fill="var(--mw-muted)">
|
|
150
|
+
{BAND_ICON[band]} {band}
|
|
151
|
+
</text>
|
|
152
|
+
</g>
|
|
153
|
+
))}
|
|
154
|
+
|
|
155
|
+
{layout.months.map((m) => (
|
|
156
|
+
<text key={m.label} x={m.x} y={H - 8} fontSize={10} fill="var(--mw-muted)">
|
|
157
|
+
{m.label}
|
|
158
|
+
</text>
|
|
159
|
+
))}
|
|
160
|
+
|
|
161
|
+
{edges.map((edge) => {
|
|
162
|
+
const a = layout.position.get(edge.from_id);
|
|
163
|
+
const b = layout.position.get(edge.to_id);
|
|
164
|
+
if (!a || !b) return null;
|
|
165
|
+
const lit = focus !== null && (edge.from_id === focus || edge.to_id === focus);
|
|
166
|
+
const lift = Math.min(110, Math.abs(b.x - a.x) * 0.3) + 18;
|
|
167
|
+
const top = Math.max(6, Math.min(a.y, b.y) - lift);
|
|
168
|
+
const continuation = isContinuation(edge);
|
|
169
|
+
return (
|
|
170
|
+
<path
|
|
171
|
+
key={edge.id}
|
|
172
|
+
d={`M ${a.x} ${a.y} Q ${(a.x + b.x) / 2} ${top} ${b.x} ${b.y}`}
|
|
173
|
+
fill="none"
|
|
174
|
+
stroke={lit ? "var(--mw-primary)" : "var(--mw-muted)"}
|
|
175
|
+
strokeWidth={lit ? 2 : 1}
|
|
176
|
+
strokeDasharray={continuation ? undefined : "3 3"}
|
|
177
|
+
opacity={lit ? 1 : focus ? 0.12 : continuation ? 0.55 : 0.3}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
|
|
182
|
+
{episodes.map((episode) => {
|
|
183
|
+
const p = layout.position.get(episode.id)!;
|
|
184
|
+
const related = (layout.degree.get(episode.id) ?? 0) > 0;
|
|
185
|
+
const isSelected = episode.id === selected;
|
|
186
|
+
return (
|
|
187
|
+
<g
|
|
188
|
+
key={episode.id}
|
|
189
|
+
onClick={() => tap(episode.id)}
|
|
190
|
+
onMouseEnter={() => setHovered(episode.id)}
|
|
191
|
+
onMouseLeave={() => setHovered(null)}
|
|
192
|
+
style={{ cursor: "pointer" }}
|
|
193
|
+
opacity={faded(episode.id) ? 0.2 : 1}
|
|
194
|
+
>
|
|
195
|
+
<title>{episode.name}</title>
|
|
196
|
+
{/* A target a finger can hit, larger than the dot it holds. */}
|
|
197
|
+
<circle cx={p.x} cy={p.y} r={9} fill="transparent" />
|
|
198
|
+
<circle
|
|
199
|
+
cx={p.x}
|
|
200
|
+
cy={p.y}
|
|
201
|
+
r={related ? 4.5 : 3}
|
|
202
|
+
fill={`var(--mw-${episode.direction ?? "west"}-ink, var(--mw-muted))`}
|
|
203
|
+
stroke={isSelected ? "var(--mw-primary)" : "none"}
|
|
204
|
+
strokeWidth={isSelected ? 2.5 : 0}
|
|
205
|
+
/>
|
|
206
|
+
</g>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</svg>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{chosen ? (
|
|
213
|
+
<div className="p-3 border rounded-lg bg-card space-y-2">
|
|
214
|
+
<div className="flex items-baseline justify-between gap-3 flex-wrap">
|
|
215
|
+
<Link href={`/episodes/${encodeURIComponent(chosen.id)}`} className="text-sm font-medium hover:underline">
|
|
216
|
+
{chosen.name}
|
|
217
|
+
</Link>
|
|
218
|
+
<span className="text-xs text-muted-foreground">{chosen.date}</span>
|
|
219
|
+
</div>
|
|
220
|
+
{chosenRelations.length === 0 ? (
|
|
221
|
+
<p className="text-xs text-muted-foreground">No relation to another episode has been woven yet.</p>
|
|
222
|
+
) : (
|
|
223
|
+
<ul className="space-y-1">
|
|
224
|
+
{chosenRelations.map((r) => (
|
|
225
|
+
<li key={`${r.other}-${r.label}`} className="text-sm">
|
|
226
|
+
<span className="text-muted-foreground">{r.label}</span>{" "}
|
|
227
|
+
<button className="hover:underline text-left" onClick={() => setSelected(r.other)}>
|
|
228
|
+
{byId.get(r.other)?.name ?? r.other}
|
|
229
|
+
</button>
|
|
230
|
+
{r.descriptions[0] && (
|
|
231
|
+
<p className="text-xs text-muted-foreground pl-3">{r.descriptions[0]}</p>
|
|
232
|
+
)}
|
|
233
|
+
</li>
|
|
234
|
+
))}
|
|
235
|
+
</ul>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
) : (
|
|
239
|
+
<p className="text-xs text-muted-foreground">
|
|
240
|
+
Each dot is an episode, oldest on the left, in its direction's band. Solid arcs carry
|
|
241
|
+
a thread forward (continues from); dashed arcs relate two episodes. Tap a dot to see why
|
|
242
|
+
they are related; tap it again to open it.
|
|
243
|
+
</p>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An episode's lineage, read from the relations the wheel already holds.
|
|
3
|
+
*
|
|
4
|
+
* Miadi's chronicle writes every episode↔episode relation to the wheel as an
|
|
5
|
+
* edge, with the words that say why it holds in `description` (jgwill/Miadi#702,
|
|
6
|
+
* jgwill/medicine-wheel#150). The wheel kept them, and its Episodes tab listed a
|
|
7
|
+
* neighbour's name and a raw `relationship_type` — never the words, and never
|
|
8
|
+
* which way the relation runs. This module turns an edge into the sentence a
|
|
9
|
+
* reader expects from where they stand: on episode 549, `549 continues_from 045`
|
|
10
|
+
* reads "continues from 045"; on episode 045, the same edge reads "continued by
|
|
11
|
+
* 549".
|
|
12
|
+
*
|
|
13
|
+
* Pure functions, no fetching: the episode page and the lineage web read the
|
|
14
|
+
* same answer. Ref jgwill/medicine-wheel#156.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface LineageEdge {
|
|
18
|
+
id: string;
|
|
19
|
+
from_id: string;
|
|
20
|
+
to_id: string;
|
|
21
|
+
relationship_type: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EpisodeRelation {
|
|
26
|
+
/** The node on the other end. */
|
|
27
|
+
other: string;
|
|
28
|
+
/** The relation as read from this episode: "continues from", "continued by", "relates to"… */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Lineage relations sort first, in this order; everything else after them. */
|
|
31
|
+
rank: number;
|
|
32
|
+
/** The words that say why it holds — one per edge that said something. */
|
|
33
|
+
descriptions: string[];
|
|
34
|
+
/** The edges this relation was read from (two when both directions were woven). */
|
|
35
|
+
edgeIds: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Relations with a direction: [as read from `from_id`, as read from `to_id`].
|
|
40
|
+
* Both spellings appear on the chronicle wheel (`continues_from` from Miadi's
|
|
41
|
+
* lineage door, `continues-from` from an earlier writer), so both are named.
|
|
42
|
+
*/
|
|
43
|
+
const DIRECTED: Record<string, [string, string]> = {
|
|
44
|
+
continues_from: ["continues from", "continued by"],
|
|
45
|
+
"continues-from": ["continues from", "continued by"],
|
|
46
|
+
continued_by: ["continued by", "continues from"],
|
|
47
|
+
"continued-by": ["continued by", "continues from"],
|
|
48
|
+
descends_from: ["descends from", "gave rise to"],
|
|
49
|
+
"descends-from": ["descends from", "gave rise to"],
|
|
50
|
+
born_from: ["born from", "gave birth to"],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Relations that read the same from either end. */
|
|
54
|
+
const SYMMETRIC = new Set(["relates_to", "relates-to", "speaks-with", "speaks_with"]);
|
|
55
|
+
|
|
56
|
+
const RANK: Record<string, number> = {
|
|
57
|
+
"continues from": 0,
|
|
58
|
+
"descends from": 0,
|
|
59
|
+
"born from": 0,
|
|
60
|
+
"continued by": 1,
|
|
61
|
+
"gave rise to": 1,
|
|
62
|
+
"gave birth to": 1,
|
|
63
|
+
"relates to": 2,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function words(type: string): string {
|
|
67
|
+
return type.replace(/[_-]+/g, " ").trim().toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One edge, read from `episodeId`'s end. Null when the edge does not touch it, or loops on it. */
|
|
71
|
+
export function readEdge(episodeId: string, edge: LineageEdge): { other: string; label: string } | null {
|
|
72
|
+
const outgoing = edge.from_id === episodeId;
|
|
73
|
+
const incoming = edge.to_id === episodeId;
|
|
74
|
+
if (outgoing === incoming) return null;
|
|
75
|
+
const other = outgoing ? edge.to_id : edge.from_id;
|
|
76
|
+
const type = edge.relationship_type ?? "";
|
|
77
|
+
const directed = DIRECTED[type.toLowerCase()];
|
|
78
|
+
if (directed) return { other, label: outgoing ? directed[0] : directed[1] };
|
|
79
|
+
if (SYMMETRIC.has(type.toLowerCase())) return { other, label: words(type) };
|
|
80
|
+
// A type the chronicle has not named a reverse for keeps its own words, and
|
|
81
|
+
// an arrow says which way it was written.
|
|
82
|
+
return { other, label: `${outgoing ? "→" : "←"} ${words(type) || "related"}` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every relation touching an episode, one per (other node, reading).
|
|
87
|
+
*
|
|
88
|
+
* Miadi's lineage door can weave a relation both ways — `A relates_to B` and
|
|
89
|
+
* `B relates_to A`, each with its own words. Read from A, those are the same
|
|
90
|
+
* relation said twice, so they merge and keep both sets of words. Two different
|
|
91
|
+
* readings of the same pair (A continues from B, and also relates to it) stay
|
|
92
|
+
* two relations.
|
|
93
|
+
*/
|
|
94
|
+
export function relationsOf(episodeId: string, edges: LineageEdge[]): EpisodeRelation[] {
|
|
95
|
+
const byKey = new Map<string, EpisodeRelation>();
|
|
96
|
+
for (const edge of edges) {
|
|
97
|
+
const read = readEdge(episodeId, edge);
|
|
98
|
+
if (!read) continue;
|
|
99
|
+
const key = `${read.other}\u0000${read.label}`;
|
|
100
|
+
const relation =
|
|
101
|
+
byKey.get(key) ??
|
|
102
|
+
{ other: read.other, label: read.label, rank: RANK[read.label] ?? 3, descriptions: [], edgeIds: [] };
|
|
103
|
+
relation.edgeIds.push(edge.id);
|
|
104
|
+
const said = edge.description?.trim();
|
|
105
|
+
if (said && !relation.descriptions.includes(said)) relation.descriptions.push(said);
|
|
106
|
+
byKey.set(key, relation);
|
|
107
|
+
}
|
|
108
|
+
return [...byKey.values()].sort((a, b) => a.rank - b.rank || a.other.localeCompare(b.other));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Episode↔episode edges only — the lineage, without the chronicle root every episode belongs to. */
|
|
112
|
+
export function lineageEdges(edges: LineageEdge[], isEpisode: (id: string) => boolean): LineageEdge[] {
|
|
113
|
+
return edges.filter(
|
|
114
|
+
(edge) => edge.from_id !== edge.to_id && isEpisode(edge.from_id) && isEpisode(edge.to_id),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Whether a reading runs along time (continues, descends) rather than across it (relates). */
|
|
119
|
+
export function isContinuation(edge: LineageEdge): boolean {
|
|
120
|
+
return Boolean(DIRECTED[(edge.relationship_type ?? "").toLowerCase()]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Where an episode sits in the chronicle ──────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/** The fields of a wheel node these helpers read. */
|
|
126
|
+
export interface ChronicleNode {
|
|
127
|
+
id: string;
|
|
128
|
+
created_at?: string;
|
|
129
|
+
metadata?: Record<string, unknown>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const FOLDER = /^(\d{4}-\d{2}-\d{2})-episode-(\d+)/i;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The episode's folder name — what the wheel's other records file it under.
|
|
136
|
+
*
|
|
137
|
+
* From the id when it is `chronicle:<folder>`. A few early episodes were
|
|
138
|
+
* registered under other ids (`node:knowledge:…`, a bare uuid); their node
|
|
139
|
+
* still carries the artefact reference, so the folder comes from
|
|
140
|
+
* `metadata.relative_path` when `metadata.root` names the chronicle.
|
|
141
|
+
*/
|
|
142
|
+
export function episodeFolderOfNode(node: ChronicleNode): string | null {
|
|
143
|
+
if (node.id.startsWith("chronicle:")) return node.id.slice("chronicle:".length) || null;
|
|
144
|
+
const root = node.metadata?.root;
|
|
145
|
+
const relative = node.metadata?.relative_path;
|
|
146
|
+
if (root !== "MIADI_CHRONICLE_ROOT" || typeof relative !== "string") return null;
|
|
147
|
+
const folder = relative.split("/")[0];
|
|
148
|
+
return folder && folder !== relative ? folder : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Where an episode sits in the chronicle: its folder's date, then its number —
|
|
153
|
+
* the order the folders keep on disk. Not `created_at`, which records when the
|
|
154
|
+
* wheel learned of an episode, and not the number alone, which the chronicle
|
|
155
|
+
* does not hand out in date order (episode 1000 follows episode 550 by two days).
|
|
156
|
+
*/
|
|
157
|
+
export function chronicleKey(node: ChronicleNode): string {
|
|
158
|
+
const match = (episodeFolderOfNode(node) ?? "").match(FOLDER);
|
|
159
|
+
return match ? `${match[1]}-${match[2].padStart(6, "0")}` : (node.created_at ?? "");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The day the episode's folder is dated, when it has one. */
|
|
163
|
+
export function episodeDateOf(node: ChronicleNode): string | undefined {
|
|
164
|
+
return (episodeFolderOfNode(node) ?? "").match(FOLDER)?.[1];
|
|
165
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.4",
|
|
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",
|
|
@@ -91,32 +91,32 @@
|
|
|
91
91
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {
|
|
94
|
-
"@medicine-wheel/ceremonial-diary": "^0.17.
|
|
95
|
-
"@medicine-wheel/ceremony-protocol": "^0.17.
|
|
96
|
-
"@medicine-wheel/community-identity": "^0.17.
|
|
97
|
-
"@medicine-wheel/community-review": "^0.17.
|
|
98
|
-
"@medicine-wheel/consent-lifecycle": "^0.17.
|
|
99
|
-
"@medicine-wheel/creative-orientation": "^0.17.
|
|
100
|
-
"@medicine-wheel/data-store": "^0.17.
|
|
101
|
-
"@medicine-wheel/data-store-postgres": "^0.17.
|
|
102
|
-
"@medicine-wheel/fire-keeper": "^0.17.
|
|
103
|
-
"@medicine-wheel/github-ceremony": "^0.17.
|
|
104
|
-
"@medicine-wheel/graph-viz": "^0.17.
|
|
105
|
-
"@medicine-wheel/honcho": "^0.17.
|
|
106
|
-
"@medicine-wheel/importance-unit": "^0.17.
|
|
107
|
-
"@medicine-wheel/mcp": "^4.17.
|
|
108
|
-
"@medicine-wheel/memory": "^0.17.
|
|
109
|
-
"@medicine-wheel/narrative-cluster": "^0.17.
|
|
110
|
-
"@medicine-wheel/narrative-engine": "^0.17.
|
|
111
|
-
"@medicine-wheel/ontology-core": "^0.17.
|
|
112
|
-
"@medicine-wheel/perception-layer": "^0.17.
|
|
113
|
-
"@medicine-wheel/prompt-decomposition": "^0.17.
|
|
114
|
-
"@medicine-wheel/relational-index": "^0.17.
|
|
115
|
-
"@medicine-wheel/relational-query": "^0.17.
|
|
116
|
-
"@medicine-wheel/session-reader": "^0.17.
|
|
117
|
-
"@medicine-wheel/storage-provider": "^0.17.
|
|
118
|
-
"@medicine-wheel/transformation-tracker": "^0.17.
|
|
119
|
-
"@medicine-wheel/ui-components": "^0.17.
|
|
94
|
+
"@medicine-wheel/ceremonial-diary": "^0.17.4",
|
|
95
|
+
"@medicine-wheel/ceremony-protocol": "^0.17.4",
|
|
96
|
+
"@medicine-wheel/community-identity": "^0.17.4",
|
|
97
|
+
"@medicine-wheel/community-review": "^0.17.4",
|
|
98
|
+
"@medicine-wheel/consent-lifecycle": "^0.17.4",
|
|
99
|
+
"@medicine-wheel/creative-orientation": "^0.17.4",
|
|
100
|
+
"@medicine-wheel/data-store": "^0.17.4",
|
|
101
|
+
"@medicine-wheel/data-store-postgres": "^0.17.4",
|
|
102
|
+
"@medicine-wheel/fire-keeper": "^0.17.4",
|
|
103
|
+
"@medicine-wheel/github-ceremony": "^0.17.4",
|
|
104
|
+
"@medicine-wheel/graph-viz": "^0.17.4",
|
|
105
|
+
"@medicine-wheel/honcho": "^0.17.4",
|
|
106
|
+
"@medicine-wheel/importance-unit": "^0.17.4",
|
|
107
|
+
"@medicine-wheel/mcp": "^4.17.4",
|
|
108
|
+
"@medicine-wheel/memory": "^0.17.4",
|
|
109
|
+
"@medicine-wheel/narrative-cluster": "^0.17.4",
|
|
110
|
+
"@medicine-wheel/narrative-engine": "^0.17.4",
|
|
111
|
+
"@medicine-wheel/ontology-core": "^0.17.4",
|
|
112
|
+
"@medicine-wheel/perception-layer": "^0.17.4",
|
|
113
|
+
"@medicine-wheel/prompt-decomposition": "^0.17.4",
|
|
114
|
+
"@medicine-wheel/relational-index": "^0.17.4",
|
|
115
|
+
"@medicine-wheel/relational-query": "^0.17.4",
|
|
116
|
+
"@medicine-wheel/session-reader": "^0.17.4",
|
|
117
|
+
"@medicine-wheel/storage-provider": "^0.17.4",
|
|
118
|
+
"@medicine-wheel/transformation-tracker": "^0.17.4",
|
|
119
|
+
"@medicine-wheel/ui-components": "^0.17.4",
|
|
120
120
|
"@miadi/episode-vessel": "^0.2.1",
|
|
121
121
|
"@neondatabase/serverless": "^0.10.0",
|
|
122
122
|
"@xyflow/react": "^12.3.0",
|