@medicine-wheel/app 0.17.11 → 0.19.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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The folders of an episode, drawn — every one open to begin with, so a file's
3
+ * folder is plain to see. Presentational: the panel owns which folders are
4
+ * closed and how a file's own row looks.
5
+ *
6
+ * @see lib/episode-file-tree.ts — how the paths are grouped and in what order
7
+ */
8
+
9
+ import type { ReactNode } from "react";
10
+ import type { FileTreeFolder, TreeableFile } from "@/lib/episode-file-tree";
11
+
12
+ export interface EpisodeFileTreeProps<T extends TreeableFile> {
13
+ tree: FileTreeFolder<T>;
14
+ /** Paths of the folders the reader closed. Any folder not here is open. */
15
+ collapsed: ReadonlySet<string>;
16
+ onToggleFolder: (path: string) => void;
17
+ /** One file's row, and whatever opens beneath it. `name` is the path's last segment. */
18
+ renderFile: (file: T, name: string) => ReactNode;
19
+ }
20
+
21
+ export function EpisodeFileTree<T extends TreeableFile>({
22
+ tree,
23
+ collapsed,
24
+ onToggleFolder,
25
+ renderFile,
26
+ }: EpisodeFileTreeProps<T>) {
27
+ return (
28
+ <ul className="space-y-1">
29
+ {tree.children.map((node) => {
30
+ if (node.type === "file") {
31
+ return <li key={node.path}>{renderFile(node.file, node.name)}</li>;
32
+ }
33
+ const isOpen = !collapsed.has(node.path);
34
+ return (
35
+ <li key={node.path} data-folder={node.path}>
36
+ <button
37
+ type="button"
38
+ onClick={() => onToggleFolder(node.path)}
39
+ aria-expanded={isOpen}
40
+ title={node.path}
41
+ className="w-full p-2 flex items-baseline gap-2 text-left text-sm font-semibold hover:underline"
42
+ >
43
+ <span aria-hidden="true" className="text-muted-foreground w-[1ch]">
44
+ {isOpen ? "▾" : "▸"}
45
+ </span>
46
+ <span aria-hidden="true">{isOpen ? "📂" : "📁"}</span>
47
+ <span className="font-mono break-all">{node.name}</span>
48
+ <span className="ml-auto text-xs font-normal text-muted-foreground shrink-0">
49
+ {node.fileCount} {node.fileCount === 1 ? "file" : "files"}
50
+ </span>
51
+ </button>
52
+ {isOpen && (
53
+ <div className="ml-4 pl-2 border-l">
54
+ <EpisodeFileTree
55
+ tree={node}
56
+ collapsed={collapsed}
57
+ onToggleFolder={onToggleFolder}
58
+ renderFile={renderFile}
59
+ />
60
+ </div>
61
+ )}
62
+ </li>
63
+ );
64
+ })}
65
+ </ul>
66
+ );
67
+ }
@@ -4,6 +4,9 @@
4
4
  * An episode's folder on the episode page — listed, and any text file read in
5
5
  * place. Read-only: the wheel reads an episode, Miadi writes it.
6
6
  *
7
+ * Files are listed in the folders their paths name, every one open to begin
8
+ * with, so a reader can tell which folder a file is in.
9
+ *
7
10
  * Text is shown as text. Markdown is not rendered here: rendering it means a
8
11
  * renderer and a sanitizer between an episode's files and this page, and plain
9
12
  * text is already readable. Media and documents are listed, not played.
@@ -11,7 +14,9 @@
11
14
  * @see app/api/episodes/[id]/files/route.ts — and the rules it states
12
15
  */
13
16
 
14
- import { useCallback, useEffect, useRef, useState } from "react";
17
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18
+ import { EpisodeFileTree } from "@/components/episode-file-tree";
19
+ import { buildFileTree, folderPaths } from "@/lib/episode-file-tree";
15
20
 
