@medicine-wheel/app 0.16.1 → 0.17.1
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/episodes/[id]/files/route.ts +78 -0
- package/app/episodes/[id]/page.tsx +74 -9
- package/app/nodes/page.tsx +2 -2
- package/components/episode-files-panel.tsx +167 -0
- package/components/episode-holdings.tsx +252 -0
- package/lib/episode-files.ts +170 -0
- package/lib/memory.ts +17 -0
- package/package.json +28 -26
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { EpisodePathError } from "@miadi/episode-vessel";
|
|
3
|
+
import { episodeFolderOf, openEpisodeRoom, readEpisodeRoomText } from "@/lib/episode-files";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An episode's folder, read-only.
|
|
7
|
+
*
|
|
8
|
+
* GET /api/episodes/<chronicle:folder>/files the room's listing
|
|
9
|
+
* GET /api/episodes/<chronicle:folder>/files?path=<file> one text file, whole
|
|
10
|
+
*
|
|
11
|
+
* The rules — containment, the room, the audience — are `lib/episode-files.ts`
|
|
12
|
+
* and, beneath it, `@miadi/episode-vessel`. This file only says them in HTTP.
|
|
13
|
+
* Every answer carries a `state`, so a page can tell "this host has no
|
|
14
|
+
* chronicle" from "this episode is not on this host" from "this episode is for
|
|
15
|
+
* the community" — three empty lists that mean three different things.
|
|
16
|
+
*
|
|
17
|
+
* There is no PUT. The wheel reads an episode; Miadi writes it.
|
|
18
|
+
* Ref jgwill/medicine-wheel#153.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const dynamic = "force-dynamic";
|
|
22
|
+
export const runtime = "nodejs";
|
|
23
|
+
|
|
24
|
+
const STATUS_OF_STATE: Record<string, number> = {
|
|
25
|
+
open: 200,
|
|
26
|
+
"no-root": 503,
|
|
27
|
+
absent: 404,
|
|
28
|
+
community: 403,
|
|
29
|
+
"outside-the-room": 403,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const STATUS_OF_CODE: Record<string, number> = {
|
|
33
|
+
ENOENT: 404,
|
|
34
|
+
TOO_LARGE: 413,
|
|
35
|
+
NO_ROOT: 503,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function answer(body: Record<string, unknown>, status: number) {
|
|
39
|
+
return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function decoded(value: string): string {
|
|
43
|
+
try {
|
|
44
|
+
return decodeURIComponent(value);
|
|
45
|
+
} catch {
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function GET(
|
|
51
|
+
request: Request,
|
|
52
|
+
{ params }: { params: Promise<{ id: string }> },
|
|
53
|
+
) {
|
|
54
|
+
const { id: rawId } = await params;
|
|
55
|
+
const id = decoded(rawId);
|
|
56
|
+
const folder = episodeFolderOf(id);
|
|
57
|
+
if (!folder) {
|
|
58
|
+
return answer(
|
|
59
|
+
{ state: "not-an-episode", error: `Not an episode id: ${id}. Expected chronicle:<episode folder>.` },
|
|
60
|
+
400,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const file = new URL(request.url).searchParams.get("path");
|
|
65
|
+
try {
|
|
66
|
+
const body = file === null ? openEpisodeRoom(folder) : readEpisodeRoomText(folder, file);
|
|
67
|
+
return answer(body, STATUS_OF_STATE[body.state] ?? 200);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof EpisodePathError) {
|
|
70
|
+
return answer(
|
|
71
|
+
{ state: "refused", code: error.code, error: error.message },
|
|
72
|
+
STATUS_OF_CODE[error.code] ?? 400,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
76
|
+
return answer({ state: "error", error: message }, 500);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* One episode: what it is, what it still holds open, and what
|
|
4
|
+
* One episode: what it is, what it holds, what it still holds open, and what
|
|
5
|
+
* it touches.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* What this is, from the node itself, with the episodes before and after it in
|
|
8
|
+
* the chronicle. What it holds, from its folder — read-only, through the rules
|
|
9
|
+
* `@miadi/episode-vessel` states for every surface that opens one (see
|
|
10
|
+
* `components/episode-files-panel.tsx`). What was said and gathered for it —
|
|
11
|
+
* ceremonies, diary, inquiry weaves, plan perspectives, captures — from routes
|
|
12
|
+
* that filtered by episode long before any page asked them
|
|
13
|
+
* (`components/episode-holdings.tsx`). What is open, from
|
|
14
|
+
* `?kind=attention&parent_id=<id>`. What it relates to, from
|
|
15
|
+
* `/api/nodes/[id]/web`, which walks the traversal that shipped in
|
|
16
|
+
* `relational-query` and went unimported. Ref jgwill/medicine-wheel#153.
|
|
11
17
|
*
|
|
12
18
|
* The relations panel is where the wheel's current honesty shows. 101 of 106
|
|
13
19
|
* containment links live only in `metadata.parent_id` and were never written as
|
|
@@ -21,6 +27,8 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
21
27
|
import { use } from "react";
|
|
22
28
|
import Link from "next/link";
|
|
23
29
|
import type { RelationalNode, RelationalEdge } from "@/lib/types";
|
|
30
|
+
import { EpisodeFilesPanel } from "@/components/episode-files-panel";
|
|
31
|
+
import { EpisodeHoldings } from "@/components/episode-holdings";
|
|
24
32
|
|
|
25
33
|
// `description` lives on the store's node, not on ontology-core's — the same
|
|
26
34
|
// widening `app/nodes/page.tsx` declares as `NodeRecord`. Named EpisodeNode
|
|
@@ -42,6 +50,46 @@ function metaString(node: EpisodeNode | null, key: string): string | null {
|
|
|
42
50
|
return typeof value === "string" && value ? value : null;
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Where an episode sits in the chronicle: its folder's date, then its number —
|
|
55
|
+
* the order the folders keep on disk. Not `created_at`, which records when the
|
|
56
|
+
* wheel learned of an episode, and not the number alone, which the chronicle
|
|
57
|
+
* does not hand out in date order (episode 1000 follows episode 550 by two days).
|
|
58
|
+
*/
|
|
59
|
+
function chronicleKey(node: EpisodeNode): string {
|
|
60
|
+
const match = (episodeFolderOf(node) ?? "").match(/^(\d{4}-\d{2}-\d{2})-episode-(\d+)/i);
|
|
61
|
+
return match ? `${match[1]}-${match[2].padStart(6, "0")}` : (node.created_at ?? "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The episode's folder name — what the wheel's other records file it under.
|
|
66
|
+
*
|
|
67
|
+
* From the id when it is `chronicle:<folder>`. A few early episodes were
|
|
68
|
+
* registered under other ids (`node:knowledge:…`, a bare uuid); their node
|
|
69
|
+
* still carries the artefact reference, so the folder comes from
|
|
70
|
+
* `metadata.relative_path` when `metadata.root` names the chronicle.
|
|
71
|
+
*/
|
|
72
|
+
function episodeFolderOf(node: EpisodeNode): string | null {
|
|
73
|
+
if (node.id.startsWith("chronicle:")) return node.id.slice("chronicle:".length) || null;
|
|
74
|
+
const relative = metaString(node, "relative_path");
|
|
75
|
+
if (metaString(node, "root") !== "MIADI_CHRONICLE_ROOT" || !relative) return null;
|
|
76
|
+
const folder = relative.split("/")[0];
|
|
77
|
+
return folder && folder !== relative ? folder : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function EpisodeLink({ node, label }: { node: EpisodeNode | null; label: string }) {
|
|
81
|
+
if (!node) return <span />;
|
|
82
|
+
return (
|
|
83
|
+
<Link
|
|
84
|
+
href={`/episodes/${encodeURIComponent(node.id)}`}
|
|
85
|
+
className="text-xs text-muted-foreground hover:underline min-w-0 truncate"
|
|
86
|
+
title={node.name}
|
|
87
|
+
>
|
|
88
|
+
{label} {node.name}
|
|
89
|
+
</Link>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
45
93
|
export default function EpisodePage({ params }: { params: Promise<{ id: string }> }) {
|
|
46
94
|
const { id: rawId } = use(params);
|
|
47
95
|
const id = decodeURIComponent(rawId);
|
|
@@ -49,6 +97,8 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
49
97
|
const [episode, setEpisode] = useState<EpisodeNode | null>(null);
|
|
50
98
|
const [attention, setAttention] = useState<EpisodeNode[]>([]);
|
|
51
99
|
const [web, setWeb] = useState<{ nodes: EpisodeNode[]; edges: RelationalEdge[] } | null>(null);
|
|
100
|
+
const [before, setBefore] = useState<EpisodeNode | null>(null);
|
|
101
|
+
const [after, setAfter] = useState<EpisodeNode | null>(null);
|
|
52
102
|
const [loading, setLoading] = useState(true);
|
|
53
103
|
const [error, setError] = useState<string | null>(null);
|
|
54
104
|
|
|
@@ -67,6 +117,11 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
67
117
|
const found = all.find((n) => n.id === id) ?? null;
|
|
68
118
|
setEpisode(found);
|
|
69
119
|
|
|
120
|
+
const ordered = [...all].sort((a, b) => chronicleKey(a).localeCompare(chronicleKey(b)));
|
|
121
|
+
const at = ordered.findIndex((n) => n.id === id);
|
|
122
|
+
setBefore(at > 0 ? ordered[at - 1] : null);
|
|
123
|
+
setAfter(at >= 0 && at < ordered.length - 1 ? ordered[at + 1] : null);
|
|
124
|
+
|
|
70
125
|
setAttention(attentionRes.ok ? ((await attentionRes.json()).nodes ?? []) : []);
|
|
71
126
|
|
|
72
127
|
// A 404 here means the node is not in the wheel at all — distinct from a
|
|
@@ -85,6 +140,7 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
85
140
|
}, [load]);
|
|
86
141
|
|
|
87
142
|
const parentId = metaString(episode, "parent_id");
|
|
143
|
+
const folder = episode ? episodeFolderOf(episode) : null;
|
|
88
144
|
const neighbours = (web?.nodes ?? []).filter((n) => n.id !== id);
|
|
89
145
|
const parentHasEdge = Boolean(parentId && neighbours.some((n) => n.id === parentId));
|
|
90
146
|
|
|
@@ -117,7 +173,7 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
117
173
|
</Link>
|
|
118
174
|
<p className="mt-4 text-sm text-muted-foreground">
|
|
119
175
|
No episode node with id <code className="font-mono">{id}</code>. The wheel answered and
|
|
120
|
-
does not hold it
|
|
176
|
+
does not hold it: an episode folder can exist before it is registered on the wheel.
|
|
121
177
|
</p>
|
|
122
178
|
</div>
|
|
123
179
|
);
|
|
@@ -139,8 +195,16 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
139
195
|
</div>
|
|
140
196
|
<p className="font-mono text-xs text-muted-foreground mt-1 break-all">{episode.id}</p>
|
|
141
197
|
{episode.description && <p className="text-sm mt-3">{episode.description}</p>}
|
|
198
|
+
{(before || after) && (
|
|
199
|
+
<nav className="flex justify-between gap-4 mt-3" aria-label="Neighbouring episodes">
|
|
200
|
+
<EpisodeLink node={before} label="←" />
|
|
201
|
+
<EpisodeLink node={after} label="→" />
|
|
202
|
+
</nav>
|
|
203
|
+
)}
|
|
142
204
|
</div>
|
|
143
205
|
|
|
206
|
+
{folder && <EpisodeFilesPanel episodeId={`chronicle:${folder}`} />}
|
|
207
|
+
|
|
144
208
|
<section>
|
|
145
209
|
<h2 className="text-sm font-semibold mb-2">
|
|
146
210
|
Open attention {attention.length > 0 && `(${attention.length})`}
|
|
@@ -163,6 +227,8 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
163
227
|
)}
|
|
164
228
|
</section>
|
|
165
229
|
|
|
230
|
+
{folder && <EpisodeHoldings episodeId={`chronicle:${folder}`} episodePath={folder} />}
|
|
231
|
+
|
|
166
232
|
<section>
|
|
167
233
|
<h2 className="text-sm font-semibold mb-2">
|
|
168
234
|
Relations {neighbours.length > 0 && `(${neighbours.length})`}
|
|
@@ -172,8 +238,7 @@ export default function EpisodePage({ params }: { params: Promise<{ id: string }
|
|
|
172
238
|
<p className="mb-2 text-xs text-muted-foreground border rounded-lg p-3 bg-card">
|
|
173
239
|
This episode records a parent in <code>metadata.parent_id</code> (
|
|
174
240
|
<code className="break-all">{parentId}</code>) that was never written as a relation, so
|
|
175
|
-
it does not appear below and the graph cannot draw it.
|
|
176
|
-
this wheel are in that state.
|
|
241
|
+
it does not appear below and the graph cannot draw it.
|
|
177
242
|
</p>
|
|
178
243
|
)}
|
|
179
244
|
|
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", "circle"];
|
|
18
|
+
const NODE_TYPES: NodeType[] = ["human", "land", "spirit", "ancestor", "future", "knowledge", "circle", "agent"];
|
|
19
19
|
const DIRECTIONS: DirectionName[] = ["east", "south", "west", "north"];
|
|
20
|
-
const NODE_TYPE_ICONS: Record<NodeType, string> = { human: "👤", land: "🌍", spirit: "✨", ancestor: "🪶", future: "🌱", knowledge: "📚", circle: "⭕" };
|
|
20
|
+
const NODE_TYPE_ICONS: Record<NodeType, string> = { human: "👤", land: "🌍", spirit: "✨", ancestor: "🪶", future: "🌱", knowledge: "📚", circle: "⭕", agent: "🤖" };
|
|
21
21
|
const DIRECTION_ICONS: Record<DirectionName, string> = { east: "🌅", south: "🌞", west: "🌄", north: "❄️" };
|
|
22
22
|
|
|
23
23
|
function describeNode(node: NodeRecord): string {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An episode's folder on the episode page — listed, and any text file read in
|
|
5
|
+
* place. Read-only: the wheel reads an episode, Miadi writes it.
|
|
6
|
+
*
|
|
7
|
+
* Text is shown as text. Markdown is not rendered here: rendering it means a
|
|
8
|
+
* renderer and a sanitizer between an episode's files and this page, and plain
|
|
9
|
+
* text is already readable. Media and documents are listed, not played.
|
|
10
|
+
*
|
|
11
|
+
* @see app/api/episodes/[id]/files/route.ts — and the rules it states
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
15
|
+
|
|
16
|
+
type EpisodeFile = {
|
|
17
|
+
relativePath: string;
|
|
18
|
+
name: string;
|
|
19
|
+
kind: "text" | "document" | "media";
|
|
20
|
+
size: number;
|
|
21
|
+
modifiedAt: string;
|
|
22
|
+
previewable: boolean;
|
|
23
|
+
guidance: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Room =
|
|
27
|
+
| { state: "open"; episode_path: string; files: EpisodeFile[] }
|
|
28
|
+
| { state: "no-root" }
|
|
29
|
+
| { state: "absent"; episode_path: string }
|
|
30
|
+
| { state: "community"; episode_path: string }
|
|
31
|
+
| { state: "not-an-episode" | "refused" | "error"; error?: string };
|
|
32
|
+
|
|
33
|
+
type Opened = { path: string; content?: string; error?: string };
|
|
34
|
+
|
|
35
|
+
function formatSize(bytes: number): string {
|
|
36
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
37
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
38
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Why a folder is not shown, in words a reader can act on. */
|
|
42
|
+
function closedReason(room: Room): string | null {
|
|
43
|
+
switch (room.state) {
|
|
44
|
+
case "no-root":
|
|
45
|
+
return "This wheel's host has no chronicle folder (MIADI_CHRONICLE_ROOT), so it knows the episode exists but cannot open it.";
|
|
46
|
+
case "absent":
|
|
47
|
+
return "The episode's folder is not on this host. The wheel holds its record; the folder lives in a chronicle checkout that has not reached this machine.";
|
|
48
|
+
case "community":
|
|
49
|
+
return "This episode is for the community signed in. The wheel has no sign-in, so its folder stays closed here.";
|
|
50
|
+
case "open":
|
|
51
|
+
return null;
|
|
52
|
+
default:
|
|
53
|
+
return room.error ?? "The folder could not be opened.";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
|
|
58
|
+
const base = `/api/episodes/${encodeURIComponent(episodeId)}/files`;
|
|
59
|
+
const [room, setRoom] = useState<Room | null>(null);
|
|
60
|
+
const [opened, setOpened] = useState<Opened | null>(null);
|
|
61
|
+
// The path last asked for, so a slow answer for an earlier click cannot
|
|
62
|
+
// replace the file the reader opened after it.
|
|
63
|
+
const wanted = useRef<string | null>(null);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
let live = true;
|
|
67
|
+
setRoom(null);
|
|
68
|
+
setOpened(null);
|
|
69
|
+
fetch(base)
|
|
70
|
+
.then((res) => res.json())
|
|
71
|
+
.then((body: Room) => live && setRoom(body))
|
|
72
|
+
.catch((e) => live && setRoom({ state: "error", error: e instanceof Error ? e.message : String(e) }));
|
|
73
|
+
return () => {
|
|
74
|
+
live = false;
|
|
75
|
+
};
|
|
76
|
+
}, [base]);
|
|
77
|
+
|
|
78
|
+
const toggle = useCallback(
|
|
79
|
+
async (file: EpisodeFile) => {
|
|
80
|
+
if (wanted.current === file.relativePath) {
|
|
81
|
+
wanted.current = null;
|
|
82
|
+
setOpened(null);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
wanted.current = file.relativePath;
|
|
86
|
+
setOpened({ path: file.relativePath });
|
|
87
|
+
let next: Opened;
|
|
88
|
+
try {
|
|
89
|
+
const res = await fetch(`${base}?path=${encodeURIComponent(file.relativePath)}`);
|
|
90
|
+
const body = await res.json();
|
|
91
|
+
next = res.ok
|
|
92
|
+
? { path: file.relativePath, content: body.content }
|
|
93
|
+
: { path: file.relativePath, error: body.error ?? body.state ?? `HTTP ${res.status}` };
|
|
94
|
+
} catch (e) {
|
|
95
|
+
next = { path: file.relativePath, error: e instanceof Error ? e.message : String(e) };
|
|
96
|
+
}
|
|
97
|
+
if (wanted.current === file.relativePath) setOpened(next);
|
|
98
|
+
},
|
|
99
|
+
[base],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const files =
|
|
103
|
+
room?.state === "open"
|
|
104
|
+
? // The episode's own work first; host and agent guidance after it.
|
|
105
|
+
[...room.files].sort((a, b) => Number(a.guidance) - Number(b.guidance))
|
|
106
|
+
: [];
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<section>
|
|
110
|
+
<h2 className="text-sm font-semibold mb-2">
|
|
111
|
+
Files {files.length > 0 && `(${files.length})`}
|
|
112
|
+
</h2>
|
|
113
|
+
|
|
114
|
+
{room === null ? (
|
|
115
|
+
<p className="text-sm text-muted-foreground">Opening the folder…</p>
|
|
116
|
+
) : room.state !== "open" ? (
|
|
117
|
+
<p className="text-sm text-muted-foreground">{closedReason(room)}</p>
|
|
118
|
+
) : files.length === 0 ? (
|
|
119
|
+
<p className="text-sm text-muted-foreground">The folder holds nothing the room shows.</p>
|
|
120
|
+
) : (
|
|
121
|
+
<ul className="space-y-1">
|
|
122
|
+
{files.map((file) => {
|
|
123
|
+
const isOpen = opened?.path === file.relativePath;
|
|
124
|
+
return (
|
|
125
|
+
<li key={file.relativePath}>
|
|
126
|
+
<div className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap">
|
|
127
|
+
{file.previewable ? (
|
|
128
|
+
<button
|
|
129
|
+
onClick={() => toggle(file)}
|
|
130
|
+
className="text-sm font-mono text-left break-all hover:underline"
|
|
131
|
+
aria-expanded={isOpen}
|
|
132
|
+
>
|
|
133
|
+
{isOpen ? "▾" : "▸"} {file.relativePath}
|
|
134
|
+
</button>
|
|
135
|
+
) : (
|
|
136
|
+
<span className="text-sm font-mono break-all text-muted-foreground">
|
|
137
|
+
{file.relativePath}
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
<span className="text-xs text-muted-foreground shrink-0 flex gap-2 items-baseline">
|
|
141
|
+
{file.guidance && <span className="mw-badge">guidance</span>}
|
|
142
|
+
{!file.previewable && <span className="mw-badge">{file.kind}</span>}
|
|
143
|
+
<span>{formatSize(file.size)}</span>
|
|
144
|
+
<span>{file.modifiedAt.slice(0, 10)}</span>
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
{isOpen && (
|
|
148
|
+
<div className="mt-1 p-3 border rounded-lg bg-card">
|
|
149
|
+
{opened?.error ? (
|
|
150
|
+
<p className="text-sm text-muted-foreground">{opened.error}</p>
|
|
151
|
+
) : opened?.content === undefined ? (
|
|
152
|
+
<p className="text-sm text-muted-foreground">Reading…</p>
|
|
153
|
+
) : (
|
|
154
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-words max-h-[70vh] overflow-auto">
|
|
155
|
+
{opened.content}
|
|
156
|
+
</pre>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</li>
|
|
161
|
+
);
|
|
162
|
+
})}
|
|
163
|
+
</ul>
|
|
164
|
+
)}
|
|
165
|
+
</section>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the wheel holds for one episode, beyond its node.
|
|
5
|
+
*
|
|
6
|
+
* Every route read here has filtered by episode for some time — ceremonies,
|
|
7
|
+
* inquiry weaves, plan perspectives and captures by `episode_path`, diary
|
|
8
|
+
* entries by the episode's node id — and the episode page never asked any of
|
|
9
|
+
* them. Nothing here is new data; it is the wheel's own records, gathered where
|
|
10
|
+
* a person looking at the episode can see them. Ref jgwill/medicine-wheel#153.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useEffect, useState } from "react";
|
|
14
|
+
|
|
15
|
+
type Ceremony = {
|
|
16
|
+
id: string;
|
|
17
|
+
type: string;
|
|
18
|
+
direction?: string;
|
|
19
|
+
intentions?: string[];
|
|
20
|
+
timestamp: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type DiaryEntry = {
|
|
24
|
+
id: string;
|
|
25
|
+
timestamp: string;
|
|
26
|
+
phase?: string;
|
|
27
|
+
entryType?: string;
|
|
28
|
+
content: string;
|
|
29
|
+
metadata?: { participant_name?: string };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type InquiryWeave = {
|
|
33
|
+
id: string;
|
|
34
|
+
artefact?: { id?: string };
|
|
35
|
+
last_sync?: { state?: string };
|
|
36
|
+
issue?: string;
|
|
37
|
+
issue_url?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type PlanPerspective = {
|
|
41
|
+
id: string;
|
|
42
|
+
plan?: { plan_filename?: string; captured_at?: string };
|
|
43
|
+
narrative?: { title?: string };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type Capture = {
|
|
47
|
+
id: string;
|
|
48
|
+
filename: string;
|
|
49
|
+
kind?: string;
|
|
50
|
+
artifact_role?: string;
|
|
51
|
+
device?: string;
|
|
52
|
+
registered_at?: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type Holdings = {
|
|
56
|
+
ceremonies: Ceremony[];
|
|
57
|
+
diary: DiaryEntry[];
|
|
58
|
+
weaves: InquiryWeave[];
|
|
59
|
+
perspectives: PlanPerspective[];
|
|
60
|
+
captures: Capture[];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const DIRECTION_ICONS: Record<string, string> = {
|
|
64
|
+
east: "🌅",
|
|
65
|
+
south: "🔥",
|
|
66
|
+
west: "🌊",
|
|
67
|
+
north: "❄️",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** A list from one route, or [] when that route did not answer — one silent route must not blank the rest. */
|
|
71
|
+
async function listFrom<T>(url: string, key: string): Promise<{ items: T[]; failed: boolean }> {
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(url);
|
|
74
|
+
if (!res.ok) return { items: [], failed: true };
|
|
75
|
+
const body = await res.json();
|
|
76
|
+
return { items: Array.isArray(body?.[key]) ? body[key] : [], failed: false };
|
|
77
|
+
} catch {
|
|
78
|
+
return { items: [], failed: true };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function byTime<T extends { timestamp: string }>(a: T, b: T): number {
|
|
83
|
+
return a.timestamp.localeCompare(b.timestamp);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function EpisodeHoldings({ episodeId, episodePath }: { episodeId: string; episodePath: string }) {
|
|
87
|
+
const [holdings, setHoldings] = useState<Holdings | null>(null);
|
|
88
|
+
const [silent, setSilent] = useState<string[]>([]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
let live = true;
|
|
92
|
+
const p = encodeURIComponent(episodePath);
|
|
93
|
+
Promise.all([
|
|
94
|
+
listFrom<Ceremony>(`/api/ceremonies?episode_path=${p}&limit=all`, "ceremonies"),
|
|
95
|
+
listFrom<DiaryEntry>(`/api/diary?chronicle=${encodeURIComponent(episodeId)}&limit=all`, "entries"),
|
|
96
|
+
listFrom<InquiryWeave>(`/api/inquiry-weaves?episode_path=${p}`, "inquiry_weaves"),
|
|
97
|
+
listFrom<PlanPerspective>(`/api/plan-perspectives?episode_path=${p}`, "plan_perspectives"),
|
|
98
|
+
listFrom<Capture>(`/api/captures?episode_path=${p}`, "captures"),
|
|
99
|
+
]).then(([ceremonies, diary, weaves, perspectives, captures]) => {
|
|
100
|
+
if (!live) return;
|
|
101
|
+
setHoldings({
|
|
102
|
+
ceremonies: [...ceremonies.items].sort(byTime),
|
|
103
|
+
diary: [...diary.items].sort(byTime),
|
|
104
|
+
weaves: weaves.items,
|
|
105
|
+
perspectives: perspectives.items,
|
|
106
|
+
captures: captures.items,
|
|
107
|
+
});
|
|
108
|
+
setSilent(
|
|
109
|
+
(
|
|
110
|
+
[
|
|
111
|
+
["ceremonies", ceremonies],
|
|
112
|
+
["diary entries", diary],
|
|
113
|
+
["inquiry weaves", weaves],
|
|
114
|
+
["plan perspectives", perspectives],
|
|
115
|
+
["captures", captures],
|
|
116
|
+
] as const
|
|
117
|
+
)
|
|
118
|
+
.filter(([, r]) => r.failed)
|
|
119
|
+
.map(([name]) => name),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
return () => {
|
|
123
|
+
live = false;
|
|
124
|
+
};
|
|
125
|
+
}, [episodeId, episodePath]);
|
|
126
|
+
|
|
127
|
+
if (!holdings) {
|
|
128
|
+
return (
|
|
129
|
+
<section>
|
|
130
|
+
<h2 className="text-sm font-semibold mb-2">Held on the wheel</h2>
|
|
131
|
+
<p className="text-sm text-muted-foreground">Asking the wheel…</p>
|
|
132
|
+
</section>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { ceremonies, diary, weaves, perspectives, captures } = holdings;
|
|
137
|
+
// A route that did not answer is named as silent, not as empty.
|
|
138
|
+
const empty = (
|
|
139
|
+
[
|
|
140
|
+
["ceremonies", ceremonies],
|
|
141
|
+
["diary entries", diary],
|
|
142
|
+
["inquiry weaves", weaves],
|
|
143
|
+
["plan perspectives", perspectives],
|
|
144
|
+
["captures", captures],
|
|
145
|
+
] as const
|
|
146
|
+
)
|
|
147
|
+
.filter(([name, items]) => items.length === 0 && !silent.includes(name))
|
|
148
|
+
.map(([name]) => name);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<section className="space-y-4">
|
|
152
|
+
<h2 className="text-sm font-semibold">Held on the wheel</h2>
|
|
153
|
+
|
|
154
|
+
{ceremonies.length > 0 && (
|
|
155
|
+
<div>
|
|
156
|
+
<h3 className="text-xs font-semibold text-muted-foreground mb-1">Ceremonies ({ceremonies.length})</h3>
|
|
157
|
+
<ul className="space-y-1">
|
|
158
|
+
{ceremonies.map((c) => (
|
|
159
|
+
<li key={c.id} className="p-2 border rounded-lg bg-card">
|
|
160
|
+
<div className="flex items-baseline justify-between gap-2 flex-wrap">
|
|
161
|
+
<span className="text-sm font-medium">
|
|
162
|
+
{c.direction && DIRECTION_ICONS[c.direction]} {c.type.replace(/_/g, " ")}
|
|
163
|
+
</span>
|
|
164
|
+
<span className="text-xs text-muted-foreground">{c.timestamp.slice(0, 16).replace("T", " ")}</span>
|
|
165
|
+
</div>
|
|
166
|
+
{c.intentions?.[0] && <p className="text-sm text-muted-foreground mt-1">{c.intentions[0]}</p>}
|
|
167
|
+
</li>
|
|
168
|
+
))}
|
|
169
|
+
</ul>
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
|
|
173
|
+
{diary.length > 0 && (
|
|
174
|
+
<div>
|
|
175
|
+
<h3 className="text-xs font-semibold text-muted-foreground mb-1">Diary ({diary.length})</h3>
|
|
176
|
+
<ul className="space-y-1">
|
|
177
|
+
{diary.map((d) => (
|
|
178
|
+
<li key={d.id} className="p-2 border rounded-lg bg-card">
|
|
179
|
+
<p className="text-xs text-muted-foreground">
|
|
180
|
+
{[d.metadata?.participant_name, d.entryType, d.phase].filter(Boolean).join(" · ")}
|
|
181
|
+
</p>
|
|
182
|
+
<p className="text-sm mt-1 whitespace-pre-wrap">{d.content}</p>
|
|
183
|
+
</li>
|
|
184
|
+
))}
|
|
185
|
+
</ul>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{weaves.length > 0 && (
|
|
190
|
+
<div>
|
|
191
|
+
<h3 className="text-xs font-semibold text-muted-foreground mb-1">Inquiry weaves ({weaves.length})</h3>
|
|
192
|
+
<ul className="space-y-1">
|
|
193
|
+
{weaves.map((w) => (
|
|
194
|
+
<li key={w.id} className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-2 flex-wrap">
|
|
195
|
+
<span className="text-sm font-mono break-all">{w.artefact?.id ?? w.id}</span>
|
|
196
|
+
<span className="text-xs text-muted-foreground flex gap-2">
|
|
197
|
+
{w.last_sync?.state && <span className="mw-badge">{w.last_sync.state}</span>}
|
|
198
|
+
{w.issue_url?.startsWith("https://") ? (
|
|
199
|
+
<a href={w.issue_url} className="hover:underline" target="_blank" rel="noreferrer">
|
|
200
|
+
{w.issue ?? "issue"}
|
|
201
|
+
</a>
|
|
202
|
+
) : (
|
|
203
|
+
w.issue
|
|
204
|
+
)}
|
|
205
|
+
</span>
|
|
206
|
+
</li>
|
|
207
|
+
))}
|
|
208
|
+
</ul>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
|
|
212
|
+
{perspectives.length > 0 && (
|
|
213
|
+
<div>
|
|
214
|
+
<h3 className="text-xs font-semibold text-muted-foreground mb-1">Plan perspectives ({perspectives.length})</h3>
|
|
215
|
+
<ul className="space-y-1">
|
|
216
|
+
{perspectives.map((pp) => (
|
|
217
|
+
<li key={pp.id} className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-2 flex-wrap">
|
|
218
|
+
<span className="text-sm">{pp.narrative?.title ?? pp.plan?.plan_filename ?? pp.id}</span>
|
|
219
|
+
<span className="text-xs text-muted-foreground">{pp.plan?.captured_at?.slice(0, 10)}</span>
|
|
220
|
+
</li>
|
|
221
|
+
))}
|
|
222
|
+
</ul>
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
{captures.length > 0 && (
|
|
227
|
+
<div>
|
|
228
|
+
<h3 className="text-xs font-semibold text-muted-foreground mb-1">Captures ({captures.length})</h3>
|
|
229
|
+
<ul className="space-y-1">
|
|
230
|
+
{captures.map((c) => (
|
|
231
|
+
<li key={c.id} className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-2 flex-wrap">
|
|
232
|
+
<span className="text-sm font-mono break-all">{c.filename}</span>
|
|
233
|
+
<span className="text-xs text-muted-foreground flex gap-2">
|
|
234
|
+
<span className="mw-badge">{c.artifact_role ?? c.kind ?? "capture"}</span>
|
|
235
|
+
{c.device && <span>{c.device}</span>}
|
|
236
|
+
<span>{c.registered_at?.slice(0, 10)}</span>
|
|
237
|
+
</span>
|
|
238
|
+
</li>
|
|
239
|
+
))}
|
|
240
|
+
</ul>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{empty.length > 0 && (
|
|
245
|
+
<p className="text-xs text-muted-foreground">No {empty.join(", ")} bound to this episode.</p>
|
|
246
|
+
)}
|
|
247
|
+
{silent.length > 0 && (
|
|
248
|
+
<p className="text-xs text-muted-foreground">The wheel did not answer for: {silent.join(", ")}.</p>
|
|
249
|
+
)}
|
|
250
|
+
</section>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An episode's folder, opened from the wheel — read-only.
|
|
3
|
+
*
|
|
4
|
+
* Every `chronicle_episode` node is registered under `chronicle:<folder>` and
|
|
5
|
+
* carries an artefact reference: `metadata.root` names `MIADI_CHRONICLE_ROOT`
|
|
6
|
+
* and `metadata.relative_path` the episode's `episode.yaml` beneath it. The
|
|
7
|
+
* wheel has always recorded where the work is and never opened it, so the
|
|
8
|
+
* Episodes tab could show an episode's name and nothing it contains.
|
|
9
|
+
*
|
|
10
|
+
* The rules for what an episode folder holds and what may be read from it are
|
|
11
|
+
* not written here. They live in `@miadi/episode-vessel`, published from
|
|
12
|
+
* jgwill/Miadi and used by every other surface that opens a vessel — the Miadi
|
|
13
|
+
* room and its file routes, the terminal browser, the phone. A second copy of
|
|
14
|
+
* them here is how two surfaces come to disagree about what is private.
|
|
15
|
+
*
|
|
16
|
+
* Two of those rules decide what the wheel shows. Both are applied as for a
|
|
17
|
+
* visitor, because the wheel has no signed-in reader:
|
|
18
|
+
*
|
|
19
|
+
* - **the room rule**: `ceremonies/` (a circle's notes and participants) and
|
|
20
|
+
* `captures/` (recordings) are not part of the room. The listing leaves them
|
|
21
|
+
* out and a read refuses them.
|
|
22
|
+
* - **the audience rule**: an episode whose `episode.yaml` says
|
|
23
|
+
* `audience: community` is closed to a visitor — no listing, no read.
|
|
24
|
+
*
|
|
25
|
+
* So the wheel shows no more of a folder than Miadi's public room already does.
|
|
26
|
+
* Nothing here writes. Ref jgwill/medicine-wheel#153.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import {
|
|
31
|
+
EpisodePathError,
|
|
32
|
+
isOutsideTheRoom,
|
|
33
|
+
listEpisodeFiles,
|
|
34
|
+
parseEpisodeFolder,
|
|
35
|
+
readEpisodeAudience,
|
|
36
|
+
readEpisodeText,
|
|
37
|
+
resolveEpisodeDir,
|
|
38
|
+
resolveEpisodeFile,
|
|
39
|
+
type EpisodeFile,
|
|
40
|
+
type FileRevision,
|
|
41
|
+
} from "@miadi/episode-vessel";
|
|
42
|
+
|
|
43
|
+
export type { EpisodeFile };
|
|
44
|
+
|
|
45
|
+
export interface EpisodeRootOptions {
|
|
46
|
+
/** The chronicle root. Defaults to `MIADI_CHRONICLE_ROOT`. */
|
|
47
|
+
root?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Why a folder could not be opened. Each one is a different thing to tell a reader. */
|
|
51
|
+
export type EpisodeRoomClosed =
|
|
52
|
+
/** This host was given no chronicle root, or the one it names does not exist. */
|
|
53
|
+
| { state: "no-root" }
|
|
54
|
+
/** The chronicle on this host has no folder by that name. */
|
|
55
|
+
| { state: "absent"; episode_path: string }
|
|
56
|
+
/** The episode is for the community signed in, and the wheel has no signed-in reader. */
|
|
57
|
+
| { state: "community"; episode_path: string };
|
|
58
|
+
|
|
59
|
+
export type EpisodeRoom =
|
|
60
|
+
| { state: "open"; episode_path: string; files: EpisodeFile[] }
|
|
61
|
+
| EpisodeRoomClosed;
|
|
62
|
+
|
|
63
|
+
export type EpisodeRoomText =
|
|
64
|
+
| {
|
|
65
|
+
state: "open";
|
|
66
|
+
episode_path: string;
|
|
67
|
+
path: string;
|
|
68
|
+
content: string;
|
|
69
|
+
contentType: string;
|
|
70
|
+
size: number;
|
|
71
|
+
revision: FileRevision;
|
|
72
|
+
}
|
|
73
|
+
/** Under `ceremonies/` or `captures/`: not part of the room. */
|
|
74
|
+
| { state: "outside-the-room"; episode_path: string; path: string }
|
|
75
|
+
| EpisodeRoomClosed;
|
|
76
|
+
|
|
77
|
+
const ID_PREFIX = "chronicle:";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `chronicle:<folder>` → `<folder>`, or null when the id does not name exactly
|
|
81
|
+
* one episode folder.
|
|
82
|
+
*
|
|
83
|
+
* `parseEpisodeFolder` accepts any slug, `/.` included, and `<folder>/.`
|
|
84
|
+
* resolves to the same directory on disk while reading as a different name to
|
|
85
|
+
* the manifest reader — the hole that opened a community room in Miadi
|
|
86
|
+
* (jgwill/Miadi#712). A bare name is this module's own test.
|
|
87
|
+
*/
|
|
88
|
+
export function episodeFolderOf(id: string): string | null {
|
|
89
|
+
const value = id.trim();
|
|
90
|
+
if (!value.startsWith(ID_PREFIX)) return null;
|
|
91
|
+
const folder = value.slice(ID_PREFIX.length);
|
|
92
|
+
if (!folder || /[\\/]/.test(folder) || folder.startsWith(".")) return null;
|
|
93
|
+
return parseEpisodeFolder(folder) ? folder : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function rootOf(options: EpisodeRootOptions): string | null {
|
|
97
|
+
const root = options.root ?? process.env.MIADI_CHRONICLE_ROOT;
|
|
98
|
+
return root?.trim() ? root.trim() : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The gate both a listing and a read pass through, in the same order. */
|
|
102
|
+
function gate(folder: string, root: string | null): EpisodeRoomClosed | null {
|
|
103
|
+
if (!root) return { state: "no-root" };
|
|
104
|
+
try {
|
|
105
|
+
resolveEpisodeDir(folder, { root });
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof EpisodePathError && error.code === "NO_ROOT") return { state: "no-root" };
|
|
108
|
+
if (error instanceof EpisodePathError && error.code === "ENOENT") {
|
|
109
|
+
return { state: "absent", episode_path: folder };
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
if (readEpisodeAudience(folder, { root }) === "community") {
|
|
114
|
+
return { state: "community", episode_path: folder };
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The files of one episode's room: everything the vessel lists except what is outside the room. */
|
|
120
|
+
export function openEpisodeRoom(folder: string, options: EpisodeRootOptions = {}): EpisodeRoom {
|
|
121
|
+
const root = rootOf(options);
|
|
122
|
+
const closed = gate(folder, root);
|
|
123
|
+
if (closed) return closed;
|
|
124
|
+
const files = listEpisodeFiles(folder, {
|
|
125
|
+
root: root!,
|
|
126
|
+
// Applied before a file consumes the listing cap, so a hundred capture
|
|
127
|
+
// files cannot push an ordinary artefact off the end.
|
|
128
|
+
include: (relativePath) => !isOutsideTheRoom(relativePath),
|
|
129
|
+
});
|
|
130
|
+
return { state: "open", episode_path: folder, files };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* One text file of the room, whole.
|
|
135
|
+
*
|
|
136
|
+
* Throws `EpisodePathError` for what the vessel refuses — an unsafe path
|
|
137
|
+
* (`INVALID_PATH`), a missing file (`ENOENT`), text too large to read whole
|
|
138
|
+
* (`TOO_LARGE`), or a file that is not text. The HTTP edge maps those once.
|
|
139
|
+
*/
|
|
140
|
+
export function readEpisodeRoomText(
|
|
141
|
+
folder: string,
|
|
142
|
+
relativePath: string,
|
|
143
|
+
options: EpisodeRootOptions = {},
|
|
144
|
+
): EpisodeRoomText {
|
|
145
|
+
const root = rootOf(options);
|
|
146
|
+
const closed = gate(folder, root);
|
|
147
|
+
if (closed) return closed;
|
|
148
|
+
|
|
149
|
+
// Validates the path (no `.`, `..`, empty or dot-prefixed segment) and
|
|
150
|
+
// refuses a symlinked file, but a linked *directory* partway down still
|
|
151
|
+
// resolves. The room rule is asked of both the name the caller gave and the
|
|
152
|
+
// file it really is, so `notes/<id>/notes.md` through a link to
|
|
153
|
+
// `ceremonies/` cannot read a ceremony's notes under another name.
|
|
154
|
+
const resolved = resolveEpisodeFile(folder, relativePath, { root: root! });
|
|
155
|
+
const real = path.relative(resolved.dir, resolved.path).split(path.sep).join("/");
|
|
156
|
+
if (isOutsideTheRoom(relativePath) || isOutsideTheRoom(real)) {
|
|
157
|
+
return { state: "outside-the-room", episode_path: folder, path: relativePath };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const text = readEpisodeText(folder, relativePath, { root: root! });
|
|
161
|
+
return {
|
|
162
|
+
state: "open",
|
|
163
|
+
episode_path: folder,
|
|
164
|
+
path: relativePath,
|
|
165
|
+
content: text.content,
|
|
166
|
+
contentType: text.contentType,
|
|
167
|
+
size: text.size,
|
|
168
|
+
revision: text.revision,
|
|
169
|
+
};
|
|
170
|
+
}
|
package/lib/memory.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from '@medicine-wheel/memory';
|
|
21
21
|
import { WHEEL_PEER, createHonchoClient, honchoFromEnv, honchoMemoryProvider } from '@medicine-wheel/honcho';
|
|
22
22
|
import { createProvider } from '@medicine-wheel/storage-provider';
|
|
23
|
+
import { speakerKindOf } from '@medicine-wheel/community-identity';
|
|
23
24
|
import { getAllBeats } from '@/lib/store';
|
|
24
25
|
import { ceremonyEpisodePath } from '@/lib/ceremony-response';
|
|
25
26
|
|
|
@@ -116,6 +117,22 @@ export function wheelForMemory(): MemoryWheel {
|
|
|
116
117
|
);
|
|
117
118
|
return out;
|
|
118
119
|
},
|
|
120
|
+
|
|
121
|
+
async kinds(ids) {
|
|
122
|
+
const store = await createProvider();
|
|
123
|
+
const out: Record<string, 'person' | 'agent' | 'wheel'> = {};
|
|
124
|
+
await Promise.all(
|
|
125
|
+
ids.map(async (id) => {
|
|
126
|
+
if (id === WHEEL_PEER) {
|
|
127
|
+
out[id] = 'wheel';
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const kind = speakerKindOf(await store.getNode(id).catch(() => null));
|
|
131
|
+
if (kind) out[id] = kind;
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
return out;
|
|
135
|
+
},
|
|
119
136
|
};
|
|
120
137
|
}
|
|
121
138
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
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,31 +91,33 @@
|
|
|
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.
|
|
95
|
-
"@medicine-wheel/ceremony-protocol": "^0.
|
|
96
|
-
"@medicine-wheel/community-
|
|
97
|
-
"@medicine-wheel/
|
|
98
|
-
"@medicine-wheel/
|
|
99
|
-
"@medicine-wheel/
|
|
100
|
-
"@medicine-wheel/data-store
|
|
101
|
-
"@medicine-wheel/
|
|
102
|
-
"@medicine-wheel/
|
|
103
|
-
"@medicine-wheel/
|
|
104
|
-
"@medicine-wheel/
|
|
105
|
-
"@medicine-wheel/
|
|
106
|
-
"@medicine-wheel/importance-unit": "^0.
|
|
107
|
-
"@medicine-wheel/mcp": "^4.
|
|
108
|
-
"@medicine-wheel/
|
|
109
|
-
"@medicine-wheel/narrative-
|
|
110
|
-
"@medicine-wheel/
|
|
111
|
-
"@medicine-wheel/
|
|
112
|
-
"@medicine-wheel/
|
|
113
|
-
"@medicine-wheel/
|
|
114
|
-
"@medicine-wheel/relational-
|
|
115
|
-
"@medicine-wheel/
|
|
116
|
-
"@medicine-wheel/
|
|
117
|
-
"@medicine-wheel/
|
|
118
|
-
"@medicine-wheel/
|
|
94
|
+
"@medicine-wheel/ceremonial-diary": "^0.17.1",
|
|
95
|
+
"@medicine-wheel/ceremony-protocol": "^0.17.1",
|
|
96
|
+
"@medicine-wheel/community-identity": "^0.17.1",
|
|
97
|
+
"@medicine-wheel/community-review": "^0.17.1",
|
|
98
|
+
"@medicine-wheel/consent-lifecycle": "^0.17.1",
|
|
99
|
+
"@medicine-wheel/creative-orientation": "^0.17.1",
|
|
100
|
+
"@medicine-wheel/data-store": "^0.17.1",
|
|
101
|
+
"@medicine-wheel/data-store-postgres": "^0.17.1",
|
|
102
|
+
"@medicine-wheel/fire-keeper": "^0.17.1",
|
|
103
|
+
"@medicine-wheel/github-ceremony": "^0.17.1",
|
|
104
|
+
"@medicine-wheel/graph-viz": "^0.17.1",
|
|
105
|
+
"@medicine-wheel/honcho": "^0.17.1",
|
|
106
|
+
"@medicine-wheel/importance-unit": "^0.17.1",
|
|
107
|
+
"@medicine-wheel/mcp": "^4.17.1",
|
|
108
|
+
"@medicine-wheel/memory": "^0.17.1",
|
|
109
|
+
"@medicine-wheel/narrative-cluster": "^0.17.1",
|
|
110
|
+
"@medicine-wheel/narrative-engine": "^0.17.1",
|
|
111
|
+
"@medicine-wheel/ontology-core": "^0.17.1",
|
|
112
|
+
"@medicine-wheel/perception-layer": "^0.17.1",
|
|
113
|
+
"@medicine-wheel/prompt-decomposition": "^0.17.1",
|
|
114
|
+
"@medicine-wheel/relational-index": "^0.17.1",
|
|
115
|
+
"@medicine-wheel/relational-query": "^0.17.1",
|
|
116
|
+
"@medicine-wheel/session-reader": "^0.17.1",
|
|
117
|
+
"@medicine-wheel/storage-provider": "^0.17.1",
|
|
118
|
+
"@medicine-wheel/transformation-tracker": "^0.17.1",
|
|
119
|
+
"@medicine-wheel/ui-components": "^0.17.1",
|
|
120
|
+
"@miadi/episode-vessel": "^0.2.1",
|
|
119
121
|
"@neondatabase/serverless": "^0.10.0",
|
|
120
122
|
"@xyflow/react": "^12.3.0",
|
|
121
123
|
"clsx": "^2.1.1",
|