@neoloopy/cld-canvas 0.1.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.
Files changed (41) hide show
  1. package/dist/engine/analysis.d.ts +15 -0
  2. package/dist/engine/analysis.js +30 -0
  3. package/dist/engine/engine.d.ts +203 -0
  4. package/dist/engine/engine.js +7 -0
  5. package/dist/engine/exporters.d.ts +45 -0
  6. package/dist/engine/exporters.js +110 -0
  7. package/dist/engine/layout.d.ts +19 -0
  8. package/dist/engine/layout.js +142 -0
  9. package/dist/engine/loopGraph.d.ts +51 -0
  10. package/dist/engine/loopGraph.js +0 -0
  11. package/dist/engine/loopKey.d.ts +24 -0
  12. package/dist/engine/loopKey.js +32 -0
  13. package/dist/engine/loopNote.d.ts +40 -0
  14. package/dist/engine/loopNote.js +130 -0
  15. package/dist/engine/nativeEngine.d.ts +175 -0
  16. package/dist/engine/nativeEngine.js +1120 -0
  17. package/dist/engine/noteCodec.d.ts +29 -0
  18. package/dist/engine/noteCodec.js +254 -0
  19. package/dist/engine/noteNaming.d.ts +18 -0
  20. package/dist/engine/noteNaming.js +28 -0
  21. package/dist/engine/specHash.d.ts +37 -0
  22. package/dist/engine/specHash.js +86 -0
  23. package/dist/engine/storage.d.ts +49 -0
  24. package/dist/engine/storage.js +116 -0
  25. package/dist/engine/subsystemLinks.d.ts +29 -0
  26. package/dist/engine/subsystemLinks.js +55 -0
  27. package/dist/engine/types.d.ts +103 -0
  28. package/dist/engine/types.js +155 -0
  29. package/dist/index.d.ts +19 -0
  30. package/dist/index.js +19 -0
  31. package/dist/view/camera.d.ts +42 -0
  32. package/dist/view/camera.js +75 -0
  33. package/dist/view/geometry.d.ts +83 -0
  34. package/dist/view/geometry.js +408 -0
  35. package/dist/view/painter.d.ts +63 -0
  36. package/dist/view/painter.js +471 -0
  37. package/dist/view/sceneCache.d.ts +61 -0
  38. package/dist/view/sceneCache.js +134 -0
  39. package/dist/view/theme.d.ts +57 -0
  40. package/dist/view/theme.js +90 -0
  41. package/package.json +17 -0