16
21
  type EpisodeFile = {
17
22
  relativePath: string;
@@ -61,6 +66,8 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
61
66
  // The path last asked for, so a slow answer for an earlier click cannot
62
67
  // replace the file the reader opened after it.
63
68
  const wanted = useRef<string | null>(null);
69
+ // Folders the reader closed. The tree opens fully; only a closed folder is remembered.
70
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
64
71
 
65
72
  useEffect(() => {
66
73
  let live = true;
@@ -99,17 +106,37 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
99
106
  [base],
100
107
  );
101
108
 
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
- : [];
109
+ const files = useMemo(() => (room?.state === "open" ? room.files : []), [room]);
110
+ const tree = useMemo(() => buildFileTree(files), [files]);
111
+ const folders = useMemo(() => folderPaths(tree), [tree]);
112
+ const allClosed = folders.length > 0 && folders.every((path) => collapsed.has(path));
113
+
114
+ const toggleFolder = useCallback(
115
+ (path: string) =>
116
+ setCollapsed((current) => {
117
+ const next = new Set(current);
118
+ if (!next.delete(path)) next.add(path);
119
+ return next;
120
+ }),
121
+ [],
122
+ );
107
123
 
108
124
  return (
109
125
  <section>
110
- <h2 className="text-sm font-semibold mb-2">
111
- Files {files.length > 0 && `(${files.length})`}
112
- </h2>
126
+ <div className="flex items-baseline justify-between gap-3 mb-2">
127
+ <h2 className="text-sm font-semibold">
128
+ Files {files.length > 0 && `(${files.length})`}
129
+ </h2>
130
+ {folders.length > 0 && (
131
+ <button
132
+ type="button"
133
+ onClick={() => setCollapsed(allClosed ? new Set() : new Set(folders))}
134
+ className="text-xs text-muted-foreground hover:underline"
135
+ >
136
+ {allClosed ? "Expand all" : "Collapse all"}
137
+ </button>
138
+ )}
139
+ </div>
113
140
 
114
141
  {room === null ? (
115
142
  <p className="text-sm text-muted-foreground">Opening the folder…</p>
@@ -118,23 +145,30 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
118
145
  ) : files.length === 0 ? (
119
146
  <p className="text-sm text-muted-foreground">The folder holds nothing the room shows.</p>
120
147
  ) : (
121
- <ul className="space-y-1">
122
- {files.map((file) => {
148
+ <EpisodeFileTree
149
+ tree={tree}
150
+ collapsed={collapsed}
151
+ onToggleFolder={toggleFolder}
152
+ renderFile={(file, name) => {
123
153
  const isOpen = opened?.path === file.relativePath;
124
154
  return (
125
- <li key={file.relativePath}>
155
+ <>
126
156
  <div className="p-2 border rounded-lg bg-card flex items-baseline justify-between gap-3 flex-wrap">
127
157
  {file.previewable ? (
128
158
  <button
129
159
  onClick={() => toggle(file)}
160
+ title={file.relativePath}
130
161
  className="text-sm font-mono text-left break-all hover:underline"
131
162
  aria-expanded={isOpen}
132
163
  >
133
- {isOpen ? "▾" : "▸"} {file.relativePath}
164
+ {isOpen ? "▾" : "▸"} {name}
134
165
  </button>
135
166
  ) : (
136
- <span className="text-sm font-mono break-all text-muted-foreground">
137
- {file.relativePath}
167
+ <span
168
+ title={file.relativePath}
169
+ className="text-sm font-mono break-all text-muted-foreground"
170
+ >
171
+ {name}
138
172
  </span>
139
173
  )}
140
174
  <span className="text-xs text-muted-foreground shrink-0 flex gap-2 items-baseline">
@@ -157,10 +191,10 @@ export function EpisodeFilesPanel({ episodeId }: { episodeId: string }) {
157
191
  )}
158
192
  </div>
159
193
  )}
160
- </li>
194
+ </>
161
195
  );
162
- })}
163
- </ul>
196
+ }}
197
+ />
164
198
  )}
165
199
  </section>
166
200
  );
package/dist/cli/mw.js CHANGED
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
48
48
  const skills_1 = require("./skills");
49
49
  const beats_register_1 = require("./beats-register");
50
50
  const orientation_1 = require("./orientation");
51
+ const events_1 = require("@medicine-wheel/events");
51
52
  // ── Config ────────────────────────────────────────────────────────
52
53
  const MW_API_URL = process.env.MW_API_URL ?? 'http://localhost:8040';
