@medicine-wheel/app 0.17.11 → 0.18.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.
@@ -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,14 @@ ${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 schema [--asyncapi] Print the JSON Schema (or AsyncAPI)
312
+
304
313
  SYSTEM
305
314
  mw status Show system status
306
315
  mw tools List all MCP tools
@@ -758,6 +767,88 @@ function cmdMemory(positional) {
758
767
  unknownSub('memory', sub, ['store']);
759
768
  }
760
769
  }
770
+ async function eventsApi(method, urlPath, body) {
771
+ const res = await fetch(`${MW_API_URL}${urlPath}`, {
772
+ method,
773
+ headers: { 'Content-Type': 'application/json' },
774
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
775
+ });
776
+ return { status: res.status, data: (await res.json().catch(() => ({}))) };
777
+ }
778
+ function printEvent(e) {
779
+ const when = e.observed_at.replace('T', ' ').slice(0, 19);
780
+ const payload = e.payload;
781
+ const changed = Array.isArray(payload.changed) ? ` ${C.dim}changed ${payload.changed.join(', ')}${C.reset}` : '';
782
+ const corr = e.correlation_id && e.correlation_id !== e.subject.id ? ` ${C.dim}↳ ${e.correlation_id}${C.reset}` : '';
783
+ 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}`);
784
+ }
785
+ async function cmdEvents(positional, flags) {
786
+ const sub = positional[0] ?? 'list';
787
+ const failed = (status, data) => {
788
+ console.error(`${C.south}${data?.error ?? `HTTP ${status}`}${C.reset}`);
789
+ if (data?.hint)
790
+ console.error(` ${data.hint}`);
791
+ process.exitCode = 1;
792
+ };
793
+ switch (sub) {
794
+ case 'list': {
795
+ const params = new URLSearchParams();
796
+ for (const key of ['type', 'subject', 'correlation', 'since', 'limit']) {
797
+ const value = flags[key];
798
+ if (typeof value === 'string')
799
+ params.set(key, value);
800
+ }
801
+ if (!params.has('limit'))
802
+ params.set('limit', '20');
803
+ const { status, data } = await eventsApi('GET', `/api/events?${params}`);
804
+ if (status !== 200)
805
+ return failed(status, data);
806
+ if (MW_FORMAT !== 'pretty')
807
+ return ppValue(data);
808
+ const events = data.events ?? [];
809
+ console.log(`${C.bold}${events.length} of ${data.total ?? events.length} event(s)${C.reset}`);
810
+ for (const e of events)
811
+ printEvent(e);
812
+ break;
813
+ }
814
+ case 'status': {
815
+ const { status, data } = await eventsApi('GET', '/api/events/publish');
816
+ if (status !== 200)
817
+ return failed(status, data);
818
+ ppValue(data);
819
+ break;
820
+ }
821
+ case 'publish': {
822
+ const { status, data } = await eventsApi('POST', '/api/events/publish', {});
823
+ if (status !== 200)
824
+ return failed(status, data);
825
+ ppValue(data);
826
+ if (data.stopped)
827
+ process.exitCode = 1;
828
+ break;
829
+ }
830
+ case 'replay': {
831
+ const from = positional[1];
832
+ if (!from) {
833
+ console.error(`${C.south}mw events replay needs an event id, or "all"${C.reset}`);
834
+ process.exitCode = EXIT_USAGE;
835
+ return;
836
+ }
837
+ const { status, data } = await eventsApi('POST', '/api/events/publish', { replay_from: from });
838
+ if (status !== 200)
839
+ return failed(status, data);
840
+ ppValue(data);
841
+ if (data.stopped)
842
+ process.exitCode = 1;
843
+ break;
844
+ }
845
+ case 'schema':
846
+ console.log(JSON.stringify(flags['asyncapi'] ? (0, events_1.asyncApiDocument)() : (0, events_1.wheelEventsJsonSchema)(), null, 2));
847
+ break;
848
+ default:
849
+ unknownSub('events', sub, ['list', 'status', 'publish', 'replay', 'schema']);
850
+ }
851
+ }
761
852
  // ── Main dispatch ─────────────────────────────────────────────────
762
853
  async function main() {
763
854
  const { flags, positional } = parseArgs(process.argv);
@@ -824,6 +915,10 @@ async function main() {
824
915
  case 'mem':
825
916
  cmdMemory(rest);
826
917
  break;
918
+ case 'events':
919
+ case 'ev':
920
+ await cmdEvents(rest, flags);
921
+ break;
827
922
  case 'skill':
828
923
  case 'sk':
829
924
  cmdSkill(rest);
@@ -4,9 +4,15 @@
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.
7
11
  */
8
12
  export async function register() {
9
13
  if (process.env.NEXT_RUNTIME !== "nodejs") return;
10
14
  const { startPendingRetries } = await import("@/lib/honcho-projection");
11
15
  startPendingRetries();
16
+ const { startEventPublishing } = await import("@/lib/event-log");
17
+ startEventPublishing();
12
18
  }
@@ -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
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The wheel's event log — what happened, kept beside the store (#164).
3
+ *
4
+ * The store keeps each record's latest state: a cycle amended twice is one
5
+ * line in `cycles.jsonl`, and the moment it was opened exists nowhere once it
6
+ * has passed. This log keeps those moments. Each write a route commits leaves
7
+ * one line in `events.jsonl` beside the store (`MW_EVENTS_FILE` overrides).
8
+ * What an event is, and which event a write is, lives in
9
+ * `@medicine-wheel/events` — the routes hand it before and after and record
10
+ * what it decides, and the MCP server's local store does the same.
11
+ *
12
+ * `source.instance` names this wheel as configured (`MW_INSTANCE`, e.g.
13
+ * `ilex:8040`). The address a request reached is not a name: behind
14
+ * `0.0.0.0` every wheel looks alike, and two browsers name the same wheel two
15
+ * ways.
16
+ *
17
+ * Publishing (episode 548's Send): with `MW_EVENTS_PUBLISH_URL` set, the log is
18
+ * sent there after every recorded event and every
19
+ * `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute), from this wheel's
20
+ * cursor, with receipts beside the log. Unset, nothing leaves the machine.
21
+ *
22
+ * Never the caller's error: a line that cannot be written is reported once on
23
+ * stderr with the record's id, and a publisher that cannot be reached is a
24
+ * receipt, not a failed write.
25
+ */
26
+
27
+ import { buildEvent, filterEvents, type EventFilter, type WheelEvent, type WheelEventInput } from '@medicine-wheel/events';
28
+ import { appendEvent, eventLogPath, readEventLog } from '@medicine-wheel/events/log';
29
+ import {
30
+ drainLog,
31
+ httpPublisher,
32
+ publisherStatus,
33
+ readReceipts,
34
+ rewind,
35
+ type DrainResult,
36
+ type Publisher,
37
+ type PublisherStatus,
38
+ type Receipt,
39
+ } from '@medicine-wheel/events/publish';
40
+ import { resolveProjectDataDir } from '@/lib/jsonl-store';
41
+
42
+ /** Where the log lives: `MW_EVENTS_FILE`, else beside the wheel's JSONL store. */
43
+ export function eventLogFile(): string {
44
+ return eventLogPath(resolveProjectDataDir());
45
+ }
46
+
47
+ /** This wheel's name in `source.instance`, when one is configured. */
48
+ export function eventInstance(): string | undefined {
49
+ return process.env.MW_INSTANCE?.trim() || undefined;
50
+ }
51
+
52
+ /**
53
+ * Record what a decider returned (`null`: the write changed nothing, so
54
+ * nothing to record). Returns the event, or `undefined` when there was none or
55
+ * its line could not be written.
56
+ */
57
+ export function recordEvent(input: WheelEventInput | null): WheelEvent | undefined {
58
+ if (!input) return undefined;
59
+ const file = eventLogFile();
60
+ let event: WheelEvent;
61
+ try {
62
+ event = buildEvent(input, { instance: eventInstance() });
63
+ appendEvent(file, event);
64
+ } catch (error) {
65
+ console.error(
66
+ `[events] medicine-wheel.${input.name} for ${input.subject.kind} ${input.subject.id} was not written to ${file}: ${(error as Error)?.message ?? String(error)}`,
67
+ );
68
+ return undefined;
69
+ }
70
+ kickPublisher();
71
+ return event;
72
+ }
73
+
74
+ /** Every event, oldest first. */
75
+ export function readEvents(): WheelEvent[] {
76
+ return readEventLog(eventLogFile());
77
+ }
78
+
79
+ export { filterEvents, type EventFilter };
80
+
81
+ // ── publishing ──────────────────────────────────────────────────────────────
82
+
83
+ /** The publisher `MW_EVENTS_PUBLISH_URL` names, or `null`: nothing leaves the machine. */
84
+ export function publisherFromEnv(): Publisher | null {
85
+ const url = process.env.MW_EVENTS_PUBLISH_URL?.trim();
86
+ if (!url) return null;
87
+ return httpPublisher({
88
+ url,
89
+ token: process.env.MW_EVENTS_PUBLISH_TOKEN?.trim() || undefined,
90
+ name: process.env.MW_EVENTS_PUBLISH_NAME?.trim() || 'http',
91
+ });
92
+ }
93
+
94
+ // One pass at a time across every copy of this module: Next compiles
95
+ // `instrumentation.ts` and the routes into separate bundles, and two passes
96
+ // over the same cursor would send the same event twice.
97
+ const STATE = Symbol.for('@medicine-wheel/app.event-publisher');
98
+ type PublishState = { running?: Promise<DrainResult | null>; again?: boolean; timer?: ReturnType<typeof setInterval>; down?: boolean };
99
+ const state: PublishState = ((globalThis as Record<symbol, PublishState>)[STATE] ??= {});
100
+
101
+ async function pass(): Promise<DrainResult | null> {
102
+ const publisher = publisherFromEnv();
103
+ if (!publisher) return null;
104
+ try {
105
+ const result = await drainLog(eventLogFile(), publisher);
106
+ // A destination that stays down is one line when it goes, one when it returns.
107
+ if (result.stopped && !state.down) {
108
+ console.error(`[events] publishing to ${publisher.name} stopped: ${result.error} — ${result.pending} event(s) waiting`);
109
+ } else if (!result.stopped && state.down) {
110
+ console.log(`[events] publishing to ${publisher.name} resumed; ${result.arrived} event(s) arrived`);
111
+ }
112
+ state.down = result.stopped;
113
+ return result;
114
+ } catch (error) {
115
+ console.error(`[events] publishing pass failed: ${(error as Error)?.message ?? String(error)}`);
116
+ return null;
117
+ }
118
+ }
119
+
120
+ /** Send what waits. A pass already running is joined, and runs once more after it for what arrived meanwhile. */
121
+ export function publishPending(): Promise<DrainResult | null> {
122
+ if (state.running) {
123
+ state.again = true;
124
+ return state.running;
125
+ }
126
+ const run = pass().then(async (result) => {
127
+ while (state.again) {
128
+ state.again = false;
129
+ result = (await pass()) ?? result;
130
+ }
131
+ return result;
132
+ }).finally(() => {
133
+ if (state.running === run) state.running = undefined;
134
+ });
135
+ state.running = run;
136
+ return run;
137
+ }
138
+
139
+ /** After a recorded event: send it soon, in the background. Nothing when no publisher is set. */
140
+ export function kickPublisher(): void {
141
+ if (!process.env.MW_EVENTS_PUBLISH_URL?.trim()) return;
142
+ void publishPending();
143
+ }
144
+
145
+ /**
146
+ * Start the publishing clock: one pass now, then one every
147
+ * `MW_EVENTS_PUBLISH_INTERVAL_MS` (default one minute). Called once from
148
+ * `instrumentation.ts`; a second call is a no-op, and without a publisher
149
+ * there is nothing to send.
150
+ */
151
+ export function startEventPublishing(intervalMs = Number(process.env.MW_EVENTS_PUBLISH_INTERVAL_MS) || 60_000): void {
152
+ if (state.timer || !publisherFromEnv()) return;
153
+ state.timer = setInterval(() => { void publishPending(); }, intervalMs);
154
+ state.timer.unref?.();
155
+ void publishPending();
156
+ }
157
+
158
+ /** Replay: the next pass sends `fromEventId` and everything after it again (the whole log without one). */
159
+ export function replayEvents(fromEventId?: string): { pending: number } | null {
160
+ const publisher = publisherFromEnv();
161
+ if (!publisher) return null;
162
+ return rewind(eventLogFile(), publisher.name, fromEventId);
163
+ }
164
+
165
+ /** Where the configured publisher stands, and its latest receipts. */
166
+ export function eventPublisherReport(limit = 20): { publisher: PublisherStatus; receipts: Receipt[] } | null {
167
+ const publisher = publisherFromEnv();
168
+ if (!publisher) return null;
169
+ const file = eventLogFile();
170
+ return { publisher: publisherStatus(file, publisher.name), receipts: readReceipts(file, publisher.name).slice(-limit).reverse() };
171
+ }
172
+
173
+ /** What `/api/health` reports: how many events, the last one, and what the publisher still owes. */
174
+ export function eventLogStatus(): { count: number; last_at?: string; publisher?: PublisherStatus } | { error: string } {
175
+ try {
176
+ const events = readEvents();
177
+ const last = events[events.length - 1];
178
+ const publisher = publisherFromEnv();
179
+ return {
180
+ count: events.length,
181
+ ...(last ? { last_at: last.observed_at } : {}),
182
+ ...(publisher ? { publisher: publisherStatus(eventLogFile(), publisher.name) } : {}),
183
+ };
184
+ } catch (error) {
185
+ return { error: (error as Error)?.message ?? String(error) };
186
+ }
187
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medicine-wheel/app",
3
- "version": "0.17.11",
3
+ "version": "0.18.0",
4
4
  "description": "Medicine Wheel — Interactive visual layer for Indigenous relational research with Four Directions, ceremonies, and narrative arcs",
5
5
  "bin": {
6
6
  "mw": "dist/cli/mw.js",
@@ -28,6 +28,7 @@
28
28
  "workspaces": [
29
29
  "src/ontology-core",
30
30
  "src/infra",
31
+ "src/events",
31
32
  "src/creative-orientation",
32
33
  "src/session-reader",
33
34
  "src/ceremony-protocol",
@@ -91,32 +92,33 @@
91
92
  "release:major": "npm run version:major && npm run publish:all && npm run release:commit"
92
93
  },
93
94
  "dependencies": {
94
- "@medicine-wheel/ceremonial-diary": "^0.17.11",
95
- "@medicine-wheel/ceremony-protocol": "^0.17.11",
96
- "@medicine-wheel/community-identity": "^0.17.11",
97
- "@medicine-wheel/community-review": "^0.17.11",
98
- "@medicine-wheel/consent-lifecycle": "^0.17.11",
99
- "@medicine-wheel/creative-orientation": "^0.17.11",
100
- "@medicine-wheel/data-store": "^0.17.11",
101
- "@medicine-wheel/data-store-postgres": "^0.17.11",
102
- "@medicine-wheel/fire-keeper": "^0.17.11",
103
- "@medicine-wheel/github-ceremony": "^0.17.11",
104
- "@medicine-wheel/graph-viz": "^0.17.11",
105
- "@medicine-wheel/honcho": "^0.17.11",
106
- "@medicine-wheel/importance-unit": "^0.17.11",
107
- "@medicine-wheel/mcp": "^4.17.11",
108
- "@medicine-wheel/memory": "^0.17.11",
109
- "@medicine-wheel/narrative-cluster": "^0.17.11",
110
- "@medicine-wheel/narrative-engine": "^0.17.11",
111
- "@medicine-wheel/ontology-core": "^0.17.11",
112
- "@medicine-wheel/perception-layer": "^0.17.11",
113
- "@medicine-wheel/prompt-decomposition": "^0.17.11",
114
- "@medicine-wheel/relational-index": "^0.17.11",
115
- "@medicine-wheel/relational-query": "^0.17.11",
116
- "@medicine-wheel/session-reader": "^0.17.11",
117
- "@medicine-wheel/storage-provider": "^0.17.11",
118
- "@medicine-wheel/transformation-tracker": "^0.17.11",
119
- "@medicine-wheel/ui-components": "^0.17.11",
95
+ "@medicine-wheel/ceremonial-diary": "^0.18.0",
96
+ "@medicine-wheel/ceremony-protocol": "^0.18.0",
97
+ "@medicine-wheel/community-identity": "^0.18.0",
98
+ "@medicine-wheel/community-review": "^0.18.0",
99
+ "@medicine-wheel/consent-lifecycle": "^0.18.0",
100
+ "@medicine-wheel/creative-orientation": "^0.18.0",
101
+ "@medicine-wheel/data-store": "^0.18.0",
102
+ "@medicine-wheel/data-store-postgres": "^0.18.0",
103
+ "@medicine-wheel/events": "^0.18.0",
104
+ "@medicine-wheel/fire-keeper": "^0.18.0",
105
+ "@medicine-wheel/github-ceremony": "^0.18.0",
106
+ "@medicine-wheel/graph-viz": "^0.18.0",
107
+ "@medicine-wheel/honcho": "^0.18.0",
108
+ "@medicine-wheel/importance-unit": "^0.18.0",
109
+ "@medicine-wheel/mcp": "^4.18.0",
110
+ "@medicine-wheel/memory": "^0.18.0",
111
+ "@medicine-wheel/narrative-cluster": "^0.18.0",
112
+ "@medicine-wheel/narrative-engine": "^0.18.0",
113
+ "@medicine-wheel/ontology-core": "^0.18.0",
114
+ "@medicine-wheel/perception-layer": "^0.18.0",
115
+ "@medicine-wheel/prompt-decomposition": "^0.18.0",
116
+ "@medicine-wheel/relational-index": "^0.18.0",
117
+ "@medicine-wheel/relational-query": "^0.18.0",
118
+ "@medicine-wheel/session-reader": "^0.18.0",
119
+ "@medicine-wheel/storage-provider": "^0.18.0",
120
+ "@medicine-wheel/transformation-tracker": "^0.18.0",
121
+ "@medicine-wheel/ui-components": "^0.18.0",
120
122
  "@miadi/episode-vessel": "^0.2.1",
121
123
  "@neondatabase/serverless": "^0.10.0",
122
124
  "@xyflow/react": "^12.3.0",
@@ -138,6 +140,7 @@
138
140
  "@types/node": "^22.0.0",
139
141
  "@types/react": "^19.0.0",
140
142
  "@types/react-dom": "^19.0.0",
143
+ "ajv": "^6.12.6",
141
144
  "eslint": "^9.39.5",
142
145
  "eslint-config-next": "^15.5.25",
143
146
  "shx": "^0.4.0",