@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,32 @@
1
+ /**
2
+ * The resolved-map key for a feedback loop: `<R|B>:<sorted unique member
3
+ * labels>`. This is the key every UI consumer indexes loop notes by; it is NOT
4
+ * the loop's on-disk identity (that lives in each `Loops/*.md` frontmatter as
5
+ * the ordered member ids — see `loopNote.ts`). Mirrors Dart `fmt.loopNoteKey`.
6
+ *
7
+ * Pure and dependency-free so both the engine (`nativeEngine`) and the view
8
+ * (`view/loopKeys`) derive the key from one definition instead of each carrying
9
+ * its own copy.
10
+ */
11
+ export function loopKey(labels, type) {
12
+ const letter = type.toUpperCase().startsWith("R") ? "R" : "B";
13
+ const uniq = [...new Set(labels.map((l) => String(l)))].sort();
14
+ return `${letter}:${uniq.join("|")}`;
15
+ }
16
+ /**
17
+ * Human-readable, non-link echo for a loop note's `loop:` frontmatter field:
18
+ * `<R|B> · <sorted unique labels joined by " | ">`. Same membership rule as
19
+ * `loopKey` (dedupe + sort) but rendered for humans — it deliberately avoids a
20
+ * leading `scheme:` so Obsidian's property view does not show it as a (dead)
21
+ * external link. Display only; NEVER an identity/lookup/export/API key (that is
22
+ * `loopKey`). Mirrors Dart `fmt.loopEchoLabel`.
23
+ *
24
+ * The Set dedupe absorbs a closed-cycle duplicate, so no explicit `first==last`
25
+ * trim is needed. Keep this symmetric with the Dart twin: do not add a trim to
26
+ * only one side — divergent membership handling would break byte parity.
27
+ */
28
+ export function loopEchoLabel(labels, type) {
29
+ const letter = type.toUpperCase().startsWith("R") ? "R" : "B";
30
+ const uniq = [...new Set(labels.map((l) => String(l)))].sort();
31
+ return uniq.length === 0 ? letter : `${letter} · ${uniq.join(" | ")}`;
32
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * One feedback-loop note (`Loops/<slug>.md`): an Obsidian-native markdown file
3
+ * whose identity is the loop's (type, ordered member variable-ids) — held in
4
+ * frontmatter, NEVER derived from the filename or from variable labels. The body
5
+ * owns the narrative; `loop` is a human-readable echo only.
6
+ *
7
+ * TypeScript port of `core/lib/vault/loop_note.dart`. The serializer matches the
8
+ * Dart output byte-for-byte (field order, `members: []` empty form, raw
9
+ * `valence`, scalar quoting via the shared note codec) so a `Loops/*.md` file
10
+ * written by the plugin and by the app/CLI round-trips losslessly on one vault.
11
+ */
12
+ import { YamlParse } from "./noteCodec";
13
+ export interface LoopNote {
14
+ /** 'R' (reinforcing) or 'B' (balancing). */
15
+ type: string;
16
+ /** Canonical-ordered member variable ids (the stable identity). */
17
+ members: string[];
18
+ /** Free-text alias shown beside the R/B badge (was `loopTitles`). */
19
+ title: string;
20
+ /** 'virtuous' | 'vicious' | '' (was `loopValence`). */
21
+ valence: string;
22
+ /** Human-readable label echo (`R:Label|Label`). Non-authoritative. */
23
+ loopEcho: string;
24
+ body: string;
25
+ /** Unknown frontmatter keys, preserved verbatim (carries app-only `archetype`). */
26
+ extra: Record<string, unknown>;
27
+ /** True when frontmatter was present but unparseable (body still served). */
28
+ malformed: boolean;
29
+ }
30
+ export declare function emptyLoopNote(partial?: Partial<LoopNote>): LoopNote;
31
+ export declare function parseLoopNote(source: string, yaml: YamlParse): LoopNote;
32
+ export declare function serializeLoopNote(n: LoopNote): string;
33
+ /**
34
+ * Rotate a cycle's ids so it starts at its lexicographically smallest id. Loop
35
+ * identity is rotation-invariant but routing-sensitive (order matters), so two
36
+ * different routings through the same node set stay distinct.
37
+ */
38
+ export declare function canonicalLoopMembers(nodeIds: string[]): string[];
39
+ export declare function loopMatchesNote(type: string, nodeIds: string[], note: LoopNote): boolean;
40
+ export declare function loopSlug(type: string, title: string, memberLabels: string[]): string;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * One feedback-loop note (`Loops/<slug>.md`): an Obsidian-native markdown file
3
+ * whose identity is the loop's (type, ordered member variable-ids) — held in
4
+ * frontmatter, NEVER derived from the filename or from variable labels. The body
5
+ * owns the narrative; `loop` is a human-readable echo only.
6
+ *
7
+ * TypeScript port of `core/lib/vault/loop_note.dart`. The serializer matches the
8
+ * Dart output byte-for-byte (field order, `members: []` empty form, raw
9
+ * `valence`, scalar quoting via the shared note codec) so a `Loops/*.md` file
10
+ * written by the plugin and by the app/CLI round-trips losslessly on one vault.
11
+ */
12
+ import { emitExtra, scalar, splitFrontmatter } from "./noteCodec";
13
+ const FENCE = "---";
14
+ const KNOWN = new Set(["type", "members", "title", "valence", "loop"]);
15
+ export function emptyLoopNote(partial = {}) {
16
+ return {
17
+ type: "R",
18
+ members: [],
19
+ title: "",
20
+ valence: "",
21
+ loopEcho: "",
22
+ body: "",
23
+ extra: {},
24
+ malformed: false,
25
+ ...partial,
26
+ };
27
+ }
28
+ export function parseLoopNote(source, yaml) {
29
+ const [fm, body] = splitFrontmatter(source);
30
+ if (fm === null)
31
+ return emptyLoopNote({ body });
32
+ let node;
33
+ let malformed = false;
34
+ try {
35
+ node = yaml(fm);
36
+ }
37
+ catch {
38
+ malformed = true;
39
+ }
40
+ const isMap = node !== null && typeof node === "object" && !Array.isArray(node);
41
+ if (node !== null && node !== undefined && !isMap)
42
+ malformed = true;
43
+ const m = isMap ? node : {};
44
+ const type = String(m["type"] ?? "R").toUpperCase().startsWith("B") ? "B" : "R";
45
+ const members = [];
46
+ if (Array.isArray(m["members"])) {
47
+ for (const e of m["members"]) {
48
+ const s = String(e).trim();
49
+ if (s.length > 0)
50
+ members.push(s);
51
+ }
52
+ }
53
+ const valenceRaw = String(m["valence"] ?? "").trim().toLowerCase();
54
+ const extra = {};
55
+ for (const [k, v] of Object.entries(m))
56
+ if (!KNOWN.has(k))
57
+ extra[k] = v;
58
+ return {
59
+ type,
60
+ members,
61
+ title: String(m["title"] ?? "").trim(),
62
+ valence: valenceRaw === "virtuous" || valenceRaw === "vicious" ? valenceRaw : "",
63
+ loopEcho: String(m["loop"] ?? "").trim(),
64
+ body,
65
+ extra,
66
+ malformed,
67
+ };
68
+ }
69
+ export function serializeLoopNote(n) {
70
+ const lines = [FENCE];
71
+ lines.push(`type: ${n.type}`);
72
+ if (n.members.length === 0) {
73
+ lines.push("members: []");
74
+ }
75
+ else {
76
+ lines.push("members:");
77
+ for (const id of n.members)
78
+ lines.push(` - ${scalar(id)}`);
79
+ }
80
+ if (n.title.length > 0)
81
+ lines.push(`title: ${scalar(n.title)}`);
82
+ if (n.valence.length > 0)
83
+ lines.push(`valence: ${n.valence}`);
84
+ if (n.loopEcho.length > 0)
85
+ lines.push(`loop: ${scalar(n.loopEcho)}`);
86
+ for (const [k, v] of Object.entries(n.extra))
87
+ emitExtra(lines, k, v, 0);
88
+ lines.push(FENCE);
89
+ let out = lines.join("\n") + "\n";
90
+ if (n.body.trim().length > 0)
91
+ out += "\n" + n.body.trim() + "\n";
92
+ return out;
93
+ }
94
+ /**
95
+ * Rotate a cycle's ids so it starts at its lexicographically smallest id. Loop
96
+ * identity is rotation-invariant but routing-sensitive (order matters), so two
97
+ * different routings through the same node set stay distinct.
98
+ */
99
+ export function canonicalLoopMembers(nodeIds) {
100
+ if (nodeIds.length === 0)
101
+ return [];
102
+ let min = 0;
103
+ for (let i = 1; i < nodeIds.length; i++) {
104
+ if (nodeIds[i] < nodeIds[min])
105
+ min = i;
106
+ }
107
+ return [...nodeIds.slice(min), ...nodeIds.slice(0, min)];
108
+ }
109
+ export function loopMatchesNote(type, nodeIds, note) {
110
+ if (note.type !== type)
111
+ return false;
112
+ const a = canonicalLoopMembers(nodeIds);
113
+ const b = canonicalLoopMembers(note.members);
114
+ if (a.length !== b.length)
115
+ return false;
116
+ for (let i = 0; i < a.length; i++)
117
+ if (a[i] !== b[i])
118
+ return false;
119
+ return true;
120
+ }
121
+ const SLUG_STRIP = /[^a-z0-9]+/g;
122
+ export function loopSlug(type, title, memberLabels) {
123
+ const basis = title.trim().length > 0 ? title : memberLabels.join("-");
124
+ const body = basis
125
+ .toLowerCase()
126
+ .replace(SLUG_STRIP, "-")
127
+ .replace(/^-+|-+$/g, "");
128
+ const t = type.toUpperCase().startsWith("B") ? "b" : "r";
129
+ return body.length === 0 ? t : `${t}-${body}`;
130
+ }
@@ -0,0 +1,175 @@
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 { BuildSpec, ExportFormat, GraphView, LinkInit, LinkPatch, ModelRef, NeoloopyEngine, NewVariable, QuantPatch, VariablePatch } from "./engine";
23
+ import { VariableFile, Viewport } from "./types";
24
+ import { YamlParse } from "./noteCodec";
25
+ import { contentSignature } from "./specHash";
26
+ import { Rendered } from "./exporters";
27
+ import { VaultStorage } from "./storage";
28
+ import { ParentAnchor } from "./subsystemLinks";
29
+ export interface NativeEngineOptions {
30
+ /** Vault-relative base folder for newly created models (may be ""). */
31
+ modelsRoot: string;
32
+ }
33
+ export declare class NativeEngine implements NeoloopyEngine {
34
+ private readonly storage;
35
+ private readonly yaml;
36
+ private readonly opts;
37
+ constructor(storage: VaultStorage, yaml: YamlParse, opts: NativeEngineOptions);
38
+ listModels(): Promise<ModelRef[]>;
39
+ loadGraph(folder: string): Promise<GraphView>;
40
+ createModel(name: string): Promise<ModelRef>;
41
+ renameModel(folder: string, name: string): Promise<ModelRef>;
42
+ retitleModel(folder: string, name: string): Promise<ModelRef>;
43
+ /**
44
+ * Sync a node's label to its filename after the user renamed the file in the
45
+ * vault (`Nodes/<stem>.md`). The node-level inverse of {@link retitleModel}:
46
+ * the file is already where the user put it, so the label follows it (the
47
+ * de-slugged stem, underscores → spaces). {@link writeNote} may then
48
+ * re-normalize the on-disk name (spaces → underscores) so it converges. The
49
+ * stable `var_…` id and every link to it are untouched, so links never break.
50
+ */
51
+ relabelNodeFromFilename(folder: string, fileStem: string): Promise<void>;
52
+ /** Target folder for renaming `folder`'s model to `name`: the new slug under
53
+ * the same parent, suffixed on collision; unchanged when the slug matches. */
54
+ private destForRename;
55
+ deleteModel(folder: string): Promise<void>;
56
+ /** Resolve a folder to its model id (read from `model.json`). */
57
+ modelId(folder: string): Promise<string>;
58
+ /**
59
+ * Duplicate a model as a brand-new model: a byte-for-byte copy of the folder
60
+ * tree (so prose, layout, loop notes, and System note all come along), then a
61
+ * full re-key so the copy has a fresh model id, fresh variable ids, and fresh
62
+ * loop/System identity anchors. `shared` keys are PRESERVED, so the copy stays
63
+ * joined to the cross-model graph. Lands as a sibling of the source, titled
64
+ * "<name> (copy)" (bumping to "(copy 2)…" on a name clash).
65
+ */
66
+ duplicateModel(srcFolder: string): Promise<ModelRef>;
67
+ /**
68
+ * Heal model-id collisions: a raw Obsidian copy of a model (or a folder of
69
+ * models) clones their ids verbatim, so two folders end up claiming the same
70
+ * `mdl_…`. Group folders by id; for each colliding group keep the OLDEST (by
71
+ * `created`) and re-key every newer sibling. Returns one row per re-keyed
72
+ * folder. A no-op (empty result) when every id is already unique.
73
+ */
74
+ healDuplicateIds(): Promise<Array<{
75
+ folder: string;
76
+ oldId: string;
77
+ newId: string;
78
+ }>>;
79
+ addVariable(folder: string, init: NewVariable): Promise<VariableFile>;
80
+ updateVariable(folder: string, id: string, patch: VariablePatch): Promise<VariableFile>;
81
+ setEquation(folder: string, id: string, patch: QuantPatch): Promise<VariableFile>;
82
+ setSubsystem(folder: string, varId: string, child: {
83
+ folder: string;
84
+ name: string;
85
+ } | null): Promise<void>;
86
+ moveVariable(folder: string, id: string, x: number, y: number): Promise<void>;
87
+ removeVariable(folder: string, id: string): Promise<void>;
88
+ addLink(folder: string, from: string, to: string, init?: LinkInit): Promise<void>;
89
+ updateLink(folder: string, from: string, to: string, patch: LinkPatch): Promise<void>;
90
+ removeLink(folder: string, from: string, to: string): Promise<void>;
91
+ buildModel(folder: string, spec: BuildSpec): Promise<void>;
92
+ relayout(folder: string): Promise<void>;
93
+ setViewport(folder: string, viewport: Viewport): Promise<void>;
94
+ getLoopNotes(folder: string): Promise<Record<string, string>>;
95
+ setLoopNote(folder: string, key: string, text: string): Promise<void>;
96
+ /**
97
+ * The vault-relative path of the canonical `Loops/*.md` file for `key`,
98
+ * creating an empty-but-anchored note if none exists yet. Returns null when no
99
+ * live loop carries that key (e.g. the graph changed). Used by the canvas to
100
+ * open the real markdown file for editing.
101
+ */
102
+ loopNotePath(folder: string, key: string): Promise<string | null>;
103
+ ensureSystemNote(folder: string): Promise<string>;
104
+ deriveParents(folder: string): Promise<ParentAnchor[]>;
105
+ export(folder: string, format: ExportFormat): Promise<Rendered>;
106
+ /** The model's `Nodes/` subfolder, where variable notes now live. */
107
+ private nodesDir;
108
+ /** A variable note's path for a filename stem: `<folder>/Nodes/<stem>.md`. */
109
+ private notePath;
110
+ /** Pre-`Nodes/` location of a variable note, flat at the model root. */
111
+ private legacyNotePath;
112
+ private readNote;
113
+ private writeNote;
114
+ private readManifest;
115
+ private writeManifest;
116
+ /** Bump `model.json` modified after a content change. */
117
+ private touchManifest;
118
+ /**
119
+ * Variable-note id → vault path, gathered from the model's `Nodes/` subfolder
120
+ * and any legacy flat notes at the model root. A `Nodes/` note wins on a
121
+ * duplicate id (mirrors the Dart reader). `model.json` and the special notes
122
+ * (System/Futures/CLA) are excluded.
123
+ */
124
+ private noteFilesById;
125
+ private listNoteFiles;
126
+ private loadNotes;
127
+ private loopsDir;
128
+ /** Sorted `.md` basenames in `Loops/`; tolerant of a missing folder. */
129
+ private listLoopNoteFiles;
130
+ private readLoopNoteFile;
131
+ private writeLoopNoteFile;
132
+ private uniqueLoopFilename;
133
+ /**
134
+ * One-way, idempotent migration of legacy loop annotations from the
135
+ * `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
136
+ * exists the model is treated as migrated and this returns immediately. Each
137
+ * legacy key is matched to a live loop by its `loopKey`; a matched key keeps
138
+ * the loop's canonical member ids, an unmatched key becomes a pre-flagged
139
+ * orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
140
+ * After writing, the four legacy keys are stripped from the manifest.
141
+ */
142
+ private migrateLoopNotesIfNeeded;
143
+ /** Find the live loop whose `loopKey` == `key`, then upsert its file. No-op
144
+ * (and no file) when the graph carries no such loop. */
145
+ private upsertLoopFileByKey;
146
+ /** Find-or-create the identity-matched `Loops/` file and apply `fields`
147
+ * (undefined leaves a field unchanged; `archetype: ""` clears it). Returns the
148
+ * filename written. Title/valence/extra of an existing file are preserved. */
149
+ private writeLoopFile;
150
+ /** Recursively find folders containing a `model.json` (dot-folders pruned). */
151
+ private findModelFolders;
152
+ /**
153
+ * Re-key a model in place: assign a fresh model id, fresh variable ids, and
154
+ * re-point every internal reference (links, loop-note members, the System
155
+ * note's identity anchor). Structure, prose, layout, `shared` keys, and
156
+ * subsystem wikilinks are untouched. Returns the new model id. Used by both
157
+ * {@link duplicateModel} (on a fresh copy) and {@link healDuplicateIds} (on a
158
+ * collided clone).
159
+ */
160
+ private rekeyModel;
161
+ /** Recursively copy a folder tree byte-for-byte (the source is untouched). */
162
+ private copyTree;
163
+ /** Remove every variable note (flat root + `Nodes/`); keep the special notes. */
164
+ private removeAllVariableFiles;
165
+ /** Rewrite `System.md`'s `model:` identity anchor to `newId` (if it exists). */
166
+ private swapSystemModelId;
167
+ /** "<base> (copy)" — bumping "(copy 2)…" against existing model titles. */
168
+ private uniqueModelName;
169
+ /** A free folder `<parent>/<leaf>` (suffixing `-2/-3…` like createModel). */
170
+ private uniqueSiblingFolder;
171
+ }
172
+ /** Re-exported from `./loopKey` so existing importers keep their path. */
173
+ export { loopKey } from "./loopKey";
174
+ /** Re-export so callers don't need a second import for the signature helper. */
175
+ export { contentSignature };