53
54
  const MW_FORMAT = process.env.MW_FORMAT ?? 'pretty';
@@ -301,6 +302,15 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
301
302
  MEMORY
302
303
  mw memory store <key> <value> [dir] Store relational memory
303
304
 
305
+ EVENTS (what happened on the wheel, kept in events.jsonl)
306
+ mw events [--type cycle.] [--subject <id>] [--correlation <id>]
307
+ [--since <iso>] [--limit 20] List events, newest first
308
+ mw events status What the publisher still owes
309
+ mw events publish Send what waits now
310
+ mw events replay <event-id|all> Send again from that event
311
+ mw events sync Commit (and push) events.jsonl now
312
+ mw events schema [--asyncapi] Print the JSON Schema (or AsyncAPI)
313
+
304
314
  SYSTEM
305
315
  mw status Show system status
306
316
  mw tools List all MCP tools
@@ -758,6 +768,95 @@ function cmdMemory(positional) {
758
768
  unknownSub('memory', sub, ['store']);
759
769
  }
760
770
  }
771
+ async function eventsApi(method, urlPath, body) {
772
+ const res = await fetch(`${MW_API_URL}${urlPath}`, {
773
+ method,
774
+ headers: { 'Content-Type': 'application/json' },
775
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
776
+ });
777
+ return { status: res.status, data: (await res.json().catch(() => ({}))) };
778
+ }
779
+ function printEvent(e) {
780
+ const when = e.observed_at.replace('T', ' ').slice(0, 19);
781
+ const payload = e.payload;
782
+ const changed = Array.isArray(payload.changed) ? ` ${C.dim}changed ${payload.changed.join(', ')}${C.reset}` : '';
783
+ const corr = e.correlation_id && e.correlation_id !== e.subject.id ? ` ${C.dim}↳ ${e.correlation_id}${C.reset}` : '';
784
+ console.log(` ${C.dim}${when}${C.reset} ${C.bold}${(0, events_1.shortName)(e.event_type)}${C.reset} ${e.subject.kind} ${e.subject.id}${changed}${corr}`);
785
+ }
786
+ async function cmdEvents(positional, flags) {
787
+ const sub = positional[0] ?? 'list';
788
+ const failed = (status, data) => {
789
+ console.error(`${C.south}${data?.error ?? `HTTP ${status}`}${C.reset}`);
790
+ if (data?.hint)
791
+ console.error(` ${data.hint}`);
792
+ process.exitCode = 1;
793
+ };
794
+ switch (sub) {
795
+ case 'list': {
796
+ const params = new URLSearchParams();
797
+ for (const key of ['type', 'subject', 'correlation', 'since', 'limit']) {
798
+ const value = flags[key];
799
+ if (typeof value === 'string')
800
+ params.set(key, value);
801
+ }
802
+ if (!params.has('limit'))
803
+ params.set('limit', '20');
804
+ const { status, data } = await eventsApi('GET', `/api/events?${params}`);
805
+ if (status !== 200)
806
+ return failed(status, data);
807
+ if (MW_FORMAT !== 'pretty')
808
+ return ppValue(data);
809
+ const events = data.events ?? [];
810
+ console.log(`${C.bold}${events.length} of ${data.total ?? events.length} event(s)${C.reset}`);
811
+ for (const e of events)
812
+ printEvent(e);
813
+ break;
814
+ }
815
+ case 'status': {
816
+ const { status, data } = await eventsApi('GET', '/api/events/publish');
817
+ if (status !== 200)
818
+ return failed(status, data);
819
+ ppValue(data);
820
+ break;
821
+ }
822
+ case 'publish': {
823
+ const { status, data } = await eventsApi('POST', '/api/events/publish', {});
824
+ if (status !== 200)
825
+ return failed(status, data);
826
+ ppValue(data);
827
+ if (data.stopped)
828
+ process.exitCode = 1;
829
+ break;
830
+ }
831
+ case 'replay': {
832
+ const from = positional[1];
833
+ if (!from) {
834
+ console.error(`${C.south}mw events replay needs an event id, or "all"${C.reset}`);
835
+ process.exitCode = EXIT_USAGE;
836
+ return;
837
+ }
838
+ const { status, data } = await eventsApi('POST', '/api/events/publish', { replay_from: from });
839
+ if (status !== 200)
840
+ return failed(status, data);
841
+ ppValue(data);
842
+ if (data.stopped)
843
+ process.exitCode = 1;
844
+ break;
845
+ }
846
+ case 'sync': {
847
+ const { status, data } = await eventsApi('POST', '/api/events/git');
848
+ if (status !== 200)
849
+ return failed(status, data);
850
+ ppValue(data);
851
+ break;
852
+ }
853
+ case 'schema':
854
+ console.log(JSON.stringify(flags['asyncapi'] ? (0, events_1.asyncApiDocument)() : (0, events_1.wheelEventsJsonSchema)(), null, 2));
855
+ break;
856
+ default:
857
+ unknownSub('events', sub, ['list', 'status', 'publish', 'replay', 'sync', 'schema']);
858
+ }
859
+ }
761
860
  // ── Main dispatch ─────────────────────────────────────────────────
