@rubytech/create-maxy-code 0.1.132 → 0.1.140

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 (161) hide show
  1. package/dist/__tests__/installer-specialist-registration.test.js +5 -40
  2. package/dist/index.js +24 -28
  3. package/dist/specialist-registration.js +0 -15
  4. package/package.json +1 -1
  5. package/payload/platform/config/brand.json +1 -1
  6. package/payload/platform/lib/graph-search/dist/boosts.d.ts +39 -0
  7. package/payload/platform/lib/graph-search/dist/boosts.d.ts.map +1 -0
  8. package/payload/platform/lib/graph-search/dist/boosts.js +57 -0
  9. package/payload/platform/lib/graph-search/dist/boosts.js.map +1 -0
  10. package/payload/platform/lib/graph-search/dist/dedup.d.ts +29 -0
  11. package/payload/platform/lib/graph-search/dist/dedup.d.ts.map +1 -0
  12. package/payload/platform/lib/graph-search/dist/dedup.js +97 -0
  13. package/payload/platform/lib/graph-search/dist/dedup.js.map +1 -0
  14. package/payload/platform/lib/graph-search/dist/index.d.ts +70 -8
  15. package/payload/platform/lib/graph-search/dist/index.d.ts.map +1 -1
  16. package/payload/platform/lib/graph-search/dist/index.js +323 -62
  17. package/payload/platform/lib/graph-search/dist/index.js.map +1 -1
  18. package/payload/platform/lib/graph-search/dist/query-expansion.d.ts +37 -0
  19. package/payload/platform/lib/graph-search/dist/query-expansion.d.ts.map +1 -0
  20. package/payload/platform/lib/graph-search/dist/query-expansion.js +101 -0
  21. package/payload/platform/lib/graph-search/dist/query-expansion.js.map +1 -0
  22. package/payload/platform/lib/graph-search/dist/route.d.ts +29 -0
  23. package/payload/platform/lib/graph-search/dist/route.d.ts.map +1 -0
  24. package/payload/platform/lib/graph-search/dist/route.js +53 -0
  25. package/payload/platform/lib/graph-search/dist/route.js.map +1 -0
  26. package/payload/platform/lib/graph-search/dist/rrf-fusion.d.ts +31 -0
  27. package/payload/platform/lib/graph-search/dist/rrf-fusion.d.ts.map +1 -0
  28. package/payload/platform/lib/graph-search/dist/rrf-fusion.js +57 -0
  29. package/payload/platform/lib/graph-search/dist/rrf-fusion.js.map +1 -0
  30. package/payload/platform/lib/graph-search/src/__tests__/boosts.test.ts +59 -0
  31. package/payload/platform/lib/graph-search/src/__tests__/dedup.test.ts +83 -0
  32. package/payload/platform/lib/graph-search/src/__tests__/query-expansion.test.ts +96 -0
  33. package/payload/platform/lib/graph-search/src/__tests__/route.test.ts +62 -0
  34. package/payload/platform/lib/graph-search/src/__tests__/rrf-fusion.test.ts +39 -0
  35. package/payload/platform/lib/graph-search/src/boosts.ts +61 -0
  36. package/payload/platform/lib/graph-search/src/dedup.ts +108 -0
  37. package/payload/platform/lib/graph-search/src/index.ts +407 -65
  38. package/payload/platform/lib/graph-search/src/query-expansion.ts +129 -0
  39. package/payload/platform/lib/graph-search/src/route.ts +70 -0
  40. package/payload/platform/lib/graph-search/src/rrf-fusion.ts +62 -0
  41. package/payload/platform/lib/obsidian-parser/dist/index.d.ts +98 -0
  42. package/payload/platform/lib/obsidian-parser/dist/index.d.ts.map +1 -0
  43. package/payload/platform/lib/obsidian-parser/dist/index.js +480 -0
  44. package/payload/platform/lib/obsidian-parser/dist/index.js.map +1 -0
  45. package/payload/platform/lib/obsidian-parser/src/index.ts +572 -0
  46. package/payload/platform/lib/obsidian-parser/tsconfig.json +8 -0
  47. package/payload/platform/neo4j/schema.cypher +86 -1
  48. package/payload/platform/package.json +2 -2
  49. package/payload/platform/plugins/.claude-plugin/marketplace.json +10 -0
  50. package/payload/platform/plugins/admin/hooks/session-end-retrospective.sh +4 -2
  51. package/payload/platform/plugins/admin/mcp/dist/index.js +6 -4
  52. package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
  53. package/payload/platform/plugins/docs/references/internals.md +36 -10
  54. package/payload/platform/plugins/docs/references/neo4j.md +63 -0
  55. package/payload/platform/plugins/docs/references/plugins-guide.md +2 -0
  56. package/payload/platform/plugins/docs/references/session-retrospective.md +11 -4
  57. package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js +2 -2
  58. package/payload/platform/plugins/graph-viewer/mcp/dist/tools/graph-render.js.map +1 -1
  59. package/payload/platform/plugins/memory/PLUGIN.md +3 -0
  60. package/payload/platform/plugins/memory/mcp/dist/index.js +203 -7
  61. package/payload/platform/plugins/memory/mcp/dist/index.js.map +1 -1
  62. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.d.ts +2 -0
  63. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.d.ts.map +1 -0
  64. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.js +25 -0
  65. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/graph-write-gate-property.test.js.map +1 -0
  66. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.d.ts +2 -0
  67. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.d.ts.map +1 -0
  68. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js +70 -0
  69. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/timeline-extractor.test.js.map +1 -0
  70. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.d.ts +2 -0
  71. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.d.ts.map +1 -0
  72. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js +50 -0
  73. package/payload/platform/plugins/memory/mcp/dist/lib/__tests__/typed-edge-schema.test.js.map +1 -0
  74. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts +33 -0
  75. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.d.ts.map +1 -0
  76. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js +99 -0
  77. package/payload/platform/plugins/memory/mcp/dist/lib/compiled-truth-rewriter.js.map +1 -0
  78. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts +21 -0
  79. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.d.ts.map +1 -1
  80. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js +24 -0
  81. package/payload/platform/plugins/memory/mcp/dist/lib/graph-write-gate.js.map +1 -1
  82. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts +13 -0
  83. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.d.ts.map +1 -0
  84. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js +151 -0
  85. package/payload/platform/plugins/memory/mcp/dist/lib/timeline-extractor.js.map +1 -0
  86. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.d.ts +55 -0
  87. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.d.ts.map +1 -0
  88. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.js +290 -0
  89. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-pass.js.map +1 -0
  90. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts +19 -0
  91. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.d.ts.map +1 -0
  92. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js +102 -0
  93. package/payload/platform/plugins/memory/mcp/dist/lib/typed-edge-schema.js.map +1 -0
  94. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js +18 -1
  95. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-search-fields.test.js.map +1 -1
  96. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js +5 -0
  97. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-update-emit.test.js.map +1 -1
  98. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js +2 -0
  99. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/memory-write-emit.test.js.map +1 -1
  100. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts +64 -1
  101. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.d.ts.map +1 -1
  102. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js +258 -0
  103. package/payload/platform/plugins/memory/mcp/dist/tools/memory-archive-write.js.map +1 -1
  104. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.d.ts.map +1 -1
  105. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js +14 -0
  106. package/payload/platform/plugins/memory/mcp/dist/tools/memory-search.js.map +1 -1
  107. package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.d.ts +29 -0
  108. package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.d.ts.map +1 -0
  109. package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.js +22 -0
  110. package/payload/platform/plugins/memory/mcp/dist/tools/memory-typed-edge-pass.js.map +1 -0
  111. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts +31 -1
  112. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.d.ts.map +1 -1
  113. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js +182 -50
  114. package/payload/platform/plugins/memory/mcp/dist/tools/memory-update.js.map +1 -1
  115. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.d.ts.map +1 -1
  116. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js +76 -1
  117. package/payload/platform/plugins/memory/mcp/dist/tools/memory-write.js.map +1 -1
  118. package/payload/platform/plugins/memory/mcp/dist/tools/obsidian-vault-import.d.ts +127 -0
  119. package/payload/platform/plugins/memory/mcp/dist/tools/obsidian-vault-import.d.ts.map +1 -0
  120. package/payload/platform/plugins/memory/mcp/dist/tools/obsidian-vault-import.js +477 -0
  121. package/payload/platform/plugins/memory/mcp/dist/tools/obsidian-vault-import.js.map +1 -0
  122. package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.d.ts +30 -0
  123. package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.d.ts.map +1 -0
  124. package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.js +129 -0
  125. package/payload/platform/plugins/memory/mcp/dist/tools/session-retrospective-skip-rate.js.map +1 -0
  126. package/payload/platform/plugins/memory/mcp/scripts/backfill-typed-edges.ts +72 -0
  127. package/payload/platform/plugins/memory/mcp/scripts/generate-edge-docs.ts +75 -0
  128. package/payload/platform/plugins/memory/mcp/vitest.config.ts +5 -0
  129. package/payload/platform/plugins/memory/references/schema-base.md +44 -0
  130. package/payload/platform/plugins/notion-import/.claude-plugin/plugin.json +8 -0
  131. package/payload/platform/plugins/notion-import/PLUGIN.md +27 -0
  132. package/payload/platform/plugins/notion-import/skills/notion-import/SKILL.md +110 -0
  133. package/payload/platform/plugins/notion-import/skills/notion-import/references/attachments.md +55 -0
  134. package/payload/platform/plugins/notion-import/skills/notion-import/references/databases.md +81 -0
  135. package/payload/platform/plugins/notion-import/skills/notion-import/references/page-tree.md +61 -0
  136. package/payload/platform/plugins/notion-import/skills/notion-import/references/workspace-export.md +41 -0
  137. package/payload/platform/plugins/obsidian-import/.claude-plugin/plugin.json +8 -0
  138. package/payload/platform/plugins/obsidian-import/PLUGIN.md +39 -0
  139. package/payload/platform/plugins/obsidian-import/skills/obsidian-import/SKILL.md +92 -0
  140. package/payload/platform/plugins/obsidian-import/skills/obsidian-import/references/attachments.md +80 -0
  141. package/payload/platform/plugins/obsidian-import/skills/obsidian-import/references/daily-notes.md +31 -0
  142. package/payload/platform/plugins/obsidian-import/skills/obsidian-import/references/vault-structure.md +46 -0
  143. package/payload/platform/plugins/obsidian-import/skills/obsidian-import/references/wikilinks.md +70 -0
  144. package/payload/platform/scripts/seed-neo4j.sh +49 -0
  145. package/payload/platform/templates/agents/admin/IDENTITY.md +4 -3
  146. package/payload/platform/templates/specialists/agents/database-operator.md +21 -1
  147. package/payload/server/{adminuser-self-heal-QAWOZ3JV.js → adminuser-self-heal-YC47O34W.js} +2 -1
  148. package/payload/server/{chunk-JSBRDJBE.js → chunk-HYQNUVGO.js} +9 -1
  149. package/payload/server/{chunk-HCYM5FLU.js → chunk-T3DJD5QR.js} +3 -1
  150. package/payload/server/maxy-edge.js +3 -2
  151. package/payload/server/public/assets/{admin-FcRHAL-3.js → admin-BM9aXUUh.js} +1 -1
  152. package/payload/server/public/assets/{data-Ds37mflX.js → data-Ds2ECtfP.js} +1 -1
  153. package/payload/server/public/assets/{graph-CmWRhaiS.js → graph-BzxObKMM.js} +1 -1
  154. package/payload/server/public/assets/{graph-labels-Ch2r00Gt.js → graph-labels-Dwgep_nr.js} +1 -1
  155. package/payload/server/public/assets/{page-BcHhJXUt.js → page-BlkBsjP2.js} +1 -1
  156. package/payload/server/public/assets/page-h_SA5SVQ.js +51 -0
  157. package/payload/server/public/data.html +3 -3
  158. package/payload/server/public/graph.html +3 -3
  159. package/payload/server/public/index.html +4 -4
  160. package/payload/server/server.js +803 -76
  161. package/payload/server/public/assets/page-BOtNny_4.js +0 -51