@@ -0,0 +1,1120 @@
1
+ /**
2
+ * NativeEngine — the default, fully-open-source TypeScript implementation of the
3
+ * qualitative neoloopy surface, working directly on vault files through a
4
+ * `VaultStorage`. It reuses the Phase-2 engine modules (codec, loop graph,
5
+ * layout, exporters, signature) so reads/writes are byte-compatible with the
6
+ * Dart CLI/app and the Python bridge. No `obsidian` import — unit-testable in
7
+ * plain Node against `MemoryStorage`.
8
+ *
9
+ * Vault conventions mirrored from `core/lib/vault/desktop_vault_storage.dart`
10
+ * and `core/lib/cli/vault_engine.dart`:
11
+ * - variable note file = `Nodes/<id>.md`, id = `var_` + 8 hex. Legacy vaults
12
+ * wrote `<id>.md` flat at the model root; those are still read (a `Nodes/`
13
+ * copy wins on a duplicate id) and swept into `Nodes/` when next written.
14
+ * - loop notes live in the model's `Loops/<slug>.md` files (see below).
15
+ * - model folder name = slug(name) (`-2`, `-3` … on collision), `model.json`
16
+ * manifest pretty-printed (2-space indent)
17
+ * - `System.md` / `Futures.md` / `CLA.md` are not variables
18
+ * - discovery: any folder with a `model.json` is a model and is NOT recursed
19
+ * into, so a model's own `Nodes/`/`Loops/` subfolders are never mistaken for
20
+ * nested models; grouped/nested model folders elsewhere are still found.
21
+ */
22
+ import { emptyVariable, manifestFromJson, manifestToJson, normalizeBasis, normalizeConfidence, } from "./types";
23
+ import { LoopGraph, labelLoopsByKey } from "./loopGraph";
24
+ import { parseNote, serializeNote, splitFrontmatter } from "./noteCodec";
25
+ import { noteSlug, noteUnslug } from "./noteNaming";
26
+ import { canonicalLoopMembers, loopMatchesNote, loopSlug, parseLoopNote, serializeLoopNote, } from "./loopNote";
27
+ import { contentSignature, stampMeta, WRITE_SOURCE_PLUGIN } from "./specHash";
28
+ import { loopEchoLabel, loopKey } from "./loopKey";
29
+ import { autoLayout } from "./layout";
30
+ import { render, } from "./exporters";
31
+ import { LoopType } from "./types";
32
+ import { baseName, joinPath, parentPath, } from "./storage";
33
+ import { deriveParentAnchors } from "./subsystemLinks";
34
+ /** Non-variable notes that share a model folder. */
35
+ const SPECIAL_NOTES = new Set(["System.md", "Futures.md", "CLA.md"]);
36
+ const MAX_SCAN_DEPTH = 12;
37
+ export class NativeEngine {
38
+ constructor(storage, yaml, opts) {
39
+ this.storage = storage;
40
+ this.yaml = yaml;
41
+ this.opts = opts;
42
+ }
43
+ // ---- discovery / listing -------------------------------------------------
44
+ async listModels() {
45
+ const folders = await this.findModelFolders();
46
+ const refs = [];
47
+ for (const folder of folders) {
48
+ try {
49
+ const manifest = await this.readManifest(folder);
50
+ const notes = await this.listNoteFiles(folder);
51
+ refs.push({
52
+ id: manifest.id,
53
+ name: manifest.name,
54
+ folder,
55
+ group: manifest.folder ?? null,
56
+ modified: manifest.modified,
57
+ variableCount: notes.length,
58
+ quant: manifestIsQuant(manifest),
59
+ });
60
+ }
61
+ catch {
62
+ // Unreadable/partial model folder — skip rather than fail the whole list.
63
+ }
64
+ }
65
+ refs.sort((a, b) => a.name.localeCompare(b.name));
66
+ return refs;
67
+ }
68
+ async loadGraph(folder) {
69
+ const manifest = await this.readManifest(folder);
70
+ const nodes = await this.loadNotes(folder);
71
+ const graph = new LoopGraph(nodes);
72
+ const loops = graph.detectLoops();
73
+ const labels = labelLoopsByKey(loops, (id) => graph.node(id)?.label ?? id);
74
+ const quant = manifestIsQuant(manifest) || nodes.some((n) => "quant" in n.extra);
75
+ return { folder, manifest, nodes, loops, labels, quant };
76
+ }
77
+ // ---- model lifecycle -----------------------------------------------------
78
+ async createModel(name) {
79
+ const modelId = genModelId();
80
+ const leaf = slug(name) || modelId;
81
+ let folder = joinPath(this.opts.modelsRoot, leaf);
82
+ let n = 2;
83
+ while (await this.storage.exists(folder)) {
84
+ folder = joinPath(this.opts.modelsRoot, `${leaf}-${n}`);
85
+ n++;
86
+ }
87
+ await this.storage.mkdirs(folder);
88
+ const now = new Date().toISOString();
89
+ const manifest = {
90
+ id: modelId,
91
+ name,
92
+ schemaVersion: 1,
93
+ viewport: { x: 0, y: 0, zoom: 1 },
94
+ created: now,
95
+ modified: now,
96
+ order: 0,
97
+ extra: {},
98
+ };
99
+ await this.writeManifest(folder, manifest);
100
+ return { id: modelId, name, folder, group: null, modified: now, variableCount: 0, quant: false };
101
+ }
102
+ async renameModel(folder, name) {
103
+ const trimmed = name.trim();
104
+ if (trimmed.length === 0) {
105
+ throw new Error("Model title cannot be empty.");
106
+ }
107
+ const manifest = await this.readManifest(folder);
108
+ // Keep the folder name in sync with the title, using the same slug rule and
109
+ // `-2/-3…` collision suffixing as createModel. Renaming the leaf within the
110
+ // model's current parent dir preserves any folder organization. Skip the move
111
+ // when the slug is unchanged (e.g. punctuation-only edits) so we don't churn
112
+ // paths — and so we never collide the folder with itself.
113
+ const dest = await this.destForRename(folder, trimmed);
114
+ // Write the new title into model.json first (at the current path), then move
115
+ // the folder so links settle around the final location.
116
+ const updated = {
117
+ ...manifest,
118
+ name: trimmed,
119
+ modified: new Date().toISOString(),
120
+ };
121
+ await this.writeManifest(folder, updated);
122
+ if (dest !== folder) {
123
+ await this.storage.move(folder, dest);
124
+ }
125
+ const notes = await this.listNoteFiles(dest);
126
+ return {
127
+ id: updated.id,
128
+ name: updated.name,
129
+ folder: dest,
130
+ group: updated.folder ?? null,
131
+ modified: updated.modified,
132
+ variableCount: notes.length,
133
+ quant: manifestIsQuant(updated),
134
+ };
135
+ }
136
+ async retitleModel(folder, name) {
137
+ const trimmed = name.trim();
138
+ if (trimmed.length === 0) {
139
+ throw new Error("Model title cannot be empty.");
140
+ }
141
+ // The inverse of renameModel: the folder is already where the user put it
142
+ // (e.g. they renamed it in Obsidian's file explorer), so the title follows
143
+ // the folder. Write the new name into model.json in place — never re-slug or
144
+ // move the directory, or we'd fight the rename the user just made.
145
+ const manifest = await this.readManifest(folder);
146
+ const updated = {
147
+ ...manifest,
148
+ name: trimmed,
149
+ modified: new Date().toISOString(),
150
+ };
151
+ await this.writeManifest(folder, updated);
152
+ const notes = await this.listNoteFiles(folder);
153
+ return {
154
+ id: updated.id,
155
+ name: updated.name,
156
+ folder,
157
+ group: updated.folder ?? null,
158
+ modified: updated.modified,
159
+ variableCount: notes.length,
160
+ quant: manifestIsQuant(updated),
161
+ };
162
+ }
163
+ /**
164
+ * Sync a node's label to its filename after the user renamed the file in the
165
+ * vault (`Nodes/<stem>.md`). The node-level inverse of {@link retitleModel}:
166
+ * the file is already where the user put it, so the label follows it (the
167
+ * de-slugged stem, underscores → spaces). {@link writeNote} may then
168
+ * re-normalize the on-disk name (spaces → underscores) so it converges. The
169
+ * stable `var_…` id and every link to it are untouched, so links never break.
170
+ */
171
+ async relabelNodeFromFilename(folder, fileStem) {
172
+ let prev;
173
+ try {
174
+ const raw = await this.storage.read(this.notePath(folder, fileStem));
175
+ prev = parseNote(raw, this.yaml, fileStem);
176
+ }
177
+ catch {
178
+ return; // file vanished or unreadable between rename and handler — nothing to sync
179
+ }
180
+ const label = noteUnslug(fileStem);
181
+ if (label.length === 0 || label === prev.label)
182
+ return;
183
+ await this.writeNote(folder, prev, { ...prev, label });
184
+ await this.touchManifest(folder);
185
+ }
186
+ /** Target folder for renaming `folder`'s model to `name`: the new slug under
187
+ * the same parent, suffixed on collision; unchanged when the slug matches. */
188
+ async destForRename(folder, name) {
189
+ const current = baseName(folder);
190
+ const desired = slug(name) || current;
191
+ if (desired === current)
192
+ return folder;
193
+ const parent = parentPath(folder);
194
+ let leaf = desired;
195
+ let n = 2;
196
+ while (await this.storage.exists(joinPath(parent, leaf))) {
197
+ leaf = `${desired}-${n}`;
198
+ n++;
199
+ }
200
+ return joinPath(parent, leaf);
201
+ }
202
+ async deleteModel(folder) {
203
+ await this.storage.rmdir(folder);
204
+ }
205
+ /** Resolve a folder to its model id (read from `model.json`). */
206
+ async modelId(folder) {
207
+ return (await this.readManifest(folder)).id;
208
+ }
209
+ /**
210
+ * Duplicate a model as a brand-new model: a byte-for-byte copy of the folder
211
+ * tree (so prose, layout, loop notes, and System note all come along), then a
212
+ * full re-key so the copy has a fresh model id, fresh variable ids, and fresh
213
+ * loop/System identity anchors. `shared` keys are PRESERVED, so the copy stays
214
+ * joined to the cross-model graph. Lands as a sibling of the source, titled
215
+ * "<name> (copy)" (bumping to "(copy 2)…" on a name clash).
216
+ */
217
+ async duplicateModel(srcFolder) {
218
+ const manifest = await this.readManifest(srcFolder);
219
+ const copyName = await this.uniqueModelName(manifest.name);
220
+ const dest = await this.uniqueSiblingFolder(parentPath(srcFolder), slug(copyName) || "model");
221
+ await this.copyTree(srcFolder, dest);
222
+ const newId = await this.rekeyModel(dest);
223
+ // rekeyModel stamped the fresh id + modified; set the copy's title in place
224
+ // (the folder is already named from copyName's slug — don't re-slug/move).
225
+ const copied = await this.readManifest(dest);
226
+ await this.writeManifest(dest, { ...copied, name: copyName });
227
+ return {
228
+ id: newId,
229
+ name: copyName,
230
+ folder: dest,
231
+ group: copied.folder ?? null,
232
+ modified: copied.modified,
233
+ variableCount: (await this.listNoteFiles(dest)).length,
234
+ quant: manifestIsQuant(copied),
235
+ };
236
+ }
237
+ /**
238
+ * Heal model-id collisions: a raw Obsidian copy of a model (or a folder of
239
+ * models) clones their ids verbatim, so two folders end up claiming the same
240
+ * `mdl_…`. Group folders by id; for each colliding group keep the OLDEST (by
241
+ * `created`) and re-key every newer sibling. Returns one row per re-keyed
242
+ * folder. A no-op (empty result) when every id is already unique.
243
+ */
244
+ async healDuplicateIds() {
245
+ const models = await this.listModels();
246
+ const byId = new Map();
247
+ for (const m of models) {
248
+ const arr = byId.get(m.id);
249
+ if (arr)
250
+ arr.push(m);
251
+ else
252
+ byId.set(m.id, [m]);
253
+ }
254
+ const changed = [];
255
+ for (const [id, group] of byId) {
256
+ if (group.length < 2)
257
+ continue;
258
+ // Oldest model keeps the id; tie-break on folder path so the choice is
259
+ // deterministic (and stable across heal passes).
260
+ const ranked = await Promise.all(group.map(async (m) => ({
261
+ m,
262
+ created: (await this.readManifest(m.folder)).created,
263
+ })));
264
+ ranked.sort((a, b) => a.created !== b.created
265
+ ? a.created.localeCompare(b.created)
266
+ : a.m.folder.localeCompare(b.m.folder));
267
+ for (const { m } of ranked.slice(1)) {
268
+ const newId = await this.rekeyModel(m.folder);
269
+ changed.push({ folder: m.folder, oldId: id, newId });
270
+ }
271
+ }
272
+ return changed;
273
+ }
274
+ // ---- variables -----------------------------------------------------------
275
+ async addVariable(folder, init) {
276
+ const vf = {
277
+ ...emptyVariable(genVarId(), init.label),
278
+ type: init.type ?? "auxiliary",
279
+ group: init.group,
280
+ shared: init.shared,
281
+ claLayer: init.claLayer,
282
+ x: init.x ?? 0,
283
+ y: init.y ?? 0,
284
+ };
285
+ const out = await this.writeNote(folder, undefined, vf);
286
+ await this.touchManifest(folder);
287
+ return out;
288
+ }
289
+ async updateVariable(folder, id, patch) {
290
+ const prev = await this.readNote(folder, id);
291
+ const next = { ...prev };
292
+ if (patch.label !== undefined)
293
+ next.label = patch.label;
294
+ if (patch.type !== undefined)
295
+ next.type = patch.type;
296
+ if (patch.group !== undefined)
297
+ next.group = patch.group ?? undefined;
298
+ if (patch.shared !== undefined)
299
+ next.shared = patch.shared ?? undefined;
300
+ if (patch.claLayer !== undefined)
301
+ next.claLayer = patch.claLayer ?? undefined;
302
+ if (patch.status !== undefined)
303
+ next.status = patch.status ?? undefined;
304
+ if (patch.tags !== undefined)
305
+ next.tags = [...patch.tags];
306
+ if (patch.body !== undefined)
307
+ next.body = patch.body;
308
+ const out = await this.writeNote(folder, prev, next);
309
+ await this.touchManifest(folder);
310
+ return out;
311
+ }
312
+ async setEquation(folder, id, patch) {
313
+ const prev = await this.readNote(folder, id);
314
+ const prevQuant = prev.extra["quant"] && typeof prev.extra["quant"] === "object"
315
+ ? prev.extra["quant"]
316
+ : {};
317
+ const quant = { ...prevQuant };
318
+ // A provided field with a value sets it; an empty/blank value clears it;
319
+ // `undefined` leaves it as-is.
320
+ for (const key of ["equation", "initial", "units"]) {
321
+ const v = patch[key];
322
+ if (v === undefined)
323
+ continue;
324
+ const trimmed = v.trim();
325
+ if (trimmed.length === 0)
326
+ delete quant[key];
327
+ else
328
+ quant[key] = trimmed;
329
+ }
330
+ const extra = { ...prev.extra };
331
+ if (Object.keys(quant).length === 0)
332
+ delete extra["quant"];
333
+ else
334
+ extra["quant"] = quant;
335
+ const out = await this.writeNote(folder, prev, { ...prev, extra });
336
+ await this.touchManifest(folder);
337
+ return out;
338
+ }
339
+ async setSubsystem(folder, varId, child) {
340
+ const prev = await this.readNote(folder, varId);
341
+ // Match the app/CLI exactly: a relative wikilink to the child's System note,
342
+ // keyed on the child folder basename, aliased to the child's display name.
343
+ const dir = child ? child.folder.split("/").filter(Boolean).pop() ?? child.folder : "";
344
+ const link = child ? `[[../${dir}/System|${child.name}]]` : undefined;
345
+ await this.writeNote(folder, prev, { ...prev, subsystem: link });
346
+ await this.touchManifest(folder);
347
+ }
348
+ async moveVariable(folder, id, x, y) {
349
+ const prev = await this.readNote(folder, id);
350
+ // stampMeta treats an x/y-only change as cosmetic → rev/modified preserved.
351
+ await this.writeNote(folder, prev, { ...prev, x, y });
352
+ }
353
+ async removeVariable(folder, id) {
354
+ // The note is named by its label now, so resolve its real path by id; also
355
+ // clear any stale id-named copies (a legacy flat note or an old Nodes/<id>.md).
356
+ const resolved = (await this.noteFilesById(folder)).get(id);
357
+ if (resolved)
358
+ await this.storage.remove(resolved);
359
+ for (const p of [this.notePath(folder, id), this.legacyNotePath(folder, id)]) {
360
+ if (p !== resolved)
361
+ await this.storage.remove(p);
362
+ }
363
+ // Drop inbound links so loop detection and the manifest stay consistent.
364
+ const notes = await this.loadNotes(folder);
365
+ for (const n of notes) {
366
+ if (n.links.some((l) => l.to === id)) {
367
+ await this.writeNote(folder, n, {
368
+ ...n,
369
+ links: n.links.filter((l) => l.to !== id),
370
+ });
371
+ }
372
+ }
373
+ await this.touchManifest(folder);
374
+ }
375
+ // ---- links ---------------------------------------------------------------
376
+ async addLink(folder, from, to, init = {}) {
377
+ const prev = await this.readNote(folder, from);
378
+ const links = prev.links.filter((l) => l.to !== to);
379
+ links.push(makeLink(to, init));
380
+ await this.writeNote(folder, prev, { ...prev, links });
381
+ await this.touchManifest(folder);
382
+ }
383
+ async updateLink(folder, from, to, patch) {
384
+ const prev = await this.readNote(folder, from);
385
+ const links = prev.links.map((l) => l.to === to ? applyLinkPatch(l, patch) : l);
386
+ await this.writeNote(folder, prev, { ...prev, links });
387
+ await this.touchManifest(folder);
388
+ }
389
+ async removeLink(folder, from, to) {
390
+ const prev = await this.readNote(folder, from);
391
+ await this.writeNote(folder, prev, {
392
+ ...prev,
393
+ links: prev.links.filter((l) => l.to !== to),
394
+ });
395
+ await this.touchManifest(folder);
396
+ }
397
+ // ---- bulk build / layout / viewport --------------------------------------
398
+ async buildModel(folder, spec) {
399
+ const idByRef = new Map();
400
+ const created = [];
401
+ for (const v of spec.variables) {
402
+ const id = v.id && v.id.trim() ? v.id.trim() : genVarId();
403
+ created.push({
404
+ ...emptyVariable(id, v.label),
405
+ type: v.type ?? "auxiliary",
406
+ group: v.group,
407
+ shared: v.shared,
408
+ });
409
+ idByRef.set(v.label, id);
410
+ idByRef.set(id, id);
411
+ }
412
+ const resolve = (ref) => idByRef.get(ref) ?? ref;
413
+ const byId = new Map(created.map((v) => [v.id, v]));
414
+ for (const lk of spec.links) {
415
+ const src = byId.get(resolve(lk.from));
416
+ if (!src)
417
+ continue; // link from an unknown source — skip
418
+ src.links = src.links.filter((l) => l.to !== resolve(lk.to));
419
+ src.links.push(makeLink(resolve(lk.to), lk));
420
+ }
421
+ if (spec.layout !== false)
422
+ applyLayout(created);
423
+ for (const v of created)
424
+ await this.writeNote(folder, undefined, v);
425
+ await this.touchManifest(folder);
426
+ }
427
+ async relayout(folder) {
428
+ const notes = await this.loadNotes(folder);
429
+ const pos = layoutPositions(notes);
430
+ for (const n of notes) {
431
+ const p = pos.get(n.id);
432
+ if (p)
433
+ await this.writeNote(folder, n, { ...n, x: p[0], y: p[1] });
434
+ }
435
+ }
436
+ async setViewport(folder, viewport) {
437
+ const manifest = await this.readManifest(folder);
438
+ // Viewport is cosmetic — write the manifest without touching `modified`.
439
+ await this.writeManifest(folder, { ...manifest, viewport });
440
+ }
441
+ // ---- loop notes (Loops/*.md — Obsidian-native, shared with the Dart app) --
442
+ //
443
+ // Each feedback loop's annotation is one `Loops/<slug>.md` file whose identity
444
+ // is the loop's (type, canonical-ordered member ids) in frontmatter — never the
445
+ // filename or the labels. The legacy `model.json` maps (loopNotes/loopTitles/
446
+ // loopValence/loopArchetypes) are auto-migrated to files on first touch. This
447
+ // mirrors `core/lib/cli/vault_engine.dart` so the plugin and the app/CLI agree
448
+ // on one vault. Resolved maps stay keyed by the legacy `<R|B>:<sorted labels>`
449
+ // key (see `loopKey`) so existing UI consumers are unchanged.
450
+ async getLoopNotes(folder) {
451
+ await this.migrateLoopNotesIfNeeded(folder);
452
+ const files = await this.listLoopNoteFiles(folder);
453
+ if (files.length === 0) {
454
+ // Pre-migration fallback: no Loops/ files → serve the manifest map.
455
+ const manifest = await this.readManifest(folder);
456
+ const out = {};
457
+ for (const [k, v] of Object.entries(readLoopNotes(manifest))) {
458
+ if (v.trim().length > 0)
459
+ out[k] = v;
460
+ }
461
+ return out;
462
+ }
463
+ const graph = new LoopGraph(await this.loadNotes(folder));
464
+ const nameOf = (id) => graph.node(id)?.label ?? id;
465
+ const out = {};
466
+ const matched = new Set();
467
+ for (const l of graph.detectLoops()) {
468
+ const type = l.type === LoopType.reinforcing ? "R" : "B";
469
+ let note;
470
+ for (const f of files) {
471
+ if (matched.has(f))
472
+ continue;
473
+ const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
474
+ if (loopMatchesNote(type, l.nodeIds, parsed)) {
475
+ matched.add(f);
476
+ note = parsed;
477
+ break;
478
+ }
479
+ }
480
+ if (!note)
481
+ continue;
482
+ if (note.body.trim().length > 0) {
483
+ out[loopKey(l.nodeIds.map(nameOf), type)] = note.body;
484
+ }
485
+ }
486
+ return out;
487
+ }
488
+ async setLoopNote(folder, key, text) {
489
+ await this.upsertLoopFileByKey(folder, key, { note: text });
490
+ }
491
+ /**
492
+ * The vault-relative path of the canonical `Loops/*.md` file for `key`,
493
+ * creating an empty-but-anchored note if none exists yet. Returns null when no
494
+ * live loop carries that key (e.g. the graph changed). Used by the canvas to
495
+ * open the real markdown file for editing.
496
+ */
497
+ async loopNotePath(folder, key) {
498
+ await this.migrateLoopNotesIfNeeded(folder);
499
+ const graph = new LoopGraph(await this.loadNotes(folder));
500
+ const nameOf = (id) => graph.node(id)?.label ?? id;
501
+ for (const l of graph.detectLoops()) {
502
+ const type = l.type === LoopType.reinforcing ? "R" : "B";
503
+ const memberLabels = l.nodeIds.map(nameOf);
504
+ if (loopKey(memberLabels, type) !== key)
505
+ continue;
506
+ for (const f of await this.listLoopNoteFiles(folder)) {
507
+ const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
508
+ if (loopMatchesNote(type, l.nodeIds, parsed)) {
509
+ return joinPath(this.loopsDir(folder), f);
510
+ }
511
+ }
512
+ const file = await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, {});
513
+ return joinPath(this.loopsDir(folder), file);
514
+ }
515
+ return null;
516
+ }
517
+ async ensureSystemNote(folder) {
518
+ const path = joinPath(folder, "System.md");
519
+ if (!(await this.storage.exists(path))) {
520
+ const manifest = await this.readManifest(folder);
521
+ // Minimal valid System note — frontmatter only. Mirrors the required half
522
+ // of core/lib/vault/system_note.dart (model id); `h`/summary/body are
523
+ // optional and stamped by the app on its next write.
524
+ await this.storage.write(path, `---\nmodel: ${JSON.stringify(manifest.id)}\n---\n`);
525
+ }
526
+ return path;
527
+ }
528
+ async deriveParents(folder) {
529
+ const models = await this.listModels();
530
+ const current = models.find((m) => m.folder === folder);
531
+ if (!current)
532
+ return [];
533
+ return deriveParentAnchors({ folder: current.folder, name: current.name }, models.filter((m) => m.folder !== folder).map((m) => ({ folder: m.folder, name: m.name })), (f) => this.loadNotes(f));
534
+ }
535
+ // ---- export --------------------------------------------------------------
536
+ async export(folder, format) {
537
+ const view = await this.loadGraph(folder);
538
+ const { nodes, edges, loops } = toExportGraph(view);
539
+ return render(format, view.manifest.id, view.manifest.name, nodes, edges, loops);
540
+ }
541
+ // ---- internals -----------------------------------------------------------
542
+ /** The model's `Nodes/` subfolder, where variable notes now live. */
543
+ nodesDir(folder) {
544
+ return joinPath(folder, "Nodes");
545
+ }
546
+ /** A variable note's path for a filename stem: `<folder>/Nodes/<stem>.md`. */
547
+ notePath(folder, stem) {
548
+ return joinPath(this.nodesDir(folder), `${stem}.md`);
549
+ }
550
+ /** Pre-`Nodes/` location of a variable note, flat at the model root. */
551
+ legacyNotePath(folder, stem) {
552
+ return joinPath(folder, `${stem}.md`);
553
+ }
554
+ async readNote(folder, id) {
555
+ // The filename tracks the label, so resolve by frontmatter id. Fall back to
556
+ // the canonical id-named path for an id that isn't on disk yet.
557
+ const path = (await this.noteFilesById(folder)).get(id) ?? this.notePath(folder, id);
558
+ return parseNote(await this.storage.read(path), this.yaml, id);
559
+ }
560
+ async writeNote(folder, prev, next) {
561
+ const stamped = stampMeta(prev, next, WRITE_SOURCE_PLUGIN);
562
+ // The file is named after the label (so the vault reads like the diagram);
563
+ // a label-less node falls back to its stable id. Dedupe against the names
564
+ // already taken by *other* notes, suffixing `-2/-3…` like model folders.
565
+ const byId = await this.noteFilesById(folder);
566
+ const currentPath = byId.get(stamped.id);
567
+ const desired = noteSlug(stamped.label) || stamped.id;
568
+ const taken = new Set();
569
+ for (const [otherId, p] of byId) {
570
+ if (otherId !== stamped.id)
571
+ taken.add(baseName(p).replace(/\.md$/, ""));
572
+ }
573
+ let stem = desired;
574
+ let n = 2;
575
+ while (taken.has(stem))
576
+ stem = `${desired}-${n++}`;
577
+ const target = this.notePath(folder, stem);
578
+ await this.storage.write(target, serializeNote(stamped));
579
+ // Migrate on write: drop the node's previous file if its name changed (an
580
+ // id-named or differently-labelled copy), plus any legacy flat copy.
581
+ if (currentPath && currentPath !== target) {
582
+ await this.storage.remove(currentPath);
583
+ }
584
+ const legacy = this.legacyNotePath(folder, stamped.id);
585
+ if (legacy !== target && legacy !== currentPath && (await this.storage.exists(legacy))) {
586
+ await this.storage.remove(legacy);
587
+ }
588
+ return stamped;
589
+ }
590
+ async readManifest(folder) {
591
+ const raw = await this.storage.read(joinPath(folder, "model.json"));
592
+ const j = JSON.parse(raw);
593
+ if (j["id"] === undefined || j["id"] === null)
594
+ j["id"] = baseName(folder);
595
+ return manifestFromJson(j);
596
+ }
597
+ async writeManifest(folder, manifest) {
598
+ await this.storage.write(joinPath(folder, "model.json"), JSON.stringify(manifestToJson(manifest), null, 2));
599
+ }
600
+ /** Bump `model.json` modified after a content change. */
601
+ async touchManifest(folder) {
602
+ try {
603
+ const manifest = await this.readManifest(folder);
604
+ await this.writeManifest(folder, {
605
+ ...manifest,
606
+ modified: new Date().toISOString(),
607
+ });
608
+ }
609
+ catch {
610
+ // No manifest yet (mid-build) — ignore.
611
+ }
612
+ }
613
+ /**
614
+ * Variable-note id → vault path, gathered from the model's `Nodes/` subfolder
615
+ * and any legacy flat notes at the model root. A `Nodes/` note wins on a
616
+ * duplicate id (mirrors the Dart reader). `model.json` and the special notes
617
+ * (System/Futures/CLA) are excluded.
618
+ */
619
+ async noteFilesById(folder) {
620
+ const byId = new Map();
621
+ const collect = async (files, excludeSpecial) => {
622
+ for (const f of files) {
623
+ const b = baseName(f);
624
+ if (!b.endsWith(".md"))
625
+ continue;
626
+ if (excludeSpecial && SPECIAL_NOTES.has(b))
627
+ continue;
628
+ // The filename no longer is the id — read it from the frontmatter
629
+ // (falling back to the filename stem for a note that lacks one).
630
+ const id = parseNote(await this.storage.read(f), this.yaml, b.replace(/\.md$/, "")).id;
631
+ byId.set(id, f);
632
+ }
633
+ };
634
+ // Legacy flat notes first, then let canonical `Nodes/` notes override.
635
+ await collect((await this.storage.list(folder)).files, true);
636
+ try {
637
+ await collect((await this.storage.list(this.nodesDir(folder))).files, false);
638
+ }
639
+ catch {
640
+ // No `Nodes/` subfolder — flat-only (legacy) layout.
641
+ }
642
+ return byId;
643
+ }
644
+ async listNoteFiles(folder) {
645
+ return [...(await this.noteFilesById(folder)).values()];
646
+ }
647
+ async loadNotes(folder) {
648
+ const byId = new Map();
649
+ const collect = async (files, excludeSpecial, canonical) => {
650
+ for (const f of files) {
651
+ const b = baseName(f);
652
+ if (!b.endsWith(".md"))
653
+ continue;
654
+ if (excludeSpecial && SPECIAL_NOTES.has(b))
655
+ continue;
656
+ const file = parseNote(await this.storage.read(f), this.yaml, b.replace(/\.md$/, ""));
657
+ // Canonical `Nodes/` notes override a legacy flat note with the same id.
658
+ if (canonical || !byId.has(file.id))
659
+ byId.set(file.id, file);
660
+ }
661
+ };
662
+ await collect((await this.storage.list(folder)).files, true, false);
663
+ try {
664
+ await collect((await this.storage.list(this.nodesDir(folder))).files, false, true);
665
+ }
666
+ catch {
667
+ // No `Nodes/` subfolder — flat-only (legacy) layout.
668
+ }
669
+ return [...byId.values()];
670
+ }
671
+ // ---- loop-note file storage (the model's `Loops/` subfolder) -------------
672
+ loopsDir(folder) {
673
+ return joinPath(folder, "Loops");
674
+ }
675
+ /** Sorted `.md` basenames in `Loops/`; tolerant of a missing folder. */
676
+ async listLoopNoteFiles(folder) {
677
+ let listing;
678
+ try {
679
+ listing = await this.storage.list(this.loopsDir(folder));
680
+ }
681
+ catch {
682
+ return [];
683
+ }
684
+ return listing.files
685
+ .map(baseName)
686
+ .filter((b) => b.endsWith(".md"))
687
+ .sort();
688
+ }
689
+ async readLoopNoteFile(folder, filename) {
690
+ try {
691
+ return await this.storage.read(joinPath(this.loopsDir(folder), filename));
692
+ }
693
+ catch {
694
+ return null;
695
+ }
696
+ }
697
+ async writeLoopNoteFile(folder, filename, content) {
698
+ await this.storage.write(joinPath(this.loopsDir(folder), filename), content);
699
+ }
700
+ async uniqueLoopFilename(folder, slug) {
701
+ const taken = new Set(await this.listLoopNoteFiles(folder));
702
+ let name = `${slug}.md`;
703
+ let n = 2;
704
+ while (taken.has(name)) {
705
+ name = `${slug}-${n}.md`;
706
+ n++;
707
+ }
708
+ return name;
709
+ }
710
+ /**
711
+ * One-way, idempotent migration of legacy loop annotations from the
712
+ * `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
713
+ * exists the model is treated as migrated and this returns immediately. Each
714
+ * legacy key is matched to a live loop by its `loopKey`; a matched key keeps
715
+ * the loop's canonical member ids, an unmatched key becomes a pre-flagged
716
+ * orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
717
+ * After writing, the four legacy keys are stripped from the manifest.
718
+ */
719
+ async migrateLoopNotesIfNeeded(folder) {
720
+ if ((await this.listLoopNoteFiles(folder)).length > 0)
721
+ return;
722
+ const manifest = await this.readManifest(folder);
723
+ const fields = ["loopNotes", "loopTitles", "loopValence", "loopArchetypes"];
724
+ const mapOf = (f) => {
725
+ const m = manifest.extra[f];
726
+ const out = {};
727
+ if (m && typeof m === "object" && !Array.isArray(m)) {
728
+ for (const [k, v] of Object.entries(m))
729
+ out[k] = String(v);
730
+ }
731
+ return out;
732
+ };
733
+ const hasLegacy = fields.some((f) => Object.keys(mapOf(f)).length > 0);
734
+ if (!hasLegacy)
735
+ return;
736
+ const notes = mapOf("loopNotes");
737
+ const titles = mapOf("loopTitles");
738
+ const valence = mapOf("loopValence");
739
+ const archetypes = mapOf("loopArchetypes");
740
+ const allKeys = new Set([
741
+ ...Object.keys(notes),
742
+ ...Object.keys(titles),
743
+ ...Object.keys(valence),
744
+ ...Object.keys(archetypes),
745
+ ]);
746
+ const graph = new LoopGraph(await this.loadNotes(folder));
747
+ const nameOf = (id) => graph.node(id)?.label ?? id;
748
+ const liveByKey = new Map();
749
+ for (const l of graph.detectLoops()) {
750
+ const type = l.type === LoopType.reinforcing ? "R" : "B";
751
+ liveByKey.set(loopKey(l.nodeIds.map(nameOf), type), l);
752
+ }
753
+ for (const key of allKeys) {
754
+ const type = key.startsWith("B") ? "B" : "R";
755
+ const live = liveByKey.get(key);
756
+ const members = live ? canonicalLoopMembers(live.nodeIds) : [];
757
+ const labels = live
758
+ ? live.nodeIds.map(nameOf)
759
+ : key.includes(":")
760
+ ? key.substring(key.indexOf(":") + 1).split("|")
761
+ : [];
762
+ const arch = archetypes[key] ?? "";
763
+ const note = {
764
+ type,
765
+ members,
766
+ title: titles[key] ?? "",
767
+ valence: valence[key] ?? "",
768
+ loopEcho: loopEchoLabel(labels, type),
769
+ body: notes[key] ?? "",
770
+ extra: arch.length > 0 ? { archetype: arch } : {},
771
+ malformed: false,
772
+ };
773
+ const filename = await this.uniqueLoopFilename(folder, loopSlug(type, note.title, labels));
774
+ await this.writeLoopNoteFile(folder, filename, serializeLoopNote(note));
775
+ }
776
+ const extra = { ...manifest.extra };
777
+ for (const f of fields)
778
+ delete extra[f];
779
+ await this.writeManifest(folder, {
780
+ ...manifest,
781
+ extra,
782
+ modified: new Date().toISOString(),
783
+ });
784
+ }
785
+ /** Find the live loop whose `loopKey` == `key`, then upsert its file. No-op
786
+ * (and no file) when the graph carries no such loop. */
787
+ async upsertLoopFileByKey(folder, key, fields) {
788
+ await this.migrateLoopNotesIfNeeded(folder);
789
+ const graph = new LoopGraph(await this.loadNotes(folder));
790
+ const nameOf = (id) => graph.node(id)?.label ?? id;
791
+ for (const l of graph.detectLoops()) {
792
+ const type = l.type === LoopType.reinforcing ? "R" : "B";
793
+ const memberLabels = l.nodeIds.map(nameOf);
794
+ if (loopKey(memberLabels, type) !== key)
795
+ continue;
796
+ await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, fields);
797
+ return;
798
+ }
799
+ }
800
+ /** Find-or-create the identity-matched `Loops/` file and apply `fields`
801
+ * (undefined leaves a field unchanged; `archetype: ""` clears it). Returns the
802
+ * filename written. Title/valence/extra of an existing file are preserved. */
803
+ async writeLoopFile(folder, nodeIds, type, memberLabels, fields) {
804
+ const members = canonicalLoopMembers(nodeIds);
805
+ let filename;
806
+ let existing = {
807
+ type,
808
+ members,
809
+ title: "",
810
+ valence: "",
811
+ loopEcho: "",
812
+ body: "",
813
+ extra: {},
814
+ malformed: false,
815
+ };
816
+ for (const f of await this.listLoopNoteFiles(folder)) {
817
+ const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
818
+ if (loopMatchesNote(type, nodeIds, parsed)) {
819
+ filename = f;
820
+ existing = parsed;
821
+ break;
822
+ }
823
+ }
824
+ const extra = { ...existing.extra };
825
+ if (fields.archetype !== undefined) {
826
+ if (fields.archetype.trim().length === 0)
827
+ delete extra["archetype"];
828
+ else
829
+ extra["archetype"] = fields.archetype.trim();
830
+ }
831
+ const next = {
832
+ type,
833
+ members,
834
+ title: fields.title ?? existing.title,
835
+ valence: fields.valence ?? existing.valence,
836
+ loopEcho: loopEchoLabel(memberLabels, type),
837
+ body: fields.note ?? existing.body,
838
+ extra,
839
+ malformed: false,
840
+ };
841
+ if (filename === undefined) {
842
+ filename = await this.uniqueLoopFilename(folder, loopSlug(type, next.title, memberLabels));
843
+ }
844
+ await this.writeLoopNoteFile(folder, filename, serializeLoopNote(next));
845
+ return filename;
846
+ }
847
+ /** Recursively find folders containing a `model.json` (dot-folders pruned). */
848
+ async findModelFolders() {
849
+ const out = [];
850
+ const visit = async (dir, depth) => {
851
+ if (depth > MAX_SCAN_DEPTH)
852
+ return;
853
+ let listing;
854
+ try {
855
+ listing = await this.storage.list(dir);
856
+ }
857
+ catch {
858
+ return;
859
+ }
860
+ if (listing.files.some((f) => baseName(f) === "model.json")) {
861
+ out.push(dir);
862
+ return; // Prune: a model's `Nodes/`/`Loops/` subfolders aren't models.
863
+ }
864
+ for (const sub of listing.folders) {
865
+ if (baseName(sub).startsWith("."))
866
+ continue;
867
+ await visit(sub, depth + 1);
868
+ }
869
+ };
870
+ // Discover models across the WHOLE vault, not just `modelsRoot`. The latter
871
+ // only governs where *new* models are created (createModel); existing
872
+ // vaults — and the Dart app/CLI — write models at the vault top level too.
873
+ await visit("", 0);
874
+ return out;
875
+ }
876
+ // ---- duplicate / re-key internals ----------------------------------------
877
+ /**
878
+ * Re-key a model in place: assign a fresh model id, fresh variable ids, and
879
+ * re-point every internal reference (links, loop-note members, the System
880
+ * note's identity anchor). Structure, prose, layout, `shared` keys, and
881
+ * subsystem wikilinks are untouched. Returns the new model id. Used by both
882
+ * {@link duplicateModel} (on a fresh copy) and {@link healDuplicateIds} (on a
883
+ * collided clone).
884
+ */
885
+ async rekeyModel(folder) {
886
+ const manifest = await this.readManifest(folder);
887
+ const notes = await this.loadNotes(folder);
888
+ const idMap = new Map();
889
+ for (const v of notes)
890
+ idMap.set(v.id, genVarId());
891
+ const newModelId = genModelId();
892
+ // Notes are filename-by-label, so a naive per-note rewrite would collide the
893
+ // new files against the still-present old ones (same label → same stem →
894
+ // `-2` phantom duplicates). Clear them all first, then write each remapped
895
+ // note into an empty `Nodes/`.
896
+ await this.removeAllVariableFiles(folder);
897
+ for (const v of notes) {
898
+ await this.writeNote(folder, undefined, remapVariableIds(v, idMap));
899
+ }
900
+ // Re-point each loop note's members (its stable identity) to the new ids.
901
+ for (const filename of await this.listLoopNoteFiles(folder)) {
902
+ const raw = await this.readLoopNoteFile(folder, filename);
903
+ if (raw === null)
904
+ continue;
905
+ const note = parseLoopNote(raw, this.yaml);
906
+ const members = canonicalLoopMembers(note.members.map((m) => idMap.get(m) ?? m));
907
+ await this.writeLoopNoteFile(folder, filename, serializeLoopNote({ ...note, members }));
908
+ }
909
+ await this.swapSystemModelId(folder, newModelId);
910
+ await this.writeManifest(folder, {
911
+ ...manifest,
912
+ id: newModelId,
913
+ modified: new Date().toISOString(),
914
+ });
915
+ return newModelId;
916
+ }
917
+ /** Recursively copy a folder tree byte-for-byte (the source is untouched). */
918
+ async copyTree(src, dst) {
919
+ await this.storage.mkdirs(dst);
920
+ const listing = await this.storage.list(src);
921
+ for (const f of listing.files) {
922
+ await this.storage.write(joinPath(dst, baseName(f)), await this.storage.read(f));
923
+ }
924
+ for (const sub of listing.folders) {
925
+ await this.copyTree(sub, joinPath(dst, baseName(sub)));
926
+ }
927
+ }
928
+ /** Remove every variable note (flat root + `Nodes/`); keep the special notes. */
929
+ async removeAllVariableFiles(folder) {
930
+ const root = (await this.storage.list(folder)).files.filter((f) => baseName(f).endsWith(".md") && !SPECIAL_NOTES.has(baseName(f)));
931
+ for (const f of root)
932
+ await this.storage.remove(f);
933
+ try {
934
+ const nodes = (await this.storage.list(this.nodesDir(folder))).files.filter((f) => baseName(f).endsWith(".md"));
935
+ for (const f of nodes)
936
+ await this.storage.remove(f);
937
+ }
938
+ catch {
939
+ // No `Nodes/` subfolder — flat-only (legacy) layout.
940
+ }
941
+ }
942
+ /** Rewrite `System.md`'s `model:` identity anchor to `newId` (if it exists). */
943
+ async swapSystemModelId(folder, newId) {
944
+ const path = joinPath(folder, "System.md");
945
+ if (!(await this.storage.exists(path)))
946
+ return;
947
+ const [fm, body] = splitFrontmatter(await this.storage.read(path));
948
+ if (fm === null)
949
+ return; // no frontmatter to anchor — leave as-is
950
+ const line = `model: ${JSON.stringify(newId)}`;
951
+ const nextFm = /^\s*model:\s*.*$/m.test(fm)
952
+ ? fm.replace(/^\s*model:\s*.*$/m, line)
953
+ : `${line}\n${fm}`;
954
+ let out = `---\n${nextFm}${nextFm.endsWith("\n") ? "" : "\n"}---\n`;
955
+ if (body.trim().length > 0)
956
+ out += `\n${body.trim()}\n`;
957
+ await this.storage.write(path, out);
958
+ }
959
+ /** "<base> (copy)" — bumping "(copy 2)…" against existing model titles. */
960
+ async uniqueModelName(base) {
961
+ const taken = new Set((await this.listModels()).map((m) => m.name.toLowerCase()));
962
+ let candidate = `${base} (copy)`;
963
+ let n = 2;
964
+ while (taken.has(candidate.toLowerCase()))
965
+ candidate = `${base} (copy ${n++})`;
966
+ return candidate;
967
+ }
968
+ /** A free folder `<parent>/<leaf>` (suffixing `-2/-3…` like createModel). */
969
+ async uniqueSiblingFolder(parent, leaf) {
970
+ let folder = joinPath(parent, leaf);
971
+ let n = 2;
972
+ while (await this.storage.exists(folder)) {
973
+ folder = joinPath(parent, `${leaf}-${n}`);
974
+ n++;
975
+ }
976
+ return folder;
977
+ }
978
+ }
979
+ // ---- pure helpers ----------------------------------------------------------
980
+ /** A copy of `v` with its id and every link target re-pointed through `idMap`. */
981
+ function remapVariableIds(v, idMap) {
982
+ return {
983
+ ...v,
984
+ id: idMap.get(v.id) ?? v.id,
985
+ links: v.links.map((l) => ({ ...l, to: idMap.get(l.to) ?? l.to })),
986
+ };
987
+ }
988
+ function makeLink(to, init) {
989
+ return {
990
+ to,
991
+ polarity: init.polarity === "-" ? "-" : "+",
992
+ delay: init.delay === true,
993
+ indirect: init.indirect === true,
994
+ nonlinear: init.nonlinear === true,
995
+ weight: init.weight,
996
+ curvature: init.curvature,
997
+ confidence: normalizeConfidence(init.confidence),
998
+ basis: normalizeBasis(init.basis),
999
+ };
1000
+ }
1001
+ function applyLinkPatch(l, patch) {
1002
+ return {
1003
+ ...l,
1004
+ polarity: patch.polarity ?? l.polarity,
1005
+ delay: patch.delay ?? l.delay,
1006
+ indirect: patch.indirect ?? l.indirect,
1007
+ nonlinear: patch.nonlinear ?? l.nonlinear,
1008
+ weight: patch.weight !== undefined ? patch.weight : l.weight,
1009
+ curvature: patch.curvature === undefined
1010
+ ? l.curvature
1011
+ : patch.curvature === null
1012
+ ? undefined
1013
+ : patch.curvature,
1014
+ confidence: patch.confidence !== undefined
1015
+ ? normalizeConfidence(patch.confidence)
1016
+ : l.confidence,
1017
+ basis: patch.basis !== undefined ? normalizeBasis(patch.basis) : l.basis,
1018
+ };
1019
+ }
1020
+ function layoutPositions(notes) {
1021
+ return autoLayout(notes.map((v) => ({ id: v.id, name: v.label, kind: v.type })), notes.flatMap((v) => v.links.map((l) => ({ source: v.id, target: l.to }))));
1022
+ }
1023
+ function applyLayout(notes) {
1024
+ const pos = layoutPositions(notes);
1025
+ for (const v of notes) {
1026
+ const p = pos.get(v.id);
1027
+ if (p) {
1028
+ v.x = p[0];
1029
+ v.y = p[1];
1030
+ }
1031
+ }
1032
+ }
1033
+ function toExportGraph(view) {
1034
+ const nodes = view.nodes.map((n) => ({
1035
+ id: n.id,
1036
+ name: n.label,
1037
+ kind: n.type,
1038
+ x: n.x,
1039
+ y: n.y,
1040
+ group: n.group,
1041
+ }));
1042
+ const edges = [];
1043
+ for (const n of view.nodes) {
1044
+ for (const l of n.links) {
1045
+ edges.push({
1046
+ id: `${n.id}__${l.to}`,
1047
+ source: n.id,
1048
+ target: l.to,
1049
+ polarity: l.polarity === "-" ? -1 : 1,
1050
+ delay: l.delay,
1051
+ curvature: l.curvature,
1052
+ dashed: l.indirect,
1053
+ weight: l.weight,
1054
+ });
1055
+ }
1056
+ }
1057
+ const nameById = new Map(view.nodes.map((n) => [n.id, n.label]));
1058
+ const loops = view.loops.map((lp) => ({
1059
+ loop: lp.nodeIds.map((id) => nameById.get(id) ?? id),
1060
+ type: lp.type === LoopType.reinforcing ? "R (reinforcing)" : "B (balancing)",
1061
+ label: view.labels.get(lp.key),
1062
+ }));
1063
+ return { nodes, edges, loops };
1064
+ }
1065
+ /** Re-exported from `./loopKey` so existing importers keep their path. */
1066
+ export { loopKey } from "./loopKey";
1067
+ /** Read the `loopNotes` string map out of a manifest's `extra` (defensive). */
1068
+ function readLoopNotes(m) {
1069
+ const raw = m.extra["loopNotes"];
1070
+ const out = {};
1071
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
1072
+ for (const [k, v] of Object.entries(raw)) {
1073
+ if (typeof v === "string")
1074
+ out[k] = v;
1075
+ }
1076
+ }
1077
+ return out;
1078
+ }
1079
+ function manifestIsQuant(m) {
1080
+ const e = m.extra;
1081
+ return (e["quantitative"] === true ||
1082
+ e["quant"] === true ||
1083
+ e["mode"] === "quantitative" ||
1084
+ e["kind"] === "quantitative");
1085
+ }
1086
+ function slug(s) {
1087
+ return s
1088
+ .toLowerCase()
1089
+ .replace(/[^a-z0-9]+/g, "-")
1090
+ .replace(/^-+|-+$/g, "");
1091
+ }
1092
+ function randomHex(bytes) {
1093
+ const arr = new Uint8Array(bytes);
1094
+ // Web Crypto is exposed as the global `crypto` in both Obsidian (Electron)
1095
+ // and Node (this pure engine's vitest tests). Referenced bare — not via
1096
+ // `window`/`globalThis` — so it resolves under Node too; `typeof`-guarded so
1097
+ // it falls back to Math.random anywhere it is unavailable.
1098
+ const c = typeof crypto !== "undefined" ? crypto : undefined;
1099
+ if (c && typeof c.getRandomValues === "function") {
1100
+ c.getRandomValues(arr);
1101
+ }
1102
+ else {
1103
+ for (let i = 0; i < bytes; i++)
1104
+ arr[i] = Math.floor(Math.random() * 256);
1105
+ }
1106
+ let s = "";
1107
+ for (const b of arr)
1108
+ s += b.toString(16).padStart(2, "0");
1109
+ return s;
1110
+ }
1111
+ function genVarId() {
1112
+ return `var_${randomHex(4)}`;
1113
+ }
1114
+ function genModelId() {
1115
+ const n = Date.now() * 1000 + Math.floor(Math.random() * 1000);
1116
+ const hex = n.toString(16);
1117
+ return `mdl_${hex.slice(-6)}`;
1118
+ }
1119
+ /** Re-export so callers don't need a second import for the signature helper. */
1120
+ export { contentSignature };