762
861
  async function main() {
763
862
  const { flags, positional } = parseArgs(process.argv);
@@ -824,6 +923,10 @@ async function main() {
824
923
  case 'mem':
825
924
  cmdMemory(rest);
826
925
  break;
926
+ case 'events':
927
+ case 'ev':
928
+ await cmdEvents(rest, flags);
929
+ break;
827
930
  case 'skill':
828
931
  case 'sk':
829
932
  cmdSkill(rest);
@@ -4,9 +4,20 @@
4
4
  * Starts the Honcho river's retry clock (#147): records Honcho could not
5
5
  * receive while it was away wait on the pending ledger, and are sent again on
6
6
  * start and every few minutes after. With `HONCHO_URL` unset this does nothing.
7
+ *
8
+ * Starts the event publisher's clock (#164): events in `events.jsonl` that the
9
+ * configured publisher has not taken are sent on start and every minute after.
10
+ * With `MW_EVENTS_PUBLISH_URL` unset this does nothing.
11
+ *
12
+ * Starts the event log's git carriage (#164): with `MW_EVENTS_GIT_SYNC` set,
13
+ * `events.jsonl` is committed in its data repository (and pushed) on start
14
+ * and every ten minutes, besides soon after each recorded event.
7
15
  */
8
16
  export async function register() {
9
17
  if (process.env.NEXT_RUNTIME !== "nodejs") return;
10
18
  const { startPendingRetries } = await import("@/lib/honcho-projection");
11
19
  startPendingRetries();
20
+ const { startEventPublishing, startEventGitSync } = await import("@/lib/event-log");
21
+ startEventPublishing();
22
+ startEventGitSync();
12
23
  }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * An episode's files, grouped into the folders their paths name.
3
+ *
4
+ * `listEpisodeFiles` returns files with the path each sits at, and no folders,
5
+ * so the Files section printed whole paths one under another and a reader could
6
+ * not tell which folder a file was in. This builds the folders back from the
7
+ * paths, so the panel can draw them.
8
+ *
9
+ * This is layout, not a rule about what an episode shows — those live in
10
+ * `@miadi/episode-vessel` and in `lib/episode-files.ts`, and nothing here
11
+ * decides what may be listed or read. The same grouping is drawn in Miadi's room
12
+ * by `@miadi/episode-ui` (`buildFileTree`); the wheel keeps its own small copy
13
+ * until it takes that package, and the two are meant to agree on order:
14
+ *
15
+ * 1. a `README.md`;
16
+ * 2. the folders, by name;
17
+ * 3. the files — text, documents, guidance, then media — by name.
18
+ *
19
+ * Names compare numerically, so `chapter-2` comes before `chapter-10`.
20
+ */
21
+
22
+ /** The part of a listed file the grouping reads. Any richer shape passes through untouched. */
23
+ export interface TreeableFile {
24
+ relativePath: string;
25
+ name: string;
26
+ kind: string;
27
+ guidance: boolean;
28
+ }
29
+
30
+ export interface FileTreeFile<T extends TreeableFile> {
31
+ type: "file";
32
+ /** The last segment of the path. */
33
+ name: string;
34
+ path: string;
35
+ file: T;
36
+ }
37
+
38
+ export interface FileTreeFolder<T extends TreeableFile> {
39
+ type: "folder";
40
+ /** The last segment of the path. Empty for the root. */
41
+ name: string;
42
+ /** The path from the episode root. Empty for the root. */
43
+ path: string;
44
+ children: FileTreeNode<T>[];
45
+ /** Every file beneath this folder, at any depth. */
46
+ fileCount: number;
47
+ }
48
+
49
+ export type FileTreeNode<T extends TreeableFile> = FileTreeFile<T> | FileTreeFolder<T>;
50
+
51
+ const compareNames = (left: string, right: string): number =>
52
+ left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" }) ||
53
+ (left < right ? -1 : left > right ? 1 : 0);
54
+
55
+ function rank(file: TreeableFile): number {
56
+ if (/^README\.md$/i.test(file.name)) return 0;
57
+ if (file.guidance) return 3;
58
+ if (file.kind === "text") return 1;
59
+ if (file.kind === "document") return 2;
60
+ return 4;
61
+ }
62
+
63
+ function order<T extends TreeableFile>(folder: FileTreeFolder<T>): void {
64
+ const folders = folder.children.filter((n): n is FileTreeFolder<T> => n.type === "folder");
65
+ const files = folder.children.filter((n): n is FileTreeFile<T> => n.type === "file");
66
+ folders.sort((a, b) => compareNames(a.name, b.name));
67
+ files.sort((a, b) => rank(a.file) - rank(b.file) || compareNames(a.name, b.name));
68
+ folder.children = [
69
+ ...files.filter((n) => rank(n.file) === 0),
70
+ ...folders,
71
+ ...files.filter((n) => rank(n.file) !== 0),
72
+ ];
73
+ folders.forEach(order);
74
+ }
75
+
76
+ /**
77
+ * Group `files` into folders by path. A path listed twice is kept once, and a
78
+ * folder exists only because a file is beneath it.
79
+ */
80
+ export function buildFileTree<T extends TreeableFile>(files: readonly T[]): FileTreeFolder<T> {
81
+ const root: FileTreeFolder<T> = { type: "folder", name: "", path: "", children: [], fileCount: 0 };
82
+ const folders = new Map<string, FileTreeFolder<T>>([["", root]]);
83
+ const seen = new Set<string>();
84
+
85
+ const folderAt = (path: string): FileTreeFolder<T> => {
86
+ const known = folders.get(path);
87
+ if (known) return known;
88
+ const cut = path.lastIndexOf("/");
89
+ const parent = folderAt(cut === -1 ? "" : path.slice(0, cut));
90
+ const created: FileTreeFolder<T> = {
91
+ type: "folder",
92
+ name: cut === -1 ? path : path.slice(cut + 1),
93
+ path,
94
+ children: [],
95
+ fileCount: 0,
96
+ };
97
+ folders.set(path, created);
98
+ parent.children.push(created);
99
+ return created;
100
+ };
101
+
102
+ for (const file of files) {
103
+ if (seen.has(file.relativePath)) continue;
104
+ seen.add(file.relativePath);
105
+ const cut = file.relativePath.lastIndexOf("/");
106
+ const parent = folderAt(cut === -1 ? "" : file.relativePath.slice(0, cut));
107
+ parent.children.push({
108
+ type: "file",
109
+ name: cut === -1 ? file.relativePath : file.relativePath.slice(cut + 1),
110
+ path: file.relativePath,
111
+ file,
112
+ });
113
+ for (let path = parent.path; ; path = path.slice(0, Math.max(path.lastIndexOf("/"), 0))) {
114
+ folders.get(path)!.fileCount += 1;
115
+ if (!path) break;
116
+ }
117
+ }
118
+
119
+ order(root);
120
+ return root;
121
+ }
122
+
123
+ /** Every folder path in the tree, outermost first. The root is not one. */
124
+ export function folderPaths<T extends TreeableFile>(tree: FileTreeFolder<T>): string[] {
125
+ const paths: string[] = [];
126
+ const walk = (folder: FileTreeFolder<T>) => {
127
+ for (const child of folder.children) {
128
+ if (child.type !== "folder") continue;
129
+ paths.push(child.path);
130
+ walk(child);
131
+ }
132
+ };
133
+ walk(tree);
134
+ return paths;
135
+ }