@medicine-wheel/app 0.2.3
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 +69 -0
- package/LICENSE +205 -0
- package/README.md +201 -0
- package/app/accountability/page.tsx +95 -0
- package/app/api/ceremonies/route.ts +52 -0
- package/app/api/directions/route.ts +6 -0
- package/app/api/edges/route.ts +27 -0
- package/app/api/health/route.ts +37 -0
- package/app/api/narrative/beats/route.ts +29 -0
- package/app/api/narrative/cycles/route.ts +23 -0
- package/app/api/nodes/route.ts +52 -0
- package/app/api/resources/route.ts +48 -0
- package/app/ceremonies/page.tsx +161 -0
- package/app/globals.css +68 -0
- package/app/graph/page.tsx +200 -0
- package/app/layout.tsx +24 -0
- package/app/narrative/beats/page.tsx +145 -0
- package/app/narrative/cycles/page.tsx +143 -0
- package/app/narrative/page.tsx +113 -0
- package/app/nodes/page.tsx +199 -0
- package/app/page.tsx +148 -0
- package/app/relations/page.tsx +191 -0
- package/components/direction-panel.tsx +96 -0
- package/components/navigation.tsx +105 -0
- package/components/theme-provider.tsx +11 -0
- package/components/workspaces-panel.tsx +110 -0
- package/dist/cli/mw.js +731 -0
- package/dist/cli/mwsrv.js +267 -0
- package/docker-build-push.sh +15 -0
- package/docker-entrypoint.sh +26 -0
- package/lib/jsonl-store.ts +586 -0
- package/lib/store.ts +226 -0
- package/lib/types.ts +23 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +5 -0
- package/package.json +112 -0
- package/postcss.config.mjs +6 -0
- package/public/fonts/Stereohead.otf +0 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL File-Based Persistence for Medicine Wheel
|
|
3
|
+
*
|
|
4
|
+
* Shared data layer that both the Web UI (Next.js) and MCP server
|
|
5
|
+
* can read/write, enabling ceremonies, cycles, nodes, edges, beats,
|
|
6
|
+
* and charts created in any interface to be visible across all interfaces.
|
|
7
|
+
*
|
|
8
|
+
* Storage format: One JSONL file per entity type in the .mw/store/ directory.
|
|
9
|
+
* Each line is a JSON-serialized record. Atomic writes via temp+rename.
|
|
10
|
+
*
|
|
11
|
+
* Cross-process sync: file-lock + read-modify-write inside flush so concurrent
|
|
12
|
+
* writers merge rather than clobber each other.
|
|
13
|
+
*
|
|
14
|
+
* Location is configurable via MW_DATA_DIR env var (defaults to .mw/store/).
|
|
15
|
+
*
|
|
16
|
+
* @see rispecs/data-store.spec.md
|
|
17
|
+
* @see https://github.com/jgwill/medicine-wheel/issues/26
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
|
|
23
|
+
// ── Types ──
|
|
24
|
+
|
|
25
|
+
interface StoredNode {
|
|
26
|
+
id: string;
|
|
27
|
+
type: string;
|
|
28
|
+
name: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
direction?: string;
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
32
|
+
created_at: string;
|
|
33
|
+
updated_at: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface StoredEdge {
|
|
37
|
+
id?: string;
|
|
38
|
+
from_id: string;
|
|
39
|
+
to_id: string;
|
|
40
|
+
relationship_type: string;
|
|
41
|
+
strength: number;
|
|
42
|
+
ceremony_honored: boolean;
|
|
43
|
+
ceremony_id?: string;
|
|
44
|
+
obligations: string[];
|
|
45
|
+
created_at: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface StoredCeremony {
|
|
49
|
+
id: string;
|
|
50
|
+
type: string;
|
|
51
|
+
direction: string;
|
|
52
|
+
participants: string[];
|
|
53
|
+
medicines_used: string[];
|
|
54
|
+
intentions: string[];
|
|
55
|
+
timestamp: string;
|
|
56
|
+
research_context?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface StoredBeat {
|
|
60
|
+
id: string;
|
|
61
|
+
direction: string;
|
|
62
|
+
title: string;
|
|
63
|
+
description: string;
|
|
64
|
+
prose?: string;
|
|
65
|
+
ceremonies: string[];
|
|
66
|
+
learnings: string[];
|
|
67
|
+
timestamp: string;
|
|
68
|
+
act: number;
|
|
69
|
+
relations_honored: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface StoredCycle {
|
|
73
|
+
id: string;
|
|
74
|
+
research_question: string;
|
|
75
|
+
current_direction: string;
|
|
76
|
+
start_date: string;
|
|
77
|
+
beats?: string[];
|
|
78
|
+
ceremonies_conducted: number;
|
|
79
|
+
relations_mapped: number;
|
|
80
|
+
wilson_alignment: number;
|
|
81
|
+
ocap_compliant: boolean;
|
|
82
|
+
archived?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface StoredChart {
|
|
86
|
+
id: string;
|
|
87
|
+
desired_outcome: string;
|
|
88
|
+
current_reality: string;
|
|
89
|
+
direction: string;
|
|
90
|
+
action_steps: any[];
|
|
91
|
+
due_date?: string;
|
|
92
|
+
created_at: string;
|
|
93
|
+
updated_at: string;
|
|
94
|
+
phase: string;
|
|
95
|
+
ceremonies_linked: string[];
|
|
96
|
+
wilson_alignment?: number;
|
|
97
|
+
cycle_id?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface StoredMmot {
|
|
101
|
+
id: string;
|
|
102
|
+
chart_id: string;
|
|
103
|
+
timestamp: string;
|
|
104
|
+
step1_expected: string;
|
|
105
|
+
step1_actual: string;
|
|
106
|
+
step2_analysis: string;
|
|
107
|
+
step3_adjustments: string[];
|
|
108
|
+
step4_feedback: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── File Lock ──
|
|
112
|
+
// Cross-process write coordination via an exclusive lock file.
|
|
113
|
+
// Uses O_EXCL (create-only) which is atomic on POSIX filesystems.
|
|
114
|
+
// Inside the lock, flush performs read-modify-write so concurrent
|
|
115
|
+
// writers merge rather than clobber each other.
|
|
116
|
+
|
|
117
|
+
function withWriteLock<T>(filePath: string, fn: () => T): T {
|
|
118
|
+
const lockPath = filePath + '.lock';
|
|
119
|
+
let locked = false;
|
|
120
|
+
|
|
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
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
132
|
+
try {
|
|
133
|
+
const fd = fs.openSync(lockPath, 'wx'); // O_EXCL — fails if exists
|
|
134
|
+
fs.closeSync(fd);
|
|
135
|
+
locked = true;
|
|
136
|
+
break;
|
|
137
|
+
} catch {
|
|
138
|
+
// Lock held by another process; spin-wait with linear back-off
|
|
139
|
+
const delayMs = Math.min(25 * (attempt + 1), 250);
|
|
140
|
+
const deadline = Date.now() + delayMs;
|
|
141
|
+
while (Date.now() < deadline) { /* spin */ }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!locked) {
|
|
146
|
+
throw new Error(`[jsonl-store] Failed to acquire write lock after 20 attempts: ${lockPath}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
return fn();
|
|
151
|
+
} finally {
|
|
152
|
+
try { fs.unlinkSync(lockPath); } catch { /* best effort */ }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── JSONL File Helpers ──
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Read all records from a JSONL file.
|
|
160
|
+
* Returns [] if the file does not exist (normal for first run).
|
|
161
|
+
* Throws on permission errors or other real FS failures so the caller
|
|
162
|
+
* knows not to proceed with a potentially-stale empty state.
|
|
163
|
+
*/
|
|
164
|
+
function readJsonl<T>(filePath: string): T[] {
|
|
165
|
+
if (!fs.existsSync(filePath)) return [];
|
|
166
|
+
|
|
167
|
+
let content: string;
|
|
168
|
+
try {
|
|
169
|
+
content = fs.readFileSync(filePath, 'utf-8');
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
throw new Error(`Failed to read JSONL store at ${filePath}: ${message}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const records: T[] = [];
|
|
176
|
+
for (const line of content.split('\n')) {
|
|
177
|
+
const trimmed = line.trim();
|
|
178
|
+
if (!trimmed) continue;
|
|
179
|
+
try {
|
|
180
|
+
records.push(JSON.parse(trimmed) as T);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// Skip individual malformed lines — log so they don't disappear silently
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
184
|
+
console.warn(`[jsonl-store] Skipping malformed line in ${filePath}: ${message}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return records;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Write all records to a JSONL file atomically (temp + rename).
|
|
192
|
+
* Must be called inside withWriteLock() for cross-process safety.
|
|
193
|
+
*/
|
|
194
|
+
function writeJsonl<T>(filePath: string, records: T[]): void {
|
|
195
|
+
const dir = path.dirname(filePath);
|
|
196
|
+
if (!fs.existsSync(dir)) {
|
|
197
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
198
|
+
}
|
|
199
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
200
|
+
const content = records.map(r => JSON.stringify(r)).join('\n') + (records.length > 0 ? '\n' : '');
|
|
201
|
+
fs.writeFileSync(tmpPath, content, 'utf-8');
|
|
202
|
+
fs.renameSync(tmpPath, filePath);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Get file mtime in ms (0 if file does not exist). */
|
|
206
|
+
function getMtime(filePath: string): number {
|
|
207
|
+
try {
|
|
208
|
+
return fs.statSync(filePath).mtimeMs;
|
|
209
|
+
} catch {
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── JsonlCollection<T> ──
|
|
215
|
+
// Single JSONL-backed entity collection keyed by `id`.
|
|
216
|
+
// flush() performs read-modify-write inside a file lock so concurrent
|
|
217
|
+
// writes from the Web UI and MCP server merge rather than clobber.
|
|
218
|
+
|
|
219
|
+
class JsonlCollection<T extends { id?: string }> {
|
|
220
|
+
private items: Map<string, T> = new Map();
|
|
221
|
+
private filePath: string;
|
|
222
|
+
private lastMtime: number = 0;
|
|
223
|
+
private loaded: boolean = false;
|
|
224
|
+
|
|
225
|
+
constructor(filePath: string) {
|
|
226
|
+
this.filePath = filePath;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Reload from disk if file changed since last sync. */
|
|
230
|
+
private sync(): void {
|
|
231
|
+
const currentMtime = getMtime(this.filePath);
|
|
232
|
+
if (!this.loaded || currentMtime !== this.lastMtime) {
|
|
233
|
+
const records = readJsonl<T>(this.filePath);
|
|
234
|
+
this.items = new Map();
|
|
235
|
+
for (const record of records) {
|
|
236
|
+
const id = (record as any).id as string | undefined;
|
|
237
|
+
if (id) this.items.set(id, record);
|
|
238
|
+
}
|
|
239
|
+
this.lastMtime = currentMtime;
|
|
240
|
+
this.loaded = true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Flush in-memory state to disk using read-modify-write inside a file lock.
|
|
246
|
+
* Reads the current on-disk state first so concurrent writes from another
|
|
247
|
+
* process are merged (our in-memory items take precedence for keys we own).
|
|
248
|
+
*/
|
|
249
|
+
private flush(): void {
|
|
250
|
+
withWriteLock(this.filePath, () => {
|
|
251
|
+
// Read disk state inside the lock so we don't clobber concurrent writes
|
|
252
|
+
const diskRecords = readJsonl<T>(this.filePath);
|
|
253
|
+
const merged = new Map<string, T>();
|
|
254
|
+
for (const r of diskRecords) {
|
|
255
|
+
const id = (r as any).id as string | undefined;
|
|
256
|
+
if (id) merged.set(id, r);
|
|
257
|
+
}
|
|
258
|
+
// Our in-memory items take precedence (we just set them)
|
|
259
|
+
for (const [id, item] of this.items) {
|
|
260
|
+
merged.set(id, item);
|
|
261
|
+
}
|
|
262
|
+
writeJsonl(this.filePath, Array.from(merged.values()));
|
|
263
|
+
// Sync our cache to match merged disk state
|
|
264
|
+
this.items = merged;
|
|
265
|
+
this.lastMtime = getMtime(this.filePath);
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
get(id: string): T | undefined {
|
|
270
|
+
this.sync();
|
|
271
|
+
return this.items.get(id);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
getAll(): T[] {
|
|
275
|
+
this.sync();
|
|
276
|
+
return Array.from(this.items.values());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
set(id: string, item: T): void {
|
|
280
|
+
this.sync();
|
|
281
|
+
this.items.set(id, item);
|
|
282
|
+
this.flush();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
has(id: string): boolean {
|
|
286
|
+
this.sync();
|
|
287
|
+
return this.items.has(id);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
size(): number {
|
|
291
|
+
this.sync();
|
|
292
|
+
return this.items.size;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
filter(predicate: (item: T) => boolean): T[] {
|
|
296
|
+
this.sync();
|
|
297
|
+
return Array.from(this.items.values()).filter(predicate);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
search(query: string, fields: (keyof T)[]): T[] {
|
|
301
|
+
this.sync();
|
|
302
|
+
const q = query.toLowerCase();
|
|
303
|
+
return Array.from(this.items.values()).filter(item =>
|
|
304
|
+
fields.some(f => {
|
|
305
|
+
const val = item[f];
|
|
306
|
+
return typeof val === 'string' && val.toLowerCase().includes(q);
|
|
307
|
+
})
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── EdgeCollection ──
|
|
313
|
+
// Edges are keyed by `${from_id}:${to_id}` (upsert semantics).
|
|
314
|
+
// add() replaces an existing edge with the same endpoints rather than
|
|
315
|
+
// appending a duplicate. flush() uses the same lock + read-modify-write
|
|
316
|
+
// strategy as JsonlCollection.
|
|
317
|
+
|
|
318
|
+
class EdgeCollection {
|
|
319
|
+
private items: Map<string, StoredEdge> = new Map();
|
|
320
|
+
private filePath: string;
|
|
321
|
+
private lastMtime: number = 0;
|
|
322
|
+
private loaded: boolean = false;
|
|
323
|
+
|
|
324
|
+
constructor(filePath: string) {
|
|
325
|
+
this.filePath = filePath;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private edgeKey(edge: StoredEdge): string {
|
|
329
|
+
// Use explicit id if present, otherwise derive from endpoints
|
|
330
|
+
return (edge as any).id || `${edge.from_id}:${edge.to_id}`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private sync(): void {
|
|
334
|
+
const currentMtime = getMtime(this.filePath);
|
|
335
|
+
if (!this.loaded || currentMtime !== this.lastMtime) {
|
|
336
|
+
const records = readJsonl<StoredEdge>(this.filePath);
|
|
337
|
+
this.items = new Map();
|
|
338
|
+
for (const r of records) {
|
|
339
|
+
this.items.set(this.edgeKey(r), r);
|
|
340
|
+
}
|
|
341
|
+
this.lastMtime = currentMtime;
|
|
342
|
+
this.loaded = true;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private flush(): void {
|
|
347
|
+
withWriteLock(this.filePath, () => {
|
|
348
|
+
// Read-modify-write inside lock to merge concurrent changes
|
|
349
|
+
const diskRecords = readJsonl<StoredEdge>(this.filePath);
|
|
350
|
+
const merged = new Map<string, StoredEdge>();
|
|
351
|
+
for (const r of diskRecords) {
|
|
352
|
+
merged.set(this.edgeKey(r), r);
|
|
353
|
+
}
|
|
354
|
+
for (const [key, edge] of this.items) {
|
|
355
|
+
merged.set(key, edge);
|
|
356
|
+
}
|
|
357
|
+
writeJsonl(this.filePath, Array.from(merged.values()));
|
|
358
|
+
this.items = merged;
|
|
359
|
+
this.lastMtime = getMtime(this.filePath);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Upsert: replaces any existing edge with the same endpoints. */
|
|
364
|
+
add(edge: StoredEdge): void {
|
|
365
|
+
this.sync();
|
|
366
|
+
this.items.set(this.edgeKey(edge), edge);
|
|
367
|
+
this.flush();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
getAll(): StoredEdge[] {
|
|
371
|
+
this.sync();
|
|
372
|
+
return Array.from(this.items.values());
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
getForNode(nodeId: string): StoredEdge[] {
|
|
376
|
+
this.sync();
|
|
377
|
+
return Array.from(this.items.values()).filter(
|
|
378
|
+
e => e.from_id === nodeId || e.to_id === nodeId
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
getRelatedNodeIds(nodeId: string): string[] {
|
|
383
|
+
this.sync();
|
|
384
|
+
const ids = new Set<string>();
|
|
385
|
+
for (const e of this.items.values()) {
|
|
386
|
+
if (e.from_id === nodeId) ids.add(e.to_id);
|
|
387
|
+
if (e.to_id === nodeId) ids.add(e.from_id);
|
|
388
|
+
}
|
|
389
|
+
return Array.from(ids);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
updateCeremony(fromId: string, toId: string, ceremonyId: string): void {
|
|
393
|
+
this.sync();
|
|
394
|
+
for (const [key, edge] of this.items) {
|
|
395
|
+
if ((edge.from_id === fromId && edge.to_id === toId) ||
|
|
396
|
+
(edge.from_id === toId && edge.to_id === fromId)) {
|
|
397
|
+
this.items.set(key, { ...edge, ceremony_honored: true, ceremony_id: ceremonyId });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
this.flush();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── JsonlStore: the full shared store ──
|
|
405
|
+
|
|
406
|
+
export class JsonlStore {
|
|
407
|
+
readonly dataDir: string;
|
|
408
|
+
|
|
409
|
+
readonly nodes: JsonlCollection<StoredNode>;
|
|
410
|
+
readonly edges: EdgeCollection;
|
|
411
|
+
readonly ceremonies: JsonlCollection<StoredCeremony>;
|
|
412
|
+
readonly beats: JsonlCollection<StoredBeat>;
|
|
413
|
+
readonly cycles: JsonlCollection<StoredCycle>;
|
|
414
|
+
readonly charts: JsonlCollection<StoredChart>;
|
|
415
|
+
readonly mmots: JsonlCollection<StoredMmot>;
|
|
416
|
+
|
|
417
|
+
constructor(dataDir?: string) {
|
|
418
|
+
this.dataDir = dataDir || process.env.MW_DATA_DIR || path.join(process.cwd(), '.mw', 'store');
|
|
419
|
+
|
|
420
|
+
if (!fs.existsSync(this.dataDir)) {
|
|
421
|
+
fs.mkdirSync(this.dataDir, { recursive: true });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
this.nodes = new JsonlCollection<StoredNode> (path.join(this.dataDir, 'nodes.jsonl'));
|
|
425
|
+
this.edges = new EdgeCollection (path.join(this.dataDir, 'edges.jsonl'));
|
|
426
|
+
this.ceremonies = new JsonlCollection<StoredCeremony>(path.join(this.dataDir, 'ceremonies.jsonl'));
|
|
427
|
+
this.beats = new JsonlCollection<StoredBeat> (path.join(this.dataDir, 'beats.jsonl'));
|
|
428
|
+
this.cycles = new JsonlCollection<StoredCycle> (path.join(this.dataDir, 'cycles.jsonl'));
|
|
429
|
+
this.charts = new JsonlCollection<StoredChart> (path.join(this.dataDir, 'charts.jsonl'));
|
|
430
|
+
this.mmots = new JsonlCollection<StoredMmot> (path.join(this.dataDir, 'mmots.jsonl'));
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// === Nodes ===
|
|
434
|
+
|
|
435
|
+
createNode(node: StoredNode): void { this.nodes.set(node.id, node); }
|
|
436
|
+
getNode(id: string): StoredNode | undefined { return this.nodes.get(id); }
|
|
437
|
+
|
|
438
|
+
getAllNodes(limit?: number): StoredNode[] {
|
|
439
|
+
const all = this.nodes.getAll();
|
|
440
|
+
return limit !== undefined ? all.slice(0, limit) : all;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
getNodesByType(type: string): StoredNode[] {
|
|
444
|
+
return this.nodes.filter(n => n.type === type);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
getNodesByDirection(direction: string): StoredNode[] {
|
|
448
|
+
return this.nodes.filter(n => n.direction === direction);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
searchNodes(query: string, opts: { type?: string; direction?: string; limit?: number } = {}): StoredNode[] {
|
|
452
|
+
let results = this.nodes.search(query, ['name', 'description'] as any);
|
|
453
|
+
if (opts.type) results = results.filter(n => n.type === opts.type);
|
|
454
|
+
if (opts.direction) results = results.filter(n => n.direction === opts.direction);
|
|
455
|
+
return opts.limit !== undefined ? results.slice(0, opts.limit) : results;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// === Edges ===
|
|
459
|
+
|
|
460
|
+
createEdge(edge: StoredEdge): void { this.edges.add(edge); }
|
|
461
|
+
getEdgesForNode(nodeId: string): StoredEdge[] { return this.edges.getForNode(nodeId); }
|
|
462
|
+
getRelatedNodeIds(nodeId: string): string[] { return this.edges.getRelatedNodeIds(nodeId); }
|
|
463
|
+
updateEdgeCeremony(fromId: string, toId: string, ceremonyId: string): void {
|
|
464
|
+
this.edges.updateCeremony(fromId, toId, ceremonyId);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
getRelationalWeb(nodeId: string, depth = 2): { nodes: StoredNode[]; edges: StoredEdge[] } {
|
|
468
|
+
const visited = new Set<string>();
|
|
469
|
+
const resultNodes: StoredNode[] = [];
|
|
470
|
+
const resultEdges: StoredEdge[] = [];
|
|
471
|
+
const queue: { id: string; d: number }[] = [{ id: nodeId, d: 0 }];
|
|
472
|
+
|
|
473
|
+
while (queue.length > 0) {
|
|
474
|
+
const { id, d } = queue.shift()!;
|
|
475
|
+
if (visited.has(id) || d > depth) continue;
|
|
476
|
+
visited.add(id);
|
|
477
|
+
const node = this.nodes.get(id);
|
|
478
|
+
if (node) resultNodes.push(node);
|
|
479
|
+
|
|
480
|
+
for (const edge of this.edges.getForNode(id)) {
|
|
481
|
+
if (!resultEdges.includes(edge)) resultEdges.push(edge);
|
|
482
|
+
const otherId = edge.from_id === id ? edge.to_id : edge.from_id;
|
|
483
|
+
if (!visited.has(otherId)) queue.push({ id: otherId, d: d + 1 });
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return { nodes: resultNodes, edges: resultEdges };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// === Ceremonies ===
|
|
490
|
+
|
|
491
|
+
logCeremony(ceremony: StoredCeremony): void { this.ceremonies.set(ceremony.id, ceremony); }
|
|
492
|
+
getCeremony(id: string): StoredCeremony | undefined { return this.ceremonies.get(id); }
|
|
493
|
+
|
|
494
|
+
getAllCeremonies(limit?: number): StoredCeremony[] {
|
|
495
|
+
const sorted = this.ceremonies.getAll()
|
|
496
|
+
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
|
|
497
|
+
return limit !== undefined ? sorted.slice(0, limit) : sorted;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
getCeremoniesByDirection(direction: string): StoredCeremony[] {
|
|
501
|
+
return this.ceremonies.filter(c => c.direction === direction);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
getCeremoniesByType(type: string): StoredCeremony[] {
|
|
505
|
+
return this.ceremonies.filter(c => c.type === type);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// === Beats ===
|
|
509
|
+
|
|
510
|
+
createBeat(beat: StoredBeat): void { this.beats.set(beat.id, beat); }
|
|
511
|
+
getBeat(id: string): StoredBeat | undefined { return this.beats.get(id); }
|
|
512
|
+
|
|
513
|
+
getAllBeats(limit?: number): StoredBeat[] {
|
|
514
|
+
const all = this.beats.getAll();
|
|
515
|
+
return limit !== undefined ? all.slice(0, limit) : all;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
getBeatsByDirection(direction: string): StoredBeat[] {
|
|
519
|
+
return this.beats.filter(b => b.direction === direction);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// === Cycles ===
|
|
523
|
+
|
|
524
|
+
createCycle(cycle: StoredCycle): void { this.cycles.set(cycle.id, cycle); }
|
|
525
|
+
getCycle(id: string): StoredCycle | undefined { return this.cycles.get(id); }
|
|
526
|
+
|
|
527
|
+
getAllCycles(): { active: StoredCycle[]; archived: StoredCycle[] } {
|
|
528
|
+
const all = this.cycles.getAll();
|
|
529
|
+
return {
|
|
530
|
+
active: all.filter(c => !c.archived),
|
|
531
|
+
archived: all.filter(c => c.archived),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
archiveCycle(id: string): void {
|
|
536
|
+
const cycle = this.cycles.get(id);
|
|
537
|
+
if (cycle) this.cycles.set(id, { ...cycle, archived: true });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// === Charts (Structural Tension) ===
|
|
541
|
+
|
|
542
|
+
saveChart(chart: StoredChart): void { this.charts.set(chart.id, chart); }
|
|
543
|
+
getChart(id: string): StoredChart | undefined { return this.charts.get(id); }
|
|
544
|
+
|
|
545
|
+
getAllCharts(direction?: string): StoredChart[] {
|
|
546
|
+
let charts = this.charts.getAll();
|
|
547
|
+
if (direction) charts = charts.filter(c => c.direction === direction);
|
|
548
|
+
return charts.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at));
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// === MMOT (Moment of Truth) ===
|
|
552
|
+
|
|
553
|
+
saveMmot(mmot: StoredMmot): void { this.mmots.set(mmot.id, mmot); }
|
|
554
|
+
getMmotsByChart(chartId: string): StoredMmot[] {
|
|
555
|
+
return this.mmots.filter(m => m.chart_id === chartId);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── Singleton instance ──
|
|
560
|
+
|
|
561
|
+
let _instance: JsonlStore | null = null;
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Get the shared JsonlStore singleton.
|
|
565
|
+
* Both Web UI and MCP server call this to get the same store instance.
|
|
566
|
+
* Data file location is resolved from MW_DATA_DIR or defaults to .mw/store/.
|
|
567
|
+
*/
|
|
568
|
+
export function getJsonlStore(dataDir?: string): JsonlStore {
|
|
569
|
+
if (!_instance) {
|
|
570
|
+
_instance = new JsonlStore(dataDir);
|
|
571
|
+
}
|
|
572
|
+
return _instance;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Resolve the project root data directory.
|
|
577
|
+
* When called from mcp/ subdirectory, resolves up to the project root.
|
|
578
|
+
*/
|
|
579
|
+
export function resolveProjectDataDir(currentDir?: string): string {
|
|
580
|
+
if (process.env.MW_DATA_DIR) return process.env.MW_DATA_DIR;
|
|
581
|
+
const dir = currentDir || process.cwd();
|
|
582
|
+
if (dir.endsWith('/mcp') || dir.endsWith('\\mcp')) {
|
|
583
|
+
return path.join(path.dirname(dir), '.mw', 'store');
|
|
584
|
+
}
|
|
585
|
+
return path.join(dir, '.mw', 'store');
|
|
586
|
+
}
|