@medicine-wheel/app 0.5.0 → 0.5.2
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/Dockerfile +3 -3
- package/app/api/narrative/beats/route.ts +10 -1
- package/app/api/narrative/cycles/route.ts +12 -1
- package/dist/cli/mw.js +2 -2
- package/dist/cli/mwsrv.js +3 -3
- package/docker-build-push.sh +1 -1
- package/docker-entrypoint.sh +2 -2
- package/lib/beat-response.ts +58 -0
- package/lib/ceremony-response.ts +55 -8
- package/lib/jsonl-store.ts +118 -22
- package/lib/store.ts +70 -4
- package/next.config.mjs +18 -0
- package/package.json +29 -24
package/Dockerfile
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
# mwsrv --docker -D /path/to/project
|
|
7
7
|
#
|
|
8
8
|
# Manual usage:
|
|
9
|
-
# docker run --rm -p
|
|
9
|
+
# docker run --rm -p 8040:8040 \
|
|
10
10
|
# -v /path/to/project/.mw/store:/data/store \
|
|
11
11
|
# -e MW_DATA_DIR=/data/store \
|
|
12
12
|
# jgwill/medicine-wheel:app
|
|
@@ -41,7 +41,7 @@ FROM node:22-alpine AS runner
|
|
|
41
41
|
WORKDIR /app
|
|
42
42
|
|
|
43
43
|
ENV NODE_ENV=production
|
|
44
|
-
ENV PORT=
|
|
44
|
+
ENV PORT=8040
|
|
45
45
|
ENV MW_STORAGE_PROVIDER=jsonl
|
|
46
46
|
ENV MW_DATA_DIR=/data/store
|
|
47
47
|
|
|
@@ -63,6 +63,6 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
|
63
63
|
# Volume for persistent data
|
|
64
64
|
VOLUME ["/data/store"]
|
|
65
65
|
|
|
66
|
-
EXPOSE
|
|
66
|
+
EXPOSE 8040
|
|
67
67
|
|
|
68
68
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
|
@@ -12,15 +12,24 @@ export async function GET() {
|
|
|
12
12
|
export async function POST(request: Request) {
|
|
13
13
|
try {
|
|
14
14
|
const body = await request.json();
|
|
15
|
+
// Pass the caller's id and timestamp through when supplied. A client that
|
|
16
|
+
// authored a beat holds its id and will look it up again — minting a fresh
|
|
17
|
+
// one here hands back a beat the caller can never find.
|
|
15
18
|
const beat = createBeat({
|
|
19
|
+
id: body.id,
|
|
20
|
+
timestamp: body.timestamp,
|
|
16
21
|
direction: body.direction,
|
|
17
22
|
title: body.title,
|
|
18
23
|
description: body.description,
|
|
19
24
|
prose: body.prose,
|
|
20
25
|
ceremonies: body.ceremonies ?? [],
|
|
21
26
|
learnings: body.learnings ?? [],
|
|
22
|
-
act: body.act
|
|
27
|
+
act: body.act,
|
|
23
28
|
relations_honored: body.relations_honored ?? [],
|
|
29
|
+
cycle_id: body.cycle_id,
|
|
30
|
+
parent_beat_id: body.parent_beat_id,
|
|
31
|
+
sub_beats: body.sub_beats,
|
|
32
|
+
origin: body.origin,
|
|
24
33
|
});
|
|
25
34
|
return NextResponse.json(beat, { status: 201 });
|
|
26
35
|
} catch (error: any) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
|
-
import { getAllCycles, createCycle } from "@/lib/store";
|
|
2
|
+
import { getAllCycles, createCycle, upsertCycle } from "@/lib/store";
|
|
3
3
|
|
|
4
4
|
export async function GET() {
|
|
5
5
|
try {
|
|
@@ -12,6 +12,17 @@ export async function GET() {
|
|
|
12
12
|
export async function POST(request: Request) {
|
|
13
13
|
try {
|
|
14
14
|
const body = await request.json();
|
|
15
|
+
|
|
16
|
+
// A body carrying an id names the cycle the caller already holds — amend
|
|
17
|
+
// it if we know it, open it under that id if we do not. Minting a fresh
|
|
18
|
+
// id here is how archiving and beat-binding duplicated cycles instead of
|
|
19
|
+
// amending them; refusing outright is how a client's own id vanished.
|
|
20
|
+
if (typeof body.id === "string" && body.id.length > 0) {
|
|
21
|
+
const existed = getAllCycles().some((c) => c.id === body.id);
|
|
22
|
+
const cycle = upsertCycle(body);
|
|
23
|
+
return NextResponse.json(cycle, { status: existed ? 200 : 201 });
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
const cycle = createCycle({
|
|
16
27
|
research_question: body.research_question,
|
|
17
28
|
current_direction: body.current_direction,
|
package/dist/cli/mw.js
CHANGED
|
@@ -47,7 +47,7 @@ const path = __importStar(require("path"));
|
|
|
47
47
|
const fs = __importStar(require("fs"));
|
|
48
48
|
const skills_1 = require("./skills");
|
|
49
49
|
// ── Config ────────────────────────────────────────────────────────
|
|
50
|
-
const MW_API_URL = process.env.MW_API_URL ?? 'http://localhost:
|
|
50
|
+
const MW_API_URL = process.env.MW_API_URL ?? 'http://localhost:8040';
|
|
51
51
|
const MW_FORMAT = process.env.MW_FORMAT ?? 'pretty';
|
|
52
52
|
function resolvePackageRoot() {
|
|
53
53
|
try {
|
|
@@ -268,7 +268,7 @@ ${C.bold}🌿 mw — Medicine Wheel CLI${C.reset}
|
|
|
268
268
|
mw help This help
|
|
269
269
|
|
|
270
270
|
ENVIRONMENT
|
|
271
|
-
MW_API_URL API base URL (default: http://localhost:
|
|
271
|
+
MW_API_URL API base URL (default: http://localhost:8040)
|
|
272
272
|
MW_MCP_PATH Optional MCP server path (auto-detected when locally available)
|
|
273
273
|
MW_FORMAT Output format: pretty (default), json, quiet
|
|
274
274
|
`);
|
package/dist/cli/mwsrv.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* mwsrv --docker -D /path/to/project
|
|
11
11
|
*
|
|
12
12
|
* Options:
|
|
13
|
-
* --port, -p <port> Port to listen on (default:
|
|
13
|
+
* --port, -p <port> Port to listen on (default: 8040)
|
|
14
14
|
* --directory, -D <dir> Host directory containing (or for) .mw/store (default: cwd)
|
|
15
15
|
* --docker Run in Docker container
|
|
16
16
|
* --pull Pull Docker image before starting
|
|
@@ -63,8 +63,8 @@ const C = {
|
|
|
63
63
|
south: '\x1b[31m',
|
|
64
64
|
reset: '\x1b[0m',
|
|
65
65
|
};
|
|
66
|
-
const DEFAULT_PORT =
|
|
67
|
-
const CONTAINER_PORT =
|
|
66
|
+
const DEFAULT_PORT = 8040;
|
|
67
|
+
const CONTAINER_PORT = 8040;
|
|
68
68
|
function parseArgs(argv) {
|
|
69
69
|
const args = argv.slice(2);
|
|
70
70
|
const flags = {};
|
package/docker-build-push.sh
CHANGED
package/docker-entrypoint.sh
CHANGED
|
@@ -7,10 +7,10 @@ set -e
|
|
|
7
7
|
# Environment variables:
|
|
8
8
|
# MW_DATA_DIR — path to JSONL store directory (default: /data/store)
|
|
9
9
|
# MW_STORAGE_PROVIDER — storage backend (default: jsonl)
|
|
10
|
-
# PORT — HTTP port (default:
|
|
10
|
+
# PORT — HTTP port (default: 8040)
|
|
11
11
|
|
|
12
12
|
STORE_DIR="${MW_DATA_DIR:-/data/store}"
|
|
13
|
-
PORT="${PORT:-
|
|
13
|
+
PORT="${PORT:-8040}"
|
|
14
14
|
|
|
15
15
|
mkdir -p "$STORE_DIR"
|
|
16
16
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { DirectionName, NarrativeBeat } from "./types";
|
|
2
|
+
|
|
3
|
+
interface BeatListResponse {
|
|
4
|
+
beats?: unknown;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const DIRECTION_NAMES = new Set<DirectionName>(["east", "south", "west", "north"]);
|
|
8
|
+
|
|
9
|
+
function asStringArray(value: unknown): string[] {
|
|
10
|
+
return Array.isArray(value)
|
|
11
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
12
|
+
: [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Repair one stored beat into a shape the arc readers can render.
|
|
17
|
+
*
|
|
18
|
+
* Unlike the cycle normalizer this keeps every field it was handed instead of
|
|
19
|
+
* rebuilding from a whitelist: beats carry additive provenance (origin,
|
|
20
|
+
* sub_beats, parent_beat_id, act) and a whitelist would silently drop whatever
|
|
21
|
+
* the narrative engine adds after this file was written. Only the fields a
|
|
22
|
+
* reader indexes into are coerced — a beat recorded before `learnings` and
|
|
23
|
+
* `relations_honored` existed takes down a page that reads `.length` on them,
|
|
24
|
+
* and one with no `direction` is looked up as a colour key that does not exist.
|
|
25
|
+
*/
|
|
26
|
+
export function normalizeNarrativeBeat(value: unknown): NarrativeBeat | null {
|
|
27
|
+
if (!value || typeof value !== "object") return null;
|
|
28
|
+
|
|
29
|
+
const beat = value as Record<string, unknown>;
|
|
30
|
+
if (typeof beat.id !== "string") return null;
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...(beat as unknown as NarrativeBeat),
|
|
34
|
+
id: beat.id,
|
|
35
|
+
direction:
|
|
36
|
+
typeof beat.direction === "string" &&
|
|
37
|
+
DIRECTION_NAMES.has(beat.direction as DirectionName)
|
|
38
|
+
? (beat.direction as DirectionName)
|
|
39
|
+
: "east",
|
|
40
|
+
ceremonies: asStringArray(beat.ceremonies),
|
|
41
|
+
learnings: asStringArray(beat.learnings),
|
|
42
|
+
relations_honored: asStringArray(beat.relations_honored),
|
|
43
|
+
timestamp:
|
|
44
|
+
typeof beat.timestamp === "string" ? beat.timestamp : new Date(0).toISOString(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function extractBeats(response: unknown): NarrativeBeat[] {
|
|
49
|
+
const candidate = Array.isArray(response)
|
|
50
|
+
? response
|
|
51
|
+
: response && typeof response === "object" && Array.isArray((response as BeatListResponse).beats)
|
|
52
|
+
? ((response as BeatListResponse).beats as unknown[])
|
|
53
|
+
: [];
|
|
54
|
+
|
|
55
|
+
return candidate
|
|
56
|
+
.map(normalizeNarrativeBeat)
|
|
57
|
+
.filter((beat): beat is NarrativeBeat => beat !== null);
|
|
58
|
+
}
|
package/lib/ceremony-response.ts
CHANGED
|
@@ -1,16 +1,63 @@
|
|
|
1
|
-
import type { CeremonyLog } from "@/lib/types";
|
|
1
|
+
import type { CeremonyLog, CeremonyType, DirectionName } from "@/lib/types";
|
|
2
2
|
|
|
3
3
|
interface CeremonyListResponse {
|
|
4
4
|
ceremonies?: unknown;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
if (Array.isArray(response)) return response as CeremonyLog[];
|
|
9
|
-
|
|
10
|
-
if (!response || typeof response !== "object") return [];
|
|
7
|
+
const DIRECTION_NAMES = new Set<DirectionName>(["east", "south", "west", "north"]);
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
return Array.isArray(
|
|
14
|
-
? (
|
|
9
|
+
function asStringArray(value: unknown): string[] {
|
|
10
|
+
return Array.isArray(value)
|
|
11
|
+
? value.filter((entry): entry is string => typeof entry === "string")
|
|
15
12
|
: [];
|
|
16
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Repair one stored ceremony into a shape the timeline can render.
|
|
17
|
+
*
|
|
18
|
+
* Returns null only when the record carries no identity — a ceremony with no
|
|
19
|
+
* id or type cannot be opened, filtered, or spoken about. Everything else is
|
|
20
|
+
* filled in: the timeline reads `.length` on participants, medicines, and
|
|
21
|
+
* intentions, and sorts on `timestamp`, so a ceremony logged before any of
|
|
22
|
+
* those fields existed takes the whole page down rather than rendering thin.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeCeremonyLog(value: unknown): CeremonyLog | null {
|
|
25
|
+
if (!value || typeof value !== "object") return null;
|
|
26
|
+
|
|
27
|
+
const ceremony = value as Record<string, unknown>;
|
|
28
|
+
if (typeof ceremony.id !== "string" || typeof ceremony.type !== "string") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
id: ceremony.id,
|
|
34
|
+
type: ceremony.type as CeremonyType,
|
|
35
|
+
direction:
|
|
36
|
+
typeof ceremony.direction === "string" &&
|
|
37
|
+
DIRECTION_NAMES.has(ceremony.direction as DirectionName)
|
|
38
|
+
? (ceremony.direction as DirectionName)
|
|
39
|
+
: "east",
|
|
40
|
+
participants: asStringArray(ceremony.participants),
|
|
41
|
+
medicines_used: asStringArray(ceremony.medicines_used),
|
|
42
|
+
intentions: asStringArray(ceremony.intentions),
|
|
43
|
+
timestamp:
|
|
44
|
+
typeof ceremony.timestamp === "string"
|
|
45
|
+
? ceremony.timestamp
|
|
46
|
+
: new Date(0).toISOString(),
|
|
47
|
+
...(typeof ceremony.research_context === "string"
|
|
48
|
+
? { research_context: ceremony.research_context }
|
|
49
|
+
: {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractCeremonyLogs(response: unknown): CeremonyLog[] {
|
|
54
|
+
const candidate = Array.isArray(response)
|
|
55
|
+
? response
|
|
56
|
+
: response && typeof response === "object" && Array.isArray((response as CeremonyListResponse).ceremonies)
|
|
57
|
+
? ((response as CeremonyListResponse).ceremonies as unknown[])
|
|
58
|
+
: [];
|
|
59
|
+
|
|
60
|
+
return candidate
|
|
61
|
+
.map(normalizeCeremonyLog)
|
|
62
|
+
.filter((ceremony): ceremony is CeremonyLog => ceremony !== null);
|
|
63
|
+
}
|
package/lib/jsonl-store.ts
CHANGED
|
@@ -67,6 +67,10 @@ interface StoredBeat {
|
|
|
67
67
|
timestamp: string;
|
|
68
68
|
act: number;
|
|
69
69
|
relations_honored: string[];
|
|
70
|
+
cycle_id?: string;
|
|
71
|
+
parent_beat_id?: string;
|
|
72
|
+
sub_beats?: string[];
|
|
73
|
+
origin?: { producer: string; source_ref?: string; method?: string };
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
interface StoredCycle {
|
|
@@ -114,28 +118,81 @@ interface StoredMmot {
|
|
|
114
118
|
// Inside the lock, flush performs read-modify-write so concurrent
|
|
115
119
|
// writers merge rather than clobber each other.
|
|
116
120
|
|
|
121
|
+
function isProcessAlive(pid: number): boolean {
|
|
122
|
+
try {
|
|
123
|
+
// Signal 0 checks process existence without sending a real signal.
|
|
124
|
+
process.kill(pid, 0);
|
|
125
|
+
return true;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Read the owner token stamped in a lock file, or null when unreadable. */
|
|
132
|
+
function readLockOwner(lockPath: string): string | null {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8').trim()) as { token?: unknown };
|
|
135
|
+
return typeof parsed.token === 'string' ? parsed.token : null;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A lock is stale only when its owner is provably gone.
|
|
143
|
+
*
|
|
144
|
+
* Age alone is not evidence: a slow but living writer holding the lock past
|
|
145
|
+
* the grace period would have it reaped from under it, and two processes
|
|
146
|
+
* would then rewrite the same file from divergent snapshots — the second
|
|
147
|
+
* rename silently discards the first one's records.
|
|
148
|
+
*/
|
|
149
|
+
function isLockStale(lockPath: string): boolean {
|
|
150
|
+
let mtimeMs: number;
|
|
151
|
+
try {
|
|
152
|
+
mtimeMs = fs.statSync(lockPath).mtimeMs;
|
|
153
|
+
} catch {
|
|
154
|
+
return false; // Lock already gone — nothing to reap.
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8').trim()) as { pid?: unknown };
|
|
159
|
+
if (typeof parsed.pid === 'number' && isProcessAlive(parsed.pid)) return false;
|
|
160
|
+
} catch {
|
|
161
|
+
// Locks written by older builds (and by the mcp/ copy of this store) carry
|
|
162
|
+
// no payload, so they name no owner — fall through to the age check.
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Date.now() - mtimeMs > 30_000;
|
|
166
|
+
}
|
|
167
|
+
|
|
117
168
|
function withWriteLock<T>(filePath: string, fn: () => T): T {
|
|
118
169
|
const lockPath = filePath + '.lock';
|
|
170
|
+
const ownerToken = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
171
|
+
const payload = JSON.stringify({
|
|
172
|
+
token: ownerToken,
|
|
173
|
+
pid: process.pid,
|
|
174
|
+
created_at: new Date().toISOString(),
|
|
175
|
+
});
|
|
119
176
|
let locked = false;
|
|
120
177
|
|
|
121
|
-
// Stale lock recovery: if a previous process crashed while holding the lock,
|
|
122
|
-
// the .lock file persists forever. Remove it if older than 30 seconds.
|
|
123
|
-
try {
|
|
124
|
-
const stat = fs.statSync(lockPath);
|
|
125
|
-
if (Date.now() - stat.mtimeMs > 30_000) {
|
|
126
|
-
console.error(`[jsonl-store] Removing stale lock: ${lockPath} (age: ${Math.round((Date.now() - stat.mtimeMs) / 1000)}s)`);
|
|
127
|
-
fs.unlinkSync(lockPath);
|
|
128
|
-
}
|
|
129
|
-
} catch { /* lock file doesn't exist — normal */ }
|
|
130
|
-
|
|
131
178
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
132
179
|
try {
|
|
133
180
|
const fd = fs.openSync(lockPath, 'wx'); // O_EXCL — fails if exists
|
|
134
|
-
|
|
181
|
+
try {
|
|
182
|
+
fs.writeFileSync(fd, payload, 'utf-8');
|
|
183
|
+
} finally {
|
|
184
|
+
fs.closeSync(fd);
|
|
185
|
+
}
|
|
135
186
|
locked = true;
|
|
136
187
|
break;
|
|
137
188
|
} catch {
|
|
138
|
-
// Lock held
|
|
189
|
+
// Lock held elsewhere. Reap it only once its owner is provably gone,
|
|
190
|
+
// otherwise spin-wait with linear back-off.
|
|
191
|
+
if (isLockStale(lockPath)) {
|
|
192
|
+
console.error(`[jsonl-store] Removing stale lock: ${lockPath}`);
|
|
193
|
+
try { fs.unlinkSync(lockPath); } catch { /* another writer got there first */ }
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
139
196
|
const delayMs = Math.min(25 * (attempt + 1), 250);
|
|
140
197
|
const deadline = Date.now() + delayMs;
|
|
141
198
|
while (Date.now() < deadline) { /* spin */ }
|
|
@@ -149,20 +206,25 @@ function withWriteLock<T>(filePath: string, fn: () => T): T {
|
|
|
149
206
|
try {
|
|
150
207
|
return fn();
|
|
151
208
|
} finally {
|
|
152
|
-
|
|
209
|
+
// Release only the lock we still hold: if a reaper judged us dead and
|
|
210
|
+
// handed the lock on, unlinking would release someone else's write.
|
|
211
|
+
try {
|
|
212
|
+
if (readLockOwner(lockPath) === ownerToken) fs.unlinkSync(lockPath);
|
|
213
|
+
} catch { /* best effort */ }
|
|
153
214
|
}
|
|
154
215
|
}
|
|
155
216
|
|
|
156
217
|
// ── JSONL File Helpers ──
|
|
157
218
|
|
|
158
219
|
/**
|
|
159
|
-
* Read all records from a JSONL file
|
|
160
|
-
*
|
|
220
|
+
* Read all records from a JSONL file, keeping the raw text of any line that
|
|
221
|
+
* would not parse so a caller about to rewrite the file can set it aside.
|
|
222
|
+
* Returns no records if the file does not exist (normal for first run).
|
|
161
223
|
* Throws on permission errors or other real FS failures so the caller
|
|
162
224
|
* knows not to proceed with a potentially-stale empty state.
|
|
163
225
|
*/
|
|
164
|
-
function
|
|
165
|
-
if (!fs.existsSync(filePath)) return [];
|
|
226
|
+
function readJsonlWithMalformed<T>(filePath: string): { records: T[]; malformed: string[] } {
|
|
227
|
+
if (!fs.existsSync(filePath)) return { records: [], malformed: [] };
|
|
166
228
|
|
|
167
229
|
let content: string;
|
|
168
230
|
try {
|
|
@@ -173,6 +235,7 @@ function readJsonl<T>(filePath: string): T[] {
|
|
|
173
235
|
}
|
|
174
236
|
|
|
175
237
|
const records: T[] = [];
|
|
238
|
+
const malformed: string[] = [];
|
|
176
239
|
for (const line of content.split('\n')) {
|
|
177
240
|
const trimmed = line.trim();
|
|
178
241
|
if (!trimmed) continue;
|
|
@@ -182,9 +245,37 @@ function readJsonl<T>(filePath: string): T[] {
|
|
|
182
245
|
// Skip individual malformed lines — log so they don't disappear silently
|
|
183
246
|
const message = error instanceof Error ? error.message : String(error);
|
|
184
247
|
console.warn(`[jsonl-store] Skipping malformed line in ${filePath}: ${message}`);
|
|
248
|
+
malformed.push(trimmed);
|
|
185
249
|
}
|
|
186
250
|
}
|
|
187
|
-
return records;
|
|
251
|
+
return { records, malformed };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function readJsonl<T>(filePath: string): T[] {
|
|
255
|
+
return readJsonlWithMalformed<T>(filePath).records;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Set unparseable lines aside before a rewrite erases them.
|
|
260
|
+
*
|
|
261
|
+
* flush() rebuilds the whole file from the records it could parse, so a line
|
|
262
|
+
* readJsonl skipped — a half-written record from a killed writer, a truncated
|
|
263
|
+
* tail — is gone for good the moment anything else is saved. Appending the raw
|
|
264
|
+
* text to a sidecar keeps it recoverable by hand. Nothing reads the sidecar
|
|
265
|
+
* back; it exists so the loss is not silent.
|
|
266
|
+
*/
|
|
267
|
+
function quarantineMalformedLines(filePath: string, malformed: string[]): void {
|
|
268
|
+
if (malformed.length === 0) return;
|
|
269
|
+
|
|
270
|
+
const quarantinePath = `${filePath}.quarantine`;
|
|
271
|
+
try {
|
|
272
|
+
fs.appendFileSync(quarantinePath, malformed.join('\n') + '\n', 'utf-8');
|
|
273
|
+
console.error(`[jsonl-store] Quarantined ${malformed.length} unparseable line(s) from ${filePath} to ${quarantinePath}`);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
// Best effort — a failed sidecar must not block the write itself.
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
console.warn(`[jsonl-store] Failed to quarantine malformed lines from ${filePath}: ${message}`);
|
|
278
|
+
}
|
|
188
279
|
}
|
|
189
280
|
|
|
190
281
|
/**
|
|
@@ -249,7 +340,8 @@ class JsonlCollection<T extends { id?: string }> {
|
|
|
249
340
|
private flush(): void {
|
|
250
341
|
withWriteLock(this.filePath, () => {
|
|
251
342
|
// Read disk state inside the lock so we don't clobber concurrent writes
|
|
252
|
-
const diskRecords =
|
|
343
|
+
const { records: diskRecords, malformed } = readJsonlWithMalformed<T>(this.filePath);
|
|
344
|
+
quarantineMalformedLines(this.filePath, malformed);
|
|
253
345
|
const merged = new Map<string, T>();
|
|
254
346
|
for (const r of diskRecords) {
|
|
255
347
|
const id = (r as any).id as string | undefined;
|
|
@@ -326,8 +418,11 @@ class EdgeCollection {
|
|
|
326
418
|
}
|
|
327
419
|
|
|
328
420
|
private edgeKey(edge: StoredEdge): string {
|
|
329
|
-
//
|
|
330
|
-
|
|
421
|
+
// JSON-array encoding of the endpoints, matching @medicine-wheel/storage-provider.
|
|
422
|
+
// A plain `from:to` join collides whenever an id carries the delimiter — the
|
|
423
|
+
// store already holds ids like `memory:1775381859564:e2e` — and the collision
|
|
424
|
+
// silently overwrites one relation with an unrelated one.
|
|
425
|
+
return JSON.stringify([edge.from_id, edge.to_id]);
|
|
331
426
|
}
|
|
332
427
|
|
|
333
428
|
private sync(): void {
|
|
@@ -346,7 +441,8 @@ class EdgeCollection {
|
|
|
346
441
|
private flush(): void {
|
|
347
442
|
withWriteLock(this.filePath, () => {
|
|
348
443
|
// Read-modify-write inside lock to merge concurrent changes
|
|
349
|
-
const diskRecords =
|
|
444
|
+
const { records: diskRecords, malformed } = readJsonlWithMalformed<StoredEdge>(this.filePath);
|
|
445
|
+
quarantineMalformedLines(this.filePath, malformed);
|
|
350
446
|
const merged = new Map<string, StoredEdge>();
|
|
351
447
|
for (const r of diskRecords) {
|
|
352
448
|
merged.set(this.edgeKey(r), r);
|
package/lib/store.ts
CHANGED
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
MedicineWheelCycle,
|
|
23
23
|
} from '@/lib/types';
|
|
24
24
|
|
|
25
|
+
import { actForDirection } from '@medicine-wheel/narrative-engine';
|
|
26
|
+
import { extractBeats } from './beat-response';
|
|
25
27
|
import { extractCycles, normalizeMedicineWheelCycle } from './cycle-response';
|
|
26
28
|
import { getJsonlStore } from './jsonl-store';
|
|
27
29
|
|
|
@@ -123,14 +125,22 @@ export function createCeremony(data: Omit<CeremonyLog, 'id' | 'timestamp'> & { i
|
|
|
123
125
|
// ── Narrative Beats ──
|
|
124
126
|
|
|
125
127
|
export function getAllBeats(): NarrativeBeat[] {
|
|
126
|
-
|
|
128
|
+
// Beats written before ceremonies/learnings/relations_honored existed reach
|
|
129
|
+
// readers as bare records; normalizing here keeps one legacy line from
|
|
130
|
+
// taking down every arc that reads the collection.
|
|
131
|
+
return extractBeats(store.getAllBeats());
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
export function getBeatsByDirection(direction: string): NarrativeBeat[] {
|
|
130
|
-
return store.getBeatsByDirection(direction)
|
|
135
|
+
return extractBeats(store.getBeatsByDirection(direction));
|
|
131
136
|
}
|
|
132
137
|
|
|
133
|
-
|
|
138
|
+
// `act` is optional because it is derived from `direction`, not supplied.
|
|
139
|
+
// Requiring it invited callers to pass a constant, which is how a west beat
|
|
140
|
+
// came to be recorded in act 1.
|
|
141
|
+
export function createBeat(
|
|
142
|
+
data: Omit<NarrativeBeat, 'id' | 'timestamp' | 'act'> & { id?: string; timestamp?: string; act?: number },
|
|
143
|
+
): NarrativeBeat {
|
|
134
144
|
const id = data.id || crypto.randomUUID();
|
|
135
145
|
const beat: NarrativeBeat = {
|
|
136
146
|
id,
|
|
@@ -141,10 +151,36 @@ export function createBeat(data: Omit<NarrativeBeat, 'id' | 'timestamp'> & { id?
|
|
|
141
151
|
ceremonies: data.ceremonies ?? [],
|
|
142
152
|
learnings: data.learnings ?? [],
|
|
143
153
|
timestamp: data.timestamp || new Date().toISOString(),
|
|
144
|
-
act
|
|
154
|
+
// Derive the act from the direction rather than defaulting to 1. A west
|
|
155
|
+
// beat posted without an act was being recorded as an opening moment,
|
|
156
|
+
// which places it wrongly on the wheel for every reader downstream.
|
|
157
|
+
act: data.act ?? actForDirection(data.direction),
|
|
145
158
|
relations_honored: data.relations_honored ?? [],
|
|
146
159
|
};
|
|
160
|
+
if (data.cycle_id !== undefined) beat.cycle_id = data.cycle_id;
|
|
161
|
+
if (data.parent_beat_id !== undefined) beat.parent_beat_id = data.parent_beat_id;
|
|
162
|
+
if (data.sub_beats !== undefined) beat.sub_beats = data.sub_beats;
|
|
163
|
+
if (data.origin !== undefined) beat.origin = data.origin;
|
|
164
|
+
|
|
147
165
|
store.createBeat(beat as any);
|
|
166
|
+
|
|
167
|
+
// Bind the cycle side of the relation. A beat naming a cycle that does not
|
|
168
|
+
// list it back is invisible to every arc reader that starts from the cycle.
|
|
169
|
+
if (beat.cycle_id) {
|
|
170
|
+
const cycle = store.getCycle(beat.cycle_id) as any;
|
|
171
|
+
if (cycle && !(cycle.beats ?? []).includes(beat.id)) {
|
|
172
|
+
store.createCycle({ ...cycle, beats: [...(cycle.beats ?? []), beat.id] });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Likewise the parent side of a telescoped beat.
|
|
177
|
+
if (beat.parent_beat_id) {
|
|
178
|
+
const parent = store.getBeat(beat.parent_beat_id) as any;
|
|
179
|
+
if (parent && !(parent.sub_beats ?? []).includes(beat.id)) {
|
|
180
|
+
store.createBeat({ ...parent, sub_beats: [...(parent.sub_beats ?? []), beat.id] });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
148
184
|
return beat;
|
|
149
185
|
}
|
|
150
186
|
|
|
@@ -172,6 +208,36 @@ export function createCycle(data: { research_question: string; current_direction
|
|
|
172
208
|
return normalizeMedicineWheelCycle(cycle) ?? cycle;
|
|
173
209
|
}
|
|
174
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Write a cycle under an id the caller already holds.
|
|
213
|
+
*
|
|
214
|
+
* When the id names a stored cycle this amends it, merging the patch over the
|
|
215
|
+
* existing record. When it names none, the cycle is created under that id
|
|
216
|
+
* rather than rejected: a client that minted its own id and is now telling us
|
|
217
|
+
* about it is opening a cycle, not amending a missing one. Rejecting it left
|
|
218
|
+
* MCP's create_research_cycle reporting a cycle_id for a cycle that was never
|
|
219
|
+
* stored — the same divergence in a louder costume.
|
|
220
|
+
*/
|
|
221
|
+
export function upsertCycle(patch: { id: string } & Partial<MedicineWheelCycle>): MedicineWheelCycle {
|
|
222
|
+
const existing = store.getCycle(patch.id) as any;
|
|
223
|
+
|
|
224
|
+
const base = existing ?? {
|
|
225
|
+
id: patch.id,
|
|
226
|
+
research_question: '',
|
|
227
|
+
start_date: new Date().toISOString(),
|
|
228
|
+
current_direction: 'east',
|
|
229
|
+
beats: [],
|
|
230
|
+
ceremonies_conducted: 0,
|
|
231
|
+
relations_mapped: 0,
|
|
232
|
+
wilson_alignment: 0,
|
|
233
|
+
ocap_compliant: false,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const merged = { ...base, ...patch, id: patch.id };
|
|
237
|
+
store.createCycle(merged);
|
|
238
|
+
return normalizeMedicineWheelCycle(merged) ?? merged;
|
|
239
|
+
}
|
|
240
|
+
|
|
175
241
|
// ── Seed Data ──
|
|
176
242
|
|
|
177
243
|
export function seedDemoData() {
|
package/next.config.mjs
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
/** @type {import('next').NextConfig} */
|
|
2
2
|
const nextConfig = {
|
|
3
3
|
images: { unoptimized: true },
|
|
4
|
+
webpack: (config, { dev }) => {
|
|
5
|
+
if (dev) {
|
|
6
|
+
// The JSONL store lives at .mw/store/ inside the repo — every API write
|
|
7
|
+
// lands there. Keep it out of the dev watcher or each write recompiles.
|
|
8
|
+
// (webpack allows one RegExp OR an array of globs; Next's default is a
|
|
9
|
+
// RegExp over node_modules/.git/.next, so restate those as globs.)
|
|
10
|
+
config.watchOptions = {
|
|
11
|
+
...config.watchOptions,
|
|
12
|
+
ignored: [
|
|
13
|
+
'**/node_modules/**',
|
|
14
|
+
'**/.git/**',
|
|
15
|
+
'**/.next/**',
|
|
16
|
+
'**/.mw/**',
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return config;
|
|
21
|
+
},
|
|
4
22
|
};
|
|
5
23
|
export default nextConfig;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medicine-wheel/app",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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",
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"src/relational-query",
|
|
41
41
|
"src/session-reader",
|
|
42
42
|
"src/storage-provider",
|
|
43
|
+
"src/ceremonial-diary",
|
|
44
|
+
"src/github-ceremony",
|
|
43
45
|
"src/transformation-tracker",
|
|
44
46
|
"src/ui-components",
|
|
45
47
|
"mcp"
|
|
@@ -50,9 +52,9 @@
|
|
|
50
52
|
"clean:packages": "npm run clean --workspaces --if-present",
|
|
51
53
|
"prebuild": "npm run build:packages",
|
|
52
54
|
"predev": "npm run build:packages",
|
|
53
|
-
"dev": "next dev -p
|
|
55
|
+
"dev": "next dev -p 8040",
|
|
54
56
|
"build": "next build",
|
|
55
|
-
"start": "next start -p
|
|
57
|
+
"start": "next start -p 8040",
|
|
56
58
|
"lint": "next lint",
|
|
57
59
|
"test": "vitest run",
|
|
58
60
|
"test:watch": "vitest",
|
|
@@ -76,27 +78,30 @@
|
|
|
76
78
|
"release:major": "npm run version:major && npm run publish:all && npm run release:commit"
|
|
77
79
|
},
|
|
78
80
|
"dependencies": {
|
|
79
|
-
"@medicine-wheel/
|
|
80
|
-
"@medicine-wheel/
|
|
81
|
-
"@medicine-wheel/
|
|
82
|
-
"@medicine-wheel/
|
|
83
|
-
"@medicine-wheel/data-store
|
|
84
|
-
"@medicine-wheel/
|
|
85
|
-
"@medicine-wheel/
|
|
86
|
-
"@medicine-wheel/
|
|
87
|
-
"@medicine-wheel/
|
|
88
|
-
"@medicine-wheel/
|
|
89
|
-
"@medicine-wheel/
|
|
90
|
-
"@medicine-wheel/
|
|
91
|
-
"@medicine-wheel/
|
|
92
|
-
"@medicine-wheel/
|
|
93
|
-
"@medicine-wheel/
|
|
94
|
-
"@medicine-wheel/
|
|
95
|
-
"@medicine-wheel/
|
|
96
|
-
"@medicine-wheel/
|
|
97
|
-
"@medicine-wheel/
|
|
98
|
-
"@medicine-wheel/
|
|
81
|
+
"@medicine-wheel/ceremonial-diary": "^0.5.2",
|
|
82
|
+
"@medicine-wheel/ceremony-protocol": "^0.5.2",
|
|
83
|
+
"@medicine-wheel/community-review": "^0.5.2",
|
|
84
|
+
"@medicine-wheel/consent-lifecycle": "^0.5.2",
|
|
85
|
+
"@medicine-wheel/data-store": "^0.5.2",
|
|
86
|
+
"@medicine-wheel/data-store-postgres": "^0.5.2",
|
|
87
|
+
"@medicine-wheel/fire-keeper": "^0.5.2",
|
|
88
|
+
"@medicine-wheel/github-ceremony": "^0.5.2",
|
|
89
|
+
"@medicine-wheel/graph-viz": "^0.5.2",
|
|
90
|
+
"@medicine-wheel/importance-unit": "^0.5.2",
|
|
91
|
+
"@medicine-wheel/mcp": "^4.5.2",
|
|
92
|
+
"@medicine-wheel/narrative-cluster": "^0.5.2",
|
|
93
|
+
"@medicine-wheel/narrative-engine": "^0.5.2",
|
|
94
|
+
"@medicine-wheel/ontology-core": "^0.5.2",
|
|
95
|
+
"@medicine-wheel/perception-layer": "^0.5.2",
|
|
96
|
+
"@medicine-wheel/prompt-decomposition": "^0.5.2",
|
|
97
|
+
"@medicine-wheel/relational-index": "^0.5.2",
|
|
98
|
+
"@medicine-wheel/relational-query": "^0.5.2",
|
|
99
|
+
"@medicine-wheel/session-reader": "^0.5.2",
|
|
100
|
+
"@medicine-wheel/storage-provider": "^0.5.2",
|
|
101
|
+
"@medicine-wheel/transformation-tracker": "^0.5.2",
|
|
102
|
+
"@medicine-wheel/ui-components": "^0.5.2",
|
|
99
103
|
"@neondatabase/serverless": "^0.10.0",
|
|
104
|
+
"@xyflow/react": "^12.3.0",
|
|
100
105
|
"clsx": "^2.1.1",
|
|
101
106
|
"lucide-react": "^0.475.0",
|
|
102
107
|
"next": "^15.3.0",
|
|
@@ -106,7 +111,7 @@
|
|
|
106
111
|
"recharts": "^2.15.4",
|
|
107
112
|
"sonner": "^1.7.0",
|
|
108
113
|
"tailwind-merge": "^3.0.2",
|
|
109
|
-
"
|
|
114
|
+
"zod": "^3.23.0"
|
|
110
115
|
},
|
|
111
116
|
"devDependencies": {
|
|
112
117
|
"@tailwindcss/postcss": "^4.1.0",
|