@@ -0,0 +1,572 @@
1
+ /**
2
+ * obsidian-parser — pure functions for parsing an Obsidian vault on disk.
3
+ *
4
+ * No DB access, no MCP, no I/O side effects other than fs reads. Composed by
5
+ * the `obsidian-vault-import` MCP tool in the memory plugin.
6
+ *
7
+ * Doctrine: every regex is linear-time (no catastrophic backtracking), every
8
+ * function is deterministic. The agent never calls this code; the server does.
9
+ */
10
+
11
+ import { createHash } from "node:crypto";
12
+ import { promises as fs } from "node:fs";
13
+ import * as path from "node:path";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Types
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface ParsedPage {
20
+ /** Path relative to the vault root, with forward slashes. The MERGE natural key. */
21
+ obsidianPath: string;
22
+ /** Filename without `.md` — the default wikilink target form. */
23
+ title: string;
24
+ /** Aliases declared in frontmatter (`aliases:` array). */
25
+ aliases: string[];
26
+ /** Tag tokens (without `#`), unioned from frontmatter `tags:` and inline `#tag` body matches. */
27
+ tags: string[];
28
+ /** Body text (frontmatter stripped). Stored verbatim. */
29
+ body: string;
30
+ /** SHA-256 of the on-disk file bytes — idempotency key. */
31
+ contentHash: string;
32
+ /** Detected daily-note date (YYYY-MM-DD) or null. */
33
+ noteDate: string | null;
34
+ /** Wikilinks emitted by this page. */
35
+ wikilinks: WikilinkOccurrence[];
36
+ /** Embedded attachments declared via `![[file.png]]` or `![alt](file.png)`. */
37
+ attachments: AttachmentOccurrence[];
38
+ /** Last-modified timestamp from fs stat — used for modified-since filtering. */
39
+ mtimeMs: number;
40
+ }
41
+
42
+ export interface WikilinkOccurrence {
43
+ /** The exact text inside `[[…]]` before any alias `|`. May contain `#anchor`. */
44
+ rawTarget: string;
45
+ /** Target after stripping `#anchor`. */
46
+ target: string;
47
+ /** Anchor portion (after `#`) or null. */
48
+ anchor: string | null;
49
+ /** Alias text after `|`, or null when absent. The display text. */
50
+ alias: string | null;
51
+ }
52
+
53
+ export interface AttachmentOccurrence {
54
+ /** Path as written in the markdown. May be relative to the page or to the vault root. */
55
+ rawPath: string;
56
+ /** Resolved path relative to vault root, normalised. May not exist on disk yet. */
57
+ resolvedPath: string;
58
+ /** Display alt text (from `![alt](path)` form) or null for embed-syntax `![[file]]`. */
59
+ alt: string | null;
60
+ }
61
+
62
+ export interface VaultWalkOptions {
63
+ /** Filter: only pages whose path starts with one of these folder prefixes. */
64
+ folderPrefixes?: string[];
65
+ /** Filter: only pages whose tags intersect with this set. */
66
+ tagFilter?: string[];
67
+ /** Filter: only pages modified at or after this epoch-ms. */
68
+ modifiedSinceMs?: number;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Vault walk
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /**
76
+ * Walk the vault directory and return one ParsedPage per `.md` file that
77
+ * survives the optional filter set.
78
+ *
79
+ * Path-traversal defence: paths containing `..` segments are rejected at
80
+ * entry. The `vaultRoot` passed in is assumed to have already been verified
81
+ * by the caller as living under the account's archive directory.
82
+ */
83
+ export async function walkVault(
84
+ vaultRoot: string,
85
+ opts: VaultWalkOptions = {},
86
+ ): Promise<ParsedPage[]> {
87
+ const pages: ParsedPage[] = [];
88
+ const queue: string[] = [vaultRoot];
89
+
90
+ while (queue.length > 0) {
91
+ const dir = queue.shift()!;
92
+ let entries;
93
+ try {
94
+ entries = await fs.readdir(dir, { withFileTypes: true });
95
+ } catch (err) {
96
+ throw new Error(
97
+ `obsidian-parser: cannot read directory ${dir}: ${
98
+ err instanceof Error ? err.message : String(err)
99
+ }`,
100
+ );
101
+ }
102
+ for (const entry of entries) {
103
+ if (entry.name.startsWith(".")) continue;
104
+ const abs = path.join(dir, entry.name);
105
+ if (entry.isDirectory()) {
106
+ queue.push(abs);
107
+ continue;
108
+ }
109
+ if (!entry.isFile()) continue;
110
+ if (!entry.name.toLowerCase().endsWith(".md")) continue;
111
+
112
+ const rel = toPosixRelative(vaultRoot, abs);
113
+ if (rel.split("/").includes("..")) continue;
114
+
115
+ if (opts.folderPrefixes && opts.folderPrefixes.length > 0) {
116
+ const matchesPrefix = opts.folderPrefixes.some((p) =>
117
+ rel === p || rel.startsWith(p.replace(/\/?$/, "/")),
118
+ );
119
+ if (!matchesPrefix) continue;
120
+ }
121
+
122
+ let stat;
123
+ let bytes;
124
+ try {
125
+ stat = await fs.stat(abs);
126
+ bytes = await fs.readFile(abs);
127
+ } catch (err) {
128
+ // Per-file isolation — log and skip, don't abort the walk.
129
+ process.stderr.write(
130
+ `[obsidian-parser] event=read-failed file=${rel} reason=${
131
+ err instanceof Error ? err.message : String(err)
132
+ }\n`,
133
+ );
134
+ continue;
135
+ }
136
+
137
+ if (opts.modifiedSinceMs && stat.mtimeMs < opts.modifiedSinceMs) continue;
138
+
139
+ let parsed: ParsedPage;
140
+ try {
141
+ parsed = parsePage(rel, bytes, stat.mtimeMs);
142
+ } catch (err) {
143
+ process.stderr.write(
144
+ `[obsidian-parser] event=parse-failed file=${rel} reason=${
145
+ err instanceof Error ? err.message : String(err)
146
+ }\n`,
147
+ );
148
+ continue;
149
+ }
150
+
151
+ if (opts.tagFilter && opts.tagFilter.length > 0) {
152
+ const wanted = new Set(opts.tagFilter.map(normalizeTag));
153
+ const hit = parsed.tags.some((t) => wanted.has(normalizeTag(t)));
154
+ if (!hit) continue;
155
+ }
156
+
157
+ pages.push(parsed);
158
+ }
159
+ }
160
+
161
+ pages.sort((a, b) => a.obsidianPath.localeCompare(b.obsidianPath));
162
+ return pages;
163
+ }
164
+
165
+ function toPosixRelative(root: string, abs: string): string {
166
+ const rel = path.relative(root, abs);
167
+ return rel.split(path.sep).join("/");
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Page parser
172
+ // ---------------------------------------------------------------------------
173
+
174
+ const DAILY_NOTE_FILENAME_RE = /^(\d{4})-(\d{2})-(\d{2})(?:\.md)?$/;
175
+ const DAILY_NOTE_FOLDER_NAMES = new Set([
176
+ "daily",
177
+ "daily-notes",
178
+ "daily notes",
179
+ "journal",
180
+ "journals",
181
+ ]);
182
+
183
+ export function parsePage(
184
+ obsidianPath: string,
185
+ bytes: Buffer,
186
+ mtimeMs: number,
187
+ ): ParsedPage {
188
+ const contentHash = createHash("sha256").update(bytes).digest("hex");
189
+ const text = bytes.toString("utf8");
190
+
191
+ const { frontmatter, body } = splitFrontmatter(text);
192
+
193
+ const title = path
194
+ .basename(obsidianPath)
195
+ .replace(/\.md$/i, "")
196
+ .normalize("NFC");
197
+
198
+ const aliases = extractAliases(frontmatter);
199
+ const frontmatterTags = extractFrontmatterTags(frontmatter);
200
+ const { bodyTags, wikilinks, attachments } = scanBody(body, obsidianPath);
201
+ const tags = dedupeNormalized([...frontmatterTags, ...bodyTags]);
202
+ const noteDate = detectDailyNote(obsidianPath, frontmatter);
203
+
204
+ return {
205
+ obsidianPath,
206
+ title,
207
+ aliases: aliases.map((a) => a.normalize("NFC")),
208
+ tags,
209
+ body,
210
+ contentHash,
211
+ noteDate,
212
+ wikilinks,
213
+ attachments,
214
+ mtimeMs,
215
+ };
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Frontmatter
220
+ // ---------------------------------------------------------------------------
221
+
222
+ interface ParsedFrontmatter {
223
+ raw: string;
224
+ /** Map of top-level key → raw value string. Arrays are returned as the
225
+ * block following the key (caller parses per-key). */
226
+ fields: Map<string, string>;
227
+ }
228
+
229
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
230
+
231
+ export function splitFrontmatter(
232
+ text: string,
233
+ ): { frontmatter: ParsedFrontmatter; body: string } {
234
+ const match = text.match(FRONTMATTER_RE);
235
+ if (!match) {
236
+ return { frontmatter: { raw: "", fields: new Map() }, body: text };
237
+ }
238
+ const raw = match[1];
239
+ const body = text.slice(match[0].length);
240
+
241
+ // Minimal YAML-subset parser: top-level `key: value` and `key:` followed by
242
+ // ` - item` lines. Not a full YAML — we explicitly limit the surface so a
243
+ // malformed frontmatter degrades to "no fields found" rather than throwing.
244
+ const fields = new Map<string, string>();
245
+ const lines = raw.split(/\r?\n/);
246
+ let currentKey: string | null = null;
247
+ let currentBlock: string[] = [];
248
+
249
+ for (const line of lines) {
250
+ if (/^[#]/.test(line.trim())) continue; // comment
251
+ const flush = () => {
252
+ if (currentKey !== null) {
253
+ const existing = fields.get(currentKey);
254
+ const next = currentBlock.join("\n");
255
+ fields.set(currentKey, existing ? existing + "\n" + next : next);
256
+ }
257
+ currentKey = null;
258
+ currentBlock = [];
259
+ };
260
+
261
+ const topMatch = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
262
+ if (topMatch && !line.startsWith(" ") && !line.startsWith("\t")) {
263
+ flush();
264
+ currentKey = topMatch[1];
265
+ const value = topMatch[2];
266
+ if (value.trim() === "") {
267
+ currentBlock = [];
268
+ } else {
269
+ currentBlock = [value];
270
+ flush();
271
+ }
272
+ continue;
273
+ }
274
+ if (currentKey !== null) {
275
+ currentBlock.push(line);
276
+ }
277
+ }
278
+ if (currentKey !== null) {
279
+ const existing = fields.get(currentKey);
280
+ const next = currentBlock.join("\n");
281
+ fields.set(currentKey, existing ? existing + "\n" + next : next);
282
+ }
283
+
284
+ return { frontmatter: { raw, fields }, body };
285
+ }
286
+
287
+ function extractAliases(fm: ParsedFrontmatter): string[] {
288
+ return parseYamlList(fm.fields.get("aliases"));
289
+ }
290
+
291
+ function extractFrontmatterTags(fm: ParsedFrontmatter): string[] {
292
+ return parseYamlList(fm.fields.get("tags")).map(normalizeTag);
293
+ }
294
+
295
+ function parseYamlList(value: string | undefined): string[] {
296
+ if (!value) return [];
297
+ const trimmed = value.trim();
298
+ if (!trimmed) return [];
299
+ // Flow-style: [a, b, c]
300
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
301
+ return trimmed
302
+ .slice(1, -1)
303
+ .split(",")
304
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
305
+ .filter(Boolean);
306
+ }
307
+ // Block-style: - item per line, or single scalar
308
+ const lines = trimmed.split(/\r?\n/);
309
+ if (lines.length === 1 && !lines[0].startsWith("-")) {
310
+ return [lines[0].replace(/^["']|["']$/g, "").trim()].filter(Boolean);
311
+ }
312
+ const out: string[] = [];
313
+ for (const line of lines) {
314
+ const m = line.match(/^\s*-\s*(.*?)\s*$/);
315
+ if (m) {
316
+ const v = m[1].replace(/^["']|["']$/g, "").trim();
317
+ if (v) out.push(v);
318
+ }
319
+ }
320
+ return out;
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Body scan: wikilinks, tags, attachments — all code-fence-aware
325
+ // ---------------------------------------------------------------------------
326
+
327
+ const WIKILINK_RE = /\[\[([^\]\r\n]+)\]\]/g;
328
+ const EMBED_RE = /!\[\[([^\]\r\n]+)\]\]/g;
329
+ const IMAGE_RE = /!\[([^\]\r\n]*)\]\(([^)\r\n]+)\)/g;
330
+ const INLINE_TAG_RE = /(^|[\s(])#([A-Za-z][\w/-]*)/g;
331
+ const ATTACHMENT_EXT_RE = /\.(png|jpe?g|gif|webp|svg|pdf|mp3|mp4|mov|wav|m4a|webm)$/i;
332
+
333
+ interface BodyScan {
334
+ bodyTags: string[];
335
+ wikilinks: WikilinkOccurrence[];
336
+ attachments: AttachmentOccurrence[];
337
+ }
338
+
339
+ function scanBody(body: string, obsidianPath: string): BodyScan {
340
+ const bodyTags: string[] = [];
341
+ const wikilinks: WikilinkOccurrence[] = [];
342
+ const attachments: AttachmentOccurrence[] = [];
343
+
344
+ for (const segment of splitOnCodeFences(body)) {
345
+ if (segment.isCode) continue;
346
+ const text = segment.text;
347
+
348
+ // Embeds first — they share `[[…]]` with wikilinks but the `!` prefix
349
+ // routes them to attachments.
350
+ const embedSpans: Array<[number, number]> = [];
351
+ for (const m of text.matchAll(EMBED_RE)) {
352
+ const inner = m[1].trim();
353
+ const resolved = resolveAttachmentPath(inner, obsidianPath);
354
+ if (resolved && ATTACHMENT_EXT_RE.test(resolved)) {
355
+ attachments.push({ rawPath: inner, resolvedPath: resolved, alt: null });
356
+ } else {
357
+ // Non-attachment embed (e.g. another note transclusion). Treat as a
358
+ // wikilink so the cross-reference is preserved.
359
+ wikilinks.push(parseWikilinkInner(inner));
360
+ }
361
+ embedSpans.push([m.index ?? 0, (m.index ?? 0) + m[0].length]);
362
+ }
363
+
364
+ for (const m of text.matchAll(WIKILINK_RE)) {
365
+ const start = m.index ?? 0;
366
+ if (embedSpans.some(([s, e]) => start >= s - 1 && start < e)) continue;
367
+ wikilinks.push(parseWikilinkInner(m[1]));
368
+ }
369
+
370
+ for (const m of text.matchAll(IMAGE_RE)) {
371
+ const rawPath = m[2].trim();
372
+ const alt = m[1].trim() || null;
373
+ if (/^https?:\/\//i.test(rawPath)) continue; // remote
374
+ const resolved = resolveAttachmentPath(rawPath, obsidianPath);
375
+ if (resolved && ATTACHMENT_EXT_RE.test(resolved)) {
376
+ attachments.push({ rawPath, resolvedPath: resolved, alt });
377
+ }
378
+ }
379
+
380
+ for (const m of text.matchAll(INLINE_TAG_RE)) {
381
+ bodyTags.push(normalizeTag(m[2]));
382
+ }
383
+ }
384
+
385
+ return {
386
+ bodyTags: dedupeNormalized(bodyTags),
387
+ wikilinks,
388
+ attachments,
389
+ };
390
+ }
391
+
392
+ function parseWikilinkInner(inner: string): WikilinkOccurrence {
393
+ const trimmed = inner.trim();
394
+ const aliasIdx = trimmed.indexOf("|");
395
+ let raw = trimmed;
396
+ let alias: string | null = null;
397
+ if (aliasIdx >= 0) {
398
+ raw = trimmed.slice(0, aliasIdx).trim();
399
+ alias = trimmed.slice(aliasIdx + 1).trim() || null;
400
+ }
401
+ const anchorIdx = raw.indexOf("#");
402
+ let target = raw;
403
+ let anchor: string | null = null;
404
+ if (anchorIdx >= 0) {
405
+ target = raw.slice(0, anchorIdx).trim();
406
+ anchor = raw.slice(anchorIdx + 1).trim() || null;
407
+ }
408
+ return { rawTarget: raw, target: target.normalize("NFC"), anchor, alias };
409
+ }
410
+
411
+ function resolveAttachmentPath(
412
+ rawPath: string,
413
+ obsidianPath: string,
414
+ ): string | null {
415
+ if (!rawPath) return null;
416
+ if (rawPath.startsWith("/") || /^[A-Za-z]:[\\/]/.test(rawPath)) return null;
417
+ if (rawPath.split("/").includes("..")) return null;
418
+ // Obsidian's default: vault-root relative. We also accept page-relative.
419
+ // For the resolved form we prefer vault-root-relative (more deterministic
420
+ // for MERGE), normalising path separators.
421
+ const norm = rawPath.replace(/\\/g, "/");
422
+ if (norm.includes("/")) return norm;
423
+ // Bare filename — could be in any attachments folder. Caller's index does
424
+ // the lookup; we record the bare form.
425
+ return norm;
426
+ }
427
+
428
+ function splitOnCodeFences(text: string): Array<{ text: string; isCode: boolean }> {
429
+ const out: Array<{ text: string; isCode: boolean }> = [];
430
+ const fenceRe = /(^|\n)(```|~~~)[^\n]*\n([\s\S]*?)\n(```|~~~)/g;
431
+ let lastIdx = 0;
432
+ for (const m of text.matchAll(fenceRe)) {
433
+ const start = (m.index ?? 0) + (m[1] ? m[1].length : 0);
434
+ out.push({ text: text.slice(lastIdx, start), isCode: false });
435
+ out.push({ text: m[0], isCode: true });
436
+ lastIdx = start + m[0].length - (m[1] ? m[1].length : 0);
437
+ }
438
+ out.push({ text: text.slice(lastIdx), isCode: false });
439
+ return out;
440
+ }
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // Tag normalization
444
+ // ---------------------------------------------------------------------------
445
+
446
+ export function normalizeTag(tag: string): string {
447
+ return tag
448
+ .replace(/^#/, "")
449
+ .normalize("NFC")
450
+ .trim()
451
+ .toLowerCase();
452
+ }
453
+
454
+ function dedupeNormalized(values: string[]): string[] {
455
+ const seen = new Set<string>();
456
+ const out: string[] = [];
457
+ for (const v of values) {
458
+ const k = v.normalize("NFC").trim().toLowerCase();
459
+ if (!k) continue;
460
+ if (seen.has(k)) continue;
461
+ seen.add(k);
462
+ out.push(v);
463
+ }
464
+ return out;
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Daily-notes detection
469
+ // ---------------------------------------------------------------------------
470
+
471
+ export function detectDailyNote(
472
+ obsidianPath: string,
473
+ fm: ParsedFrontmatter,
474
+ ): string | null {
475
+ // 1. Frontmatter `date:` or `created:` wins if it's an ISO date.
476
+ for (const key of ["date", "created"]) {
477
+ const v = fm.fields.get(key);
478
+ if (v) {
479
+ const m = v.trim().match(/^(\d{4}-\d{2}-\d{2})/);
480
+ if (m) return m[1];
481
+ }
482
+ }
483
+ // 2. Filename matches YYYY-MM-DD.md.
484
+ const basename = path.basename(obsidianPath);
485
+ const fileMatch = basename.match(DAILY_NOTE_FILENAME_RE);
486
+ if (fileMatch) return `${fileMatch[1]}-${fileMatch[2]}-${fileMatch[3]}`;
487
+
488
+ // 3. Filename is YYYY-MM-DD inside a known daily-notes folder.
489
+ const parts = obsidianPath.split("/");
490
+ const inDailyFolder = parts
491
+ .slice(0, -1)
492
+ .some((p) => DAILY_NOTE_FOLDER_NAMES.has(p.toLowerCase()));
493
+ if (inDailyFolder) {
494
+ const stem = basename.replace(/\.md$/i, "");
495
+ const m = stem.match(/^(\d{4})-(\d{2})-(\d{2})$/);
496
+ if (m) return `${m[1]}-${m[2]}-${m[3]}`;
497
+ }
498
+ return null;
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // Wikilink resolution helpers
503
+ // ---------------------------------------------------------------------------
504
+
505
+ export interface VaultIndex {
506
+ /** Lowercased title (without `.md`) → set of `obsidianPath` candidates. */
507
+ byTitle: Map<string, string[]>;
508
+ /** Lowercased alias → set of `obsidianPath` candidates. */
509
+ byAlias: Map<string, string[]>;
510
+ }
511
+
512
+ export function buildVaultIndex(pages: ParsedPage[]): VaultIndex {
513
+ const byTitle = new Map<string, string[]>();
514
+ const byAlias = new Map<string, string[]>();
515
+ for (const p of pages) {
516
+ pushKey(byTitle, p.title.toLowerCase(), p.obsidianPath);
517
+ for (const a of p.aliases) {
518
+ pushKey(byAlias, a.toLowerCase(), p.obsidianPath);
519
+ }
520
+ }
521
+ return { byTitle, byAlias };
522
+ }
523
+
524
+ function pushKey(m: Map<string, string[]>, k: string, v: string): void {
525
+ const arr = m.get(k);
526
+ if (arr) {
527
+ if (!arr.includes(v)) arr.push(v);
528
+ } else {
529
+ m.set(k, [v]);
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Look up a wikilink target inside the vault, returning candidate page paths.
535
+ *
536
+ * The target is matched against (a) page titles and (b) declared aliases.
537
+ * Path-form targets (`Folder/Page`) match directly. Returns an empty array
538
+ * when no intra-vault candidate exists — the caller falls through to entity
539
+ * resolution.
540
+ */
541
+ export function resolveWikilinkInVault(
542
+ target: string,
543
+ index: VaultIndex,
544
+ knownPaths: Set<string>,
545
+ ): string[] {
546
+ const t = target.normalize("NFC").trim();
547
+ if (!t) return [];
548
+ // Path-form: contains a slash or ends with `.md`.
549
+ if (t.includes("/") || t.toLowerCase().endsWith(".md")) {
550
+ const norm = t.replace(/\\/g, "/").replace(/\.md$/i, "") + ".md";
551
+ if (knownPaths.has(norm)) return [norm];
552
+ return [];
553
+ }
554
+ const titleHits = index.byTitle.get(t.toLowerCase()) ?? [];
555
+ const aliasHits = index.byAlias.get(t.toLowerCase()) ?? [];
556
+ const seen = new Set<string>();
557
+ const out: string[] = [];
558
+ for (const p of [...titleHits, ...aliasHits]) {
559
+ if (seen.has(p)) continue;
560
+ seen.add(p);
561
+ out.push(p);
562
+ }
563
+ return out;
564
+ }
565
+
566
+ // ---------------------------------------------------------------------------
567
+ // Import-id helper
568
+ // ---------------------------------------------------------------------------
569
+
570
+ export function makeImportId(): string {
571
+ return `obs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
572
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -2,6 +2,14 @@
2
2
  // Maxy Phase 0 — Neo4j Graph Schema
3
3
  // Schema.org compliant node types
4
4
  // ============================================================
5
+ //
6
+ // This file declares INDEXES and CONSTRAINTS only. The legal
7
+ // (sourceLabel, EDGE_TYPE, targetLabel) triples for the typed-edge
8
+ // auto-extraction pass (Task 305) live in
9
+ // `platform/plugins/memory/mcp/src/lib/typed-edge-schema.ts`
10
+ // as the TYPED_EDGE_ALLOWLIST constant. The Haiku prompt, the inline
11
+ // validator, and the public docs are all generated from that one
12
+ // constant; do not duplicate the enumeration here.
5
13
 
6
14
  // ----------------------------------------------------------
7
15
  // Constraints
@@ -228,6 +236,12 @@ FOR (k:KnowledgeDocument) ON (k.accountId);
228
236
  CREATE INDEX knowledge_doc_source_url IF NOT EXISTS
229
237
  FOR (k:KnowledgeDocument) ON (k.sourceUrl);
230
238
 
239
+ // obsidianPath — natural key for vault pages MERGEd by the obsidian-import
240
+ // plugin. Per-account by composing accountId in the MERGE pattern; the index
241
+ // here speeds up the lookup on re-imports for the idempotency check.
242
+ CREATE INDEX knowledge_doc_obsidian_path IF NOT EXISTS
243
+ FOR (k:KnowledgeDocument) ON (k.obsidianPath);
244
+
231
245
  CREATE VECTOR INDEX knowledge_doc_embedding IF NOT EXISTS
232
246
  FOR (k:KnowledgeDocument) ON (k.embedding)
233
247
  OPTIONS {
@@ -361,7 +375,12 @@ ON EACH [n.name, n.firstName, n.lastName, n.givenName, n.familyName,
361
375
  n.email, n.note, n.label, n.value, n.message, n.preview, n.tagline,
362
376
  n.subject, n.bodyPreview, n.fromName, n.fromAddress, n.agentAddress, n.screeningReason,
363
377
  n.authority, n.contactValue, n.toolName,
364
- n.displayName, n.slug, n.role];
378
+ n.displayName, n.slug, n.role,
379
+ n.compiledTruth];
380
+ // `compiledTruth` (Task 306) is the authoritative-summary property the
381
+ // compiled-truth + timeline knowledge model writes on Person/Company/Concept.
382
+ // Indexed here so Task 308's compiledTruth boost path also benefits from BM25
383
+ // hits against the summary text. No-op until 306 writes the property.
365
384
 
366
385
  // Project node — a standalone creative-output node distinct from
367
386
  // :Section. Anchored via (:UserProfile)-[:CREATED]->(:Project), with optional
@@ -1345,3 +1364,69 @@ OPTIONS {
1345
1364
  `vector.similarity_function`: 'cosine'
1346
1365
  }
1347
1366
  };
1367
+
1368
+ // ----------------------------------------------------------
1369
+ // Compiled-truth + timeline knowledge model (Task 306) —
1370
+ // gbrain's "compiled truth above the rule, append-only timeline
1371
+ // below" pattern. Every :Person / :Organization / :Concept carries
1372
+ // an agent-rewritten summary (`compiledTruth`, ≤2000 chars) plus
1373
+ // a history of dated `:TimelineEvent` rows path-anchored via
1374
+ // `(entity)-[:HAS_TIMELINE_EVENT]->(:TimelineEvent)`. The
1375
+ // compiledTruth is what the agent reads when it needs a summary;
1376
+ // the timeline is what it cites and appends to.
1377
+ //
1378
+ // Naming:
1379
+ // :TimelineEvent — distinct from :Event (scheduling plugin's
1380
+ // schema:Event with eventId-unique). Collisions were the reason
1381
+ // to fork; do not blend.
1382
+ // :HAS_TIMELINE_EVENT — distinct from :HAS_EVENT (visitor analytics
1383
+ // Session→PageView/Click/ScrollMilestone).
1384
+ //
1385
+ // MERGE doctrine: writers MUST path-anchor from the entity to defend
1386
+ // cross-account collisions, e.g.
1387
+ // MATCH (e) WHERE elementId(e) = $entityId AND e.accountId = $accountId
1388
+ // MERGE (e)-[:HAS_TIMELINE_EVENT]->(t:TimelineEvent {timelineEventId: $id})
1389
+ // Never label-only MERGE on :TimelineEvent.
1390
+ //
1391
+ // `kind` enum (5 values, no catch-all):
1392
+ // mentioned | met | quoted | attended | transacted
1393
+ // Extractor refuses on ambiguity rather than collapsing to "other".
1394
+ //
1395
+ // `compiledTruth` source:
1396
+ // compiledTruthSource = "rewriter" (default) — compiled-truth-rewriter
1397
+ // library wrote it.
1398
+ // compiledTruthSource = "operator" — operator overrode via the graph UI.
1399
+ // Rewriter refuses to overwrite for 7 days unless forceRewrite=true.
1400
+ // ----------------------------------------------------------
1401
+
1402
+ CREATE CONSTRAINT timeline_event_id_unique IF NOT EXISTS
1403
+ FOR (t:TimelineEvent) REQUIRE t.timelineEventId IS UNIQUE;
1404
+
1405
+ CREATE INDEX timeline_event_account IF NOT EXISTS
1406
+ FOR (t:TimelineEvent) ON (t.accountId);
1407
+
1408
+ CREATE INDEX timeline_event_occurred_at IF NOT EXISTS
1409
+ FOR (t:TimelineEvent) ON (t.occurredAt);
1410
+
1411
+ CREATE INDEX timeline_event_kind IF NOT EXISTS
1412
+ FOR (t:TimelineEvent) ON (t.kind);
1413
+
1414
+ CREATE VECTOR INDEX timeline_event_embedding IF NOT EXISTS
1415
+ FOR (t:TimelineEvent) ON (t.embedding)
1416
+ OPTIONS {
1417
+ indexConfig: {
1418
+ `vector.dimensions`: 768,
1419
+ `vector.similarity_function`: 'cosine'
1420
+ }
1421
+ };
1422
+
1423
+ // `compiledTruthUpdatedAt` indices for stale-detection scans
1424
+ // (e.g. "find :Person with truth older than 7 days that has new events").
1425
+ CREATE INDEX person_compiled_truth_updated_at IF NOT EXISTS
1426
+ FOR (p:Person) ON (p.compiledTruthUpdatedAt);
1427
+
1428
+ CREATE INDEX organization_compiled_truth_updated_at IF NOT EXISTS
1429
+ FOR (o:Organization) ON (o.compiledTruthUpdatedAt);
1430
+
1431
+ CREATE INDEX concept_compiled_truth_updated_at IF NOT EXISTS
1432
+ FOR (c:Concept) ON (c.compiledTruthUpdatedAt);