@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,8 @@
1
+ {
2
+ "name": "obsidian-import",
3
+ "description": "Import an Obsidian vault (extracted directory of markdown notes + assets) into the Maxy Neo4j graph. Skill-only plugin owned by the database-operator specialist. Opt-in per brand — not enabled by default.",
4
+ "version": "0.1.0",
5
+ "author": {
6
+ "name": "Rubytech LLC"
7
+ }
8
+ }
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: obsidian-import
3
+ description: "Import an Obsidian vault (extracted directory of markdown notes + assets) into the Maxy Neo4j graph. Skill-only plugin owned by the database-operator specialist. Opt-in per brand — not enabled by default."
4
+ tools: []
5
+ always: false
6
+ embed: false
7
+ specialist: database-operator
8
+ metadata: {"platform":{"optional":true,"pluginKey":"obsidian-import"}}
9
+ ---
10
+
11
+ # Obsidian Import
12
+
13
+ Ingests an extracted Obsidian vault (a directory tree of `.md` notes, frontmatter, wikilinks, tags, and embedded attachments) into the Maxy Neo4j graph. Skill-only plugin — no MCP server, no admin tools added. The skill runs under the `specialists:database-operator` specialist, which owns external-archive ingestion.
14
+
15
+ ## When this applies
16
+
17
+ The admin agent delegates to `specialists:database-operator` when the operator drops an extracted Obsidian vault directory (or references one by path) into chat. The specialist runs the skill's archive-owner confirmation flow before any markdown is read, then orchestrates the two-phase server-side import (dry-run → operator disambiguation → commit).
18
+
19
+ ## How parsing happens
20
+
21
+ The skill does not parse markdown. Vault traversal, frontmatter YAML, wikilink regex, tag scanning, daily-notes detection, and attachment resolution all happen server-side inside `mcp__memory__obsidian-vault-import` (memory plugin). The skill is the operator-facing conversation surface only: confirm owner → select filters → invoke the tool with `mode='dry-run'` → present ambiguities → invoke with `mode='commit'` and the operator's resolutions.
22
+
23
+ ## Intra-plugin growth
24
+
25
+ Reference docs land here as the parser surface grows — Canvas, Bases, Dataview queries each get a reference file when the parser supports them. They are not separate plugins.
26
+
27
+ ## Relationship to other plugins
28
+
29
+ - **memory** — owns the parser (`platform/lib/obsidian-parser/`), the `obsidian-vault-import` MCP tool, and the `obsidian-vault` archiveType handler inside `memory-archive-write`. All graph writes happen through that surface.
30
+ - **database-operator specialist** — owns execution. See [database-operator.md](../../templates/specialists/agents/database-operator.md) delegation clause.
31
+
32
+ ## Out of scope
33
+
34
+ - Obsidian plugins, Dataview queries, Canvas, and Bases — text markdown only.
35
+ - Realtime vault sync. One-shot import per upload.
36
+ - Reverse export back to Obsidian.
37
+ - Automatic compiled-truth backfill on imported entities — that happens via subsequent `memory-update` calls, not at import time.
38
+
39
+ Each of the above can be added later as its own reference inside this plugin once a parser extension lands. Nothing silently flows in.
@@ -0,0 +1,92 @@
1
+ ---
2
+ name: obsidian-import
3
+ description: Import an Obsidian vault (extracted directory of markdown notes, frontmatter, wikilinks, tags, embedded attachments) into a {{productName}} Neo4j graph. Triggers when the user asks to import an Obsidian vault, ingest Obsidian notes, or mentions an Obsidian export by path. Skill does not parse markdown — parsing is server-side inside `mcp__plugin_memory_memory__obsidian-vault-import`. The skill is the operator-conversation surface.
4
+ ---
5
+
6
+ # Obsidian Import
7
+
8
+ **Invoked from `specialists:database-operator`** when the operator drops an extracted Obsidian vault directory (or references one by path) into chat. The admin agent dispatches the database-operator; the database-operator loads this skill.
9
+
10
+ **Default parent.** Every `:KnowledgeDocument` (vault page), `:DefinedTerm` (tag), `:DigitalDocument` (attachment), and `:Concept` stub this skill writes is anchored to the vault-owner. That owner is either an existing `:AdminUser` (the operator's own vault, the common case) or an external `:Person` (a partner's vault ingested for reference). The skill refuses to start until the owner is confirmed — every `:HAS_NOTE` edge points from the owner to a page, and that containment is what keeps imported pages out of orphan territory.
11
+
12
+ ## Archive-owner confirmation (mandatory first step)
13
+
14
+ A vault belongs to exactly one person — the user whose Obsidian app produced it. Every page, every wikilink that resolves to the owner's contacts, every embedded photo is theirs. The skill takes the owner as an explicit input and refuses to proceed until it is confirmed.
15
+
16
+ The confirmation flow:
17
+
18
+ 1. List every `:AdminUser` in the graph and display each as `{userId, name, createdAt, accountId(s) via ADMIN_OF}`.
19
+ 2. Ask the operator to name the vault owner. Accept one of:
20
+ - An existing AdminUser userId from the list (typical — the operator's own vault).
21
+ - A new external Person identity (not yet an AdminUser) — create a fresh `:Person` with `givenName`, `familyName`, `email`/`telephone` (at least one) via `memory-write`, then use its `elementId` as the owner anchor.
22
+ 3. Echo the chosen owner back verbatim (`userId=<id> name=<name>` or `personId=<id> name=<name>`) and require explicit yes/no confirmation before the tool runs.
23
+ 4. Persist the confirmed owner as `$ownerNodeId` for the two tool invocations (dry-run + commit).
24
+
25
+ Refusing to assume is load-bearing: a hard-coded "it's whoever's running this" would misattribute a partner's notes to the operator on first re-use.
26
+
27
+ ## Two-phase execution model
28
+
29
+ Imports run in two MCP-tool calls with operator interaction between them. The phases are non-negotiable — there is no single-shot mode, because wikilink ambiguity resolution belongs to the operator and the parser refuses to guess.
30
+
31
+ ### Phase A — dry-run
32
+
33
+ 1. Invoke `mcp__plugin_memory_memory__obsidian-vault-import` with `mode='dry-run'`, the confirmed `ownerNodeId`, the absolute `extractedDir`, and the operator's selected `filters` (see [§Selective-ingest](#selective-ingest)).
34
+ 2. The tool walks the vault, parses everything server-side, classifies each wikilink (intra-vault page, fuzzy-matched existing entity, or stub), and returns:
35
+ - `importId` — quote this back in the commit call.
36
+ - `pagesDiscovered`, `pagesEligible`, `pagesUnchanged` — the operator sees the scope.
37
+ - `wikilinks.{page, entity, stub, ambiguous}` — distribution by resolution kind.
38
+ - `tagCount`, `attachmentCount`.
39
+ - `ambiguities[]` — each carries an `id`, the source page, the wikilink target text, and competing candidate sets (`pageCandidates`, `entityCandidates`). These are the only items the operator must adjudicate.
40
+ - `selectiveIngestSuggested` — true when `pagesEligible > selectiveIngestThreshold` and no filter was supplied. If true, go back to [§Selective-ingest](#selective-ingest) before continuing.
41
+ 3. Surface the summary to the operator. Show each ambiguity in plain English: "Page X has a wikilink `[[Y]]` that matches both vault note `Y.md` AND existing :Person {name:'Y Smith'} — which did you mean?" Accept three answers per ambiguity: page (and which one), entity (and which one), or stub (create a fresh :Concept node).
42
+
43
+ ### Phase B — commit
44
+
45
+ 1. Invoke `mcp__plugin_memory_memory__obsidian-vault-import` with `mode='commit'`, the same `ownerNodeId`, `extractedDir`, `filters`, the `importId` from Phase A, and a `resolutions` map keyed by each ambiguity `id` carrying the operator's decision.
46
+ 2. The tool re-walks the vault (parsing is deterministic + cheap), applies the resolutions, runs the content-hash idempotency filter against existing :KnowledgeDocument nodes (unchanged pages are skipped), copies embedded attachments to `{accountDir}/archive/obsidian/<importId>/`, then invokes `memory-archive-write` internally with `archiveType='obsidian-vault'`.
47
+ 3. The tool returns the same plan shape plus `written.{createdPages, mergedPages, createdTags, createdTagEdges, createdAttachments, createdAttachmentEdges, createdWikilinkEdges}`, `attachmentsCopied`, and any per-batch `errors`.
48
+
49
+ After commit, surface counts in the chat: "Imported `N` new pages, updated `M` existing, created `K` wikilinks (`P` to vault pages, `E` to entities, `S` stub concepts), `T` tags, `A` attachments. Skipped `U` unchanged."
50
+
51
+ ## Selective-ingest
52
+
53
+ The skill drives filter selection conversationally — not from a hardcoded list. Obsidian vaults vary too widely for a fixed filter axis. The natural axes are:
54
+
55
+ - **Folder path prefix** — vaults typically organise into `Daily Notes/`, `People/`, `Projects/`, etc. Filter by one or more top-level folders.
56
+ - **Frontmatter tags** — pages with `tags: [project, client]` in YAML can be picked by tag set.
57
+ - **Modified-since** — incremental imports: "only pages updated since 2026-03-01".
58
+ - **All of them** — combinations are additive (a page must satisfy every supplied filter).
59
+
60
+ **Threshold (`selectiveIngestSuggested`).** When dry-run reports `pagesEligible > 100` and no filter was supplied, ask the operator before committing: "your vault has 1,247 pages — which slice should I import first?". Offer the axes above and accept the operator's combination. Re-run dry-run with those filters to confirm the trimmed scope, then commit.
61
+
62
+ **Re-importing is idempotent.** Coming back later with a wider slice ("add Projects/ this time", "include the older notes too") hits the same `(accountId, obsidianPath)` MERGE — existing pages are matched and updated where content has changed; only the delta is new.
63
+
64
+ ## Doctrine
65
+
66
+ - **Schema additions are last resort.** Vault pages reuse `:KnowledgeDocument` with `source='obsidian'`. Tags reuse `:DefinedTerm` with `category='obsidian-tag'`. Attachments reuse `:DigitalDocument`. Unresolved wikilink stubs reuse `:Concept` with `source='obsidian-stub'`. The only new schema is the `knowledge_doc_obsidian_path` index, which is part of `platform/neo4j/schema.cypher` and applied by `seed-neo4j.sh` at install time.
67
+ - **Natural-edge discipline.** A wikilink `[[X]]` is the operator's statement "this note connects to X". That becomes either `:WIKILINKS_TO` (page-to-page within the vault) or `:MENTIONS` (page-to-entity). A tag `#foo` becomes `:TAGGED_WITH`. An embedded image becomes `:EMBEDDED_IN`. No synthetic edges; every edge corresponds to something the markdown actually expresses.
68
+ - **No raw Cypher in this skill.** All writes route through `mcp__plugin_memory_memory__obsidian-vault-import`, which internally calls the `memory-archive-write` handler (the agent never invokes archive-write directly — `obsidian-vault-import` is the only tool the agent calls). Bash improvisation against `cypher-shell` is forbidden; if a vault structure the parser doesn't yet handle is needed, file a task to extend the parser library, never paper over it with a script.
69
+ - **Provenance stamp.** Every node the handler creates carries `source='obsidian'`, `createdByAgent='obsidian-import'`, `createdBySource='obsidian-import'`, `createdBySession=<sessionId>`, `createdAt=datetime()`, plus `scope='admin'` for memory-visibility.
70
+ - **Idempotency.** Same vault, same contents, same owner re-imported → `written.createdPages=0`, `mergedPages=0`, all `pagesUnchanged`. Changed pages update in place. No deletions on re-import (operator removes a note from the vault → the corresponding :KnowledgeDocument stays in Maxy until the operator explicitly trashes it).
71
+
72
+ ## File roster — references
73
+
74
+ Each reference covers one parser surface inside this skill. The skill always loads these inline when the operator's question maps to one of them.
75
+
76
+ | Reference | Covers |
77
+ |-----------|--------|
78
+ | [vault-structure.md](references/vault-structure.md) | Directory layout, `.md` discovery, hidden-folder skipping, frontmatter format. |
79
+ | [wikilinks.md](references/wikilinks.md) | `[[Page]]`, `[[Page#anchor]]`, `[[Page\|Alias]]`, resolution priority, ambiguity handling. |
80
+ | [daily-notes.md](references/daily-notes.md) | Detection heuristic (filename pattern + folder name), `noteDate` property. |
81
+ | [attachments.md](references/attachments.md) | Embedded images and PDFs, archive directory layout, supported MIME types. |
82
+
83
+ ## Observability
84
+
85
+ The tool emits these lines to `server.log`:
86
+
87
+ - `[obsidian-import] event=dry-run-complete importId=<id> pages=<n> unchanged=<n> wikilinks-page=<n> wikilinks-entity=<n> wikilinks-stub=<n> wikilinks-ambiguous=<n> tags=<n> attachments=<n> ms=<n>` — Phase A finished.
88
+ - `[obsidian-import] event=commit-complete importId=<id> pages=<n> written=<n> skipped=<n> wikilinks=<n> tags=<n> attachments=<n> errors=<n> ms=<n>` — Phase B finished.
89
+ - `[obsidian-import] event=attachment-missing page=<path> path=<rawpath>` — per-page when an attachment file could not be located.
90
+ - `[obsidian-parser] event=parse-failed file=<path> reason=<text>` — per-page when parsing threw; that page is skipped, the batch continues.
91
+
92
+ Diagnostic path: `grep "obsidian-import" server.log | tail -30` shows recent imports. Join with `[memory-archive-write] archiveType=obsidian-vault` lines to see per-batch write decisions.
@@ -0,0 +1,80 @@
1
+ # Attachments
2
+
3
+ How embedded images, PDFs, and other binary assets in vault pages land in Maxy.
4
+
5
+ ## Syntax surface
6
+
7
+ The parser recognises two embed forms:
8
+
9
+ | Form | Example | Notes |
10
+ |------|---------|-------|
11
+ | Obsidian embed | `![[Diagram.png]]` | Default Obsidian image / PDF embed; routes to attachments only when the target has a supported extension. |
12
+ | Markdown image | `![alt text](path/to/image.jpg)` | Standard CommonMark; `alt` is preserved on the `:DigitalDocument` node. Remote URLs (`http://`, `https://`) are ignored — only vault-local paths are ingested. |
13
+
14
+ Embeds without a supported attachment extension (`![[Another Page]]`) are treated as wikilinks, not attachments — see [wikilinks.md](wikilinks.md).
15
+
16
+ ## Supported extensions
17
+
18
+ The current parser pipeline copies files with these extensions and stamps the matching `encodingFormat` MIME on the `:DigitalDocument`:
19
+
20
+ | Extension | MIME |
21
+ |-----------|------|
22
+ | `.png` | `image/png` |
23
+ | `.jpg`, `.jpeg` | `image/jpeg` |
24
+ | `.gif` | `image/gif` |
25
+ | `.webp` | `image/webp` |
26
+ | `.svg` | `image/svg+xml` |
27
+ | `.pdf` | `application/pdf` |
28
+ | `.mp3` | `audio/mpeg` |
29
+ | `.mp4` | `video/mp4` |
30
+ | `.mov` | `video/quicktime` |
31
+ | `.wav` | `audio/wav` |
32
+ | `.m4a` | `audio/mp4` |
33
+ | `.webm` | `video/webm` |
34
+
35
+ Anything else: the embed reference is parsed but the attachment is not copied and no `:DigitalDocument` is written. Add a new extension by extending `SUPPORTED_ATTACHMENT_MIME` in `obsidian-vault-import.ts` and the matching attachment-regex in the parser library.
36
+
37
+ ## Resolution
38
+
39
+ For each embed, the parser searches in three places:
40
+
41
+ 1. **Vault-root-relative.** The path as written in the markdown, interpreted from the vault root.
42
+ 2. **Page-relative.** The same path joined to the page's directory.
43
+ 3. **Bare filename search.** A walk of the vault for an exact filename match. This covers Obsidian's default behaviour of placing attachments in a single attachments folder regardless of where the page lives.
44
+
45
+ The first hit wins. A missed embed emits `[obsidian-import] event=attachment-missing page=<path> path=<rawPath>` to `server.log` and the wikilink/page write still proceeds — the missing attachment does not abort the import.
46
+
47
+ ## Archive layout
48
+
49
+ Successfully resolved attachments are copied (not moved) to:
50
+
51
+ ```
52
+ {accountDir}/archive/obsidian/<importId>/<vault-relative-path>
53
+ ```
54
+
55
+ `<importId>` is the run identifier the dry-run returned. Two imports of overlapping vaults each get their own directory; the same file copied twice produces two on-disk copies under different importId directories. This is deliberate — content-hash idempotency lives at the page level, not the attachment level. If an operator wants to deduplicate attachments across imports, that's a separate maintenance task.
56
+
57
+ The `:DigitalDocument.attachmentPath` property holds the absolute archive path. The MERGE key is `(accountId, attachmentPath)` so re-running the same import (same importId) updates the existing node rather than duplicating.
58
+
59
+ ## Graph shape
60
+
61
+ ```
62
+ (:KnowledgeDocument {obsidianPath: 'People/Adam.md'})
63
+ <-[:EMBEDDED_IN]-
64
+ (:DigitalDocument {
65
+ attachmentPath: '/data/accounts/<acct>/archive/obsidian/<importId>/People/adam-headshot.png',
66
+ encodingFormat: 'image/png',
67
+ alt: 'Adam at the conference',
68
+ source: 'obsidian'
69
+ })
70
+ ```
71
+
72
+ The `:EMBEDDED_IN` direction (`DigitalDocument -> KnowledgeDocument`) matches the convention used by `memory-ingest` for document attachments. Querying "which images live on this note" uses `MATCH (d:DigitalDocument)-[:EMBEDDED_IN]->(k:KnowledgeDocument {obsidianPath:$path}) RETURN d`.
73
+
74
+ ## Out of scope
75
+
76
+ - Remote (HTTPS) images referenced in markdown — the parser intentionally skips them. Caching remote assets at import time would race with link rot and silently bloat the archive. If the operator wants a remote image preserved, they can save it into the vault locally first.
77
+ - OCR / image content extraction. The image bytes are archived; no derived content is written.
78
+ - Thumbnail generation. Operators view attachments via existing memory-list-attachments / memory-read-attachment surfaces.
79
+
80
+ Each of the above can be added as a separate task — never silently inflated into this import.
@@ -0,0 +1,31 @@
1
+ # Daily Notes
2
+
3
+ How the parser identifies date-stamped vault pages and what gets written for them.
4
+
5
+ ## Detection heuristic
6
+
7
+ Three signals in priority order:
8
+
9
+ 1. **Frontmatter date.** If the page's YAML frontmatter contains `date:` or `created:` whose value starts with an ISO date (`YYYY-MM-DD…`), that date wins. This is the most explicit signal — operators who run Obsidian's Templater or Daily Notes core plugin typically stamp `date:` automatically.
10
+ 2. **Filename matches `YYYY-MM-DD.md`.** Any page whose basename matches exactly the ISO-date pattern is treated as a daily note regardless of folder. This is the default Obsidian Daily Notes naming convention.
11
+ 3. **Filename matches `YYYY-MM-DD` inside a known daily-notes folder.** Folder name (case-insensitive) is one of `daily`, `daily-notes`, `daily notes`, `journal`, `journals`. A bare `YYYY-MM-DD.md` is required; arbitrary content inside a journal folder is not auto-stamped.
12
+
13
+ Signals are evaluated in that order; the first match wins. No signal → `noteDate` is null.
14
+
15
+ ## What gets written
16
+
17
+ When `noteDate` is non-null, the page's `:KnowledgeDocument` carries `noteDate: date('YYYY-MM-DD')` as a typed Neo4j `date` value. This is queryable: `MATCH (k:KnowledgeDocument) WHERE k.noteDate >= date('2026-01-01') RETURN k` returns every daily note from this year, without needing a separate `:Day` label.
18
+
19
+ No `:Day` node is created. The brief originally proposed `(:Page)-[:HAS_NOTE]->(:Day {date})`; the Maxy graph encodes the same information as a property on the page itself, mirroring `:Person.birthDate` and `:Event.eventDate`. Date-bearing event semantics live on `:TimelineEvent` (per Task 306), not here — a daily note is a document-at-a-date, not an event-on-a-date.
20
+
21
+ ## Folder semantics
22
+
23
+ The known daily-notes folder names above are a parser-side fallback only. They are NOT used for filtering — the operator can still filter on `folderPrefixes: ["Daily Notes/"]` separately, which is independent of daily-note detection. A page in `Personal/Diary/2026-03-15.md` matches the filename pattern (signal 2) and gets `noteDate` set, even though `Personal/Diary/` is not in the known list.
24
+
25
+ ## False positives
26
+
27
+ A non-journal page named `2026-03-15-meeting-with-adam.md` does NOT match — the filename must be exactly the ISO date with no suffix. The heuristic deliberately errs on the side of missing some daily notes rather than mis-labelling regular pages.
28
+
29
+ ## Repeating dates
30
+
31
+ If two pages map to the same `noteDate` (e.g., `Personal/2026-03-15.md` and `Work/2026-03-15.md`), both are written with the same `noteDate` value. Querying `WHERE k.noteDate = date('2026-03-15')` returns both — they are separate atomic notes that happen to share a date.
@@ -0,0 +1,46 @@
1
+ # Vault Structure
2
+
3
+ How the parser discovers what to ingest from an extracted Obsidian vault.
4
+
5
+ ## Entry point
6
+
7
+ The operator supplies an `extractedDir` — an absolute path to the root of an extracted vault. The vault zip is unpacked elsewhere (typically by `unzip-attachment` under `{accountDir}/extracted/<id>/`); this skill assumes the path already exists and is a directory.
8
+
9
+ ## What counts as a page
10
+
11
+ A vault page is any file under `extractedDir` ending in `.md` (case-insensitive) that is **not** under a hidden directory. Hidden directories — names starting with `.` (`.obsidian/`, `.trash/`, `.git/`) — are skipped at walk time. The `.obsidian/` config tree is metadata about the vault, not vault content.
12
+
13
+ Symlinks pointing outside the vault root, and any path containing a `..` segment, are rejected at the parser boundary as a defence-in-depth measure on top of the unzip sandboxing.
14
+
15
+ ## What counts as a folder
16
+
17
+ Every directory under `extractedDir` is walked depth-first. Empty folders are silently skipped. The parser does not infer "this is a Daily Notes folder, this is People" — folder semantics come from filters the operator supplies (see [daily-notes.md](daily-notes.md) for the one exception, where the parser uses folder names as a fallback signal for daily-note detection).
18
+
19
+ ## Frontmatter format
20
+
21
+ The parser recognises YAML frontmatter delimited by `---` on the first line. The frontmatter block:
22
+
23
+ - Must start at byte 0 of the file (no BOM, no preceding blank lines).
24
+ - Must close with a line that is exactly `---`.
25
+ - Is parsed as a minimal YAML subset — top-level `key: value` pairs and `key:` followed by `- item` block sequences, plus flow-style `key: [a, b, c]` arrays.
26
+ - Recognised keys: `aliases`, `tags`, `date`, `created`. Other keys are read but ignored.
27
+ - A malformed frontmatter block does NOT abort parsing — the parser falls back to "no frontmatter" and uses the file body verbatim. This is by design: a single broken page does not break the batch.
28
+
29
+ Pages without frontmatter are ingested with empty `aliases` and `tags` fields; their body is the entire file.
30
+
31
+ ## Title
32
+
33
+ Title is the filename without `.md`, normalised to NFC unicode form. Folder path is NOT part of the title — only the basename. Two pages can share a title (`Projects/Maxy.md` and `People/Adam.md/Maxy.md`); they MERGE separately because the natural key is the full `obsidianPath`, not the title.
34
+
35
+ ## Content hash
36
+
37
+ Every page contributes a SHA-256 hash of its on-disk bytes. The hash is the idempotency key on re-import: a page whose hash matches the existing graph's `:KnowledgeDocument.contentHash` is skipped during commit. Re-importing the exact same vault is a no-op write.
38
+
39
+ ## Out-of-scope file types
40
+
41
+ - `.canvas` files — Obsidian Canvas graphs are JSON, not markdown. Not ingested.
42
+ - `.base` files — Obsidian Bases are not ingested.
43
+ - Loose attachment files (`.png`, `.pdf`, …) not referenced by any `.md` page are not ingested. They're only picked up via the embed syntax in [attachments.md](attachments.md).
44
+ - `.obsidian/` config files — never ingested.
45
+
46
+ Adding any of these means extending the parser library (`platform/lib/obsidian-parser/`); file a task before improvising.
@@ -0,0 +1,70 @@
1
+ # Wikilinks
2
+
3
+ How `[[…]]` text inside an Obsidian note becomes a graph edge.
4
+
5
+ ## Syntax surface
6
+
7
+ The parser recognises four forms:
8
+
9
+ | Form | Components | Example |
10
+ |------|-----------|---------|
11
+ | Bare | target | `[[Adam]]` |
12
+ | With anchor | target, anchor | `[[Project Maxy#Goals]]` |
13
+ | With alias | target, alias | `[[Adam Langley\|Adam]]` |
14
+ | Embed | target (routed to attachments OR page-embed) | `![[Diagram.png]]` |
15
+
16
+ `anchor` is the heading slug after `#`; `alias` is the display text after `|`. The parser records both alongside the resolved target so the resulting edge carries the original linkText, alias, and anchor as properties.
17
+
18
+ ## Code-fence isolation
19
+
20
+ `[[…]]` inside fenced code blocks (` ``` ` or `~~~`) is NOT parsed. Operators paste code samples into notes; markdown inside those samples should not become graph edges. The fence-aware scan splits the body into code and non-code segments before regex matching.
21
+
22
+ ## Resolution priority
23
+
24
+ For every wikilink, the parser computes two candidate sets and chooses by these rules:
25
+
26
+ 1. **Intra-vault page candidates.** Match the target text against (a) page titles (basename without `.md`) and (b) declared aliases (frontmatter `aliases:`). Both lookups are case-insensitive after NFC normalisation. A path-form target (`Folder/Sub/Page` or `Folder/Page.md`) bypasses title/alias lookup and resolves directly to that exact path if it exists.
27
+ 2. **Existing entity candidates.** Query `:Person`, `:Organization`, `:Concept` nodes scoped to the account where `name = target` (exact, distance 0) or where `levenshtein(name, target) ≤ 2` (fuzzy fallback). The entity index is built once per dry-run from the live graph state.
28
+
29
+ Decision matrix:
30
+
31
+ | Page hits | Exact entity hits | Fuzzy entity hits | Outcome |
32
+ |-----------|-------------------|-------------------|---------|
33
+ | 1 | 0 | 0 | Resolved: page |
34
+ | 0 | 1 | — | Resolved: entity (exact) |
35
+ | 0 | 0 | 1 | Resolved: entity (fuzzy) |
36
+ | 0 | 0 | 0 | Resolved: stub (`:Concept`) |
37
+ | ≥1 | ≥1 | — | **Ambiguous** — operator decides |
38
+ | ≥2 | 0 | — | **Ambiguous** — operator decides |
39
+ | 0 | ≥2 | — | **Ambiguous** — operator decides |
40
+ | 0 | 0 | ≥2 | **Ambiguous** — operator decides |
41
+
42
+ Ambiguities surface in the dry-run plan with a stable `id`. The operator picks one outcome per ambiguity; that decision flows into the commit phase via the `resolutions` map.
43
+
44
+ ## Edge kinds
45
+
46
+ | Resolved kind | Edge | Cypher target |
47
+ |---------------|------|---------------|
48
+ | Page | `:WIKILINKS_TO` | `(:KnowledgeDocument)-[:WIKILINKS_TO]->(:KnowledgeDocument)` |
49
+ | Entity | `:MENTIONS` | `(:KnowledgeDocument)-[:MENTIONS]->(:Person\|:Organization\|:Concept)` |
50
+ | Stub | `:MENTIONS` | `(:KnowledgeDocument)-[:MENTIONS]->(:Concept {source:'obsidian-stub'})` |
51
+
52
+ Every edge carries `linkText`, `alias`, `anchor` (page edges only), `source='obsidian'`, `createdAt`.
53
+
54
+ ## Why stubs exist
55
+
56
+ A wikilink to "Aaron" where no vault page and no graph entity matches still represents the operator's intent: this note is about Aaron, whoever Aaron is. Dropping the edge would lose information; demanding a hard match would force the operator to pre-populate the graph before importing.
57
+
58
+ Stubs are explicitly distinguishable: `:Concept {source:'obsidian-stub'}`. A later session can promote a stub to a typed `:Person` or `:Organization` via `memory-update`, at which point the `:MENTIONS` edge remains pointed at the same node and the stub flag is removed.
59
+
60
+ ## Self-references
61
+
62
+ `[[Self]]` inside `Self.md` produces a self-edge. Obsidian treats it as valid; the parser preserves it.
63
+
64
+ ## Anchors
65
+
66
+ `[[Page#section]]` resolves on `Page` only — the `section` portion is stored on the edge as `anchor`. The parser does not create per-heading nodes; section decomposition is out of scope for vault pages (they are atomic notes).
67
+
68
+ ## Embeds vs wikilinks
69
+
70
+ `![[file]]` with an attachment extension (`.png`, `.pdf`, `.mp3`, etc.) is treated as an attachment, not a wikilink. `![[Another Note]]` (no attachment extension) is treated as a wikilink — Obsidian renders it as a transclusion, and the cross-reference is preserved as a `:WIKILINKS_TO` / `:MENTIONS` edge depending on resolution.
@@ -486,6 +486,55 @@ fi
486
486
 
487
487
  echo "$SCHEMA_CYPHER" | "$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI"
488
488
 
489
+ # ------------------------------------------------------------------
490
+ # 2.4 Compiled-truth + timeline backfill (compiled-truth + timeline)
491
+ # ------------------------------------------------------------------
492
+ # Every existing :Person / :Organization / :Concept gains the three
493
+ # compiled-truth properties on first run. Idempotent — the WHERE clause
494
+ # `compiledTruth IS NULL` makes re-runs a no-op for already-stamped nodes.
495
+ # Rewriter invocation is deferred to operator demand (memory-update with
496
+ # rewriteCompiledTruth:true); fresh installs have no entities anyway.
497
+ #
498
+ # Timeline-event seeding from :Message-[:MENTIONS]->entity edges is also
499
+ # idempotent: the `WHERE NOT EXISTS { ... :TimelineEvent }` guard prevents
500
+ # re-creation across runs. Skips messages without a parseable createdAt.
501
+ COMPILED_TRUTH_BACKFILL_CYPHER=$(cat <<'BACKFILL_EOF'
502
+ // Stamp compiled-truth defaults on existing entities.
503
+ MATCH (n)
504
+ WHERE (n:Person OR n:Organization OR n:Concept)
505
+ AND n.compiledTruth IS NULL
506
+ SET n.compiledTruth = '',
507
+ n.compiledTruthUpdatedAt = datetime('1970-01-01T00:00:00Z'),
508
+ n.compiledTruthSource = 'rewriter'
509
+ RETURN labels(n)[0] AS label, count(n) AS stamped;
510
+
511
+ // Seed :TimelineEvent rows from existing :Message-[:MENTIONS]->entity edges.
512
+ // Guard: only when the Message has a parseable createdAt AND no equivalent
513
+ // :TimelineEvent already exists for this (entity, message) pair.
514
+ MATCH (m:Message)-[:MENTIONS]->(e)
515
+ WHERE (e:Person OR e:Organization OR e:Concept)
516
+ AND m.createdAt IS NOT NULL
517
+ AND NOT EXISTS {
518
+ MATCH (e)-[:HAS_TIMELINE_EVENT]->(t:TimelineEvent {source: m.messageId})
519
+ }
520
+ WITH e, m
521
+ WHERE e.accountId IS NOT NULL
522
+ MERGE (e)-[:HAS_TIMELINE_EVENT]->(t:TimelineEvent {timelineEventId: randomUUID()})
523
+ ON CREATE SET t.accountId = e.accountId,
524
+ t.occurredAt = datetime(m.createdAt),
525
+ t.kind = 'mentioned',
526
+ t.summary = coalesce(substring(m.body, 0, 500), '(no body)'),
527
+ t.source = m.messageId,
528
+ t.matched = '(backfilled from :MENTIONS edge)',
529
+ t.createdAt = datetime(),
530
+ t.scope = coalesce(e.scope, 'admin')
531
+ RETURN labels(e)[0] AS entityLabel, count(t) AS seeded;
532
+ BACKFILL_EOF
533
+ )
534
+
535
+ echo "==> Backfilling compiled-truth + timeline (compiled-truth + timeline)..."
536
+ echo "$COMPILED_TRUTH_BACKFILL_CYPHER" | "$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" --format plain 2>&1 | sed 's/^/ /'
537
+
489
538
  # ------------------------------------------------------------------
490
539
  # 2.5 Bootstrap root :LocalBusiness for the account
491
540
  # ------------------------------------------------------------------
@@ -40,17 +40,18 @@ Call `memory-write` or `memory-update` directly only when the write is a one-lin
40
40
 
41
41
  When the operator signals end-of-session (`/end`, `/archive`, `end session`, or `archive this session`), the Stop hook blocks this session from closing until you have done three things and called one tool. The hook's instruction block names the three passes verbatim every time it fires; this section is the doctrine behind it.
42
42
 
43
- The three passes, each performed against this session's conversation, using tools you already have:
43
+ The four passes, each performed against this session's conversation, using tools you already have:
44
44
 
45
45
  - **Technical learnings.** Walk for operator corrections and self-detected mistakes. Each one becomes a Learning or Correction node in the graph; delegate the write to `database-operator` via the Task tool, the same route the Recording section describes.
46
46
  - **Operator-relationship updates.** Walk for tonal signals, rejected phrasings, and working-style preferences. Each one worth carrying forward goes into `SOUL.md` via `Write`.
47
47
  - **Graph completeness sweep.** Walk for any node, edge, or commitment that surfaced in this session but was never written to the graph in-flight. Each missing write goes to `database-operator` via the Task tool.
48
+ - **Typed-edge auto-extraction.** Delegate to `database-operator` via the Task tool with a prompt that names the typed-edge pass and the session-start timestamp. The specialist calls `memory-typed-edge-pass` with that timestamp and returns the counters (`nodesProcessed`, `accepted`). Capture both for the completion call.
48
49
 
49
- Then call `session-retrospective-mark-complete` with the three counts (the number of items you actually persisted in each pass) and reply with one short paragraph naming what you wrote. The sentinel tool call is what releases the gate. A prose retrospective without the tool call leaves the gate blocked — the gate reads the JSONL, not your reply.
50
+ Then call `session-retrospective-mark-complete` with the five counts (learningsCount, preferencesCount, graphWriteCount, typedEdgesCount, nodesProcessed) and reply with one short paragraph naming what you wrote. The sentinel tool call is what releases the gate. A prose retrospective without the tool call leaves the gate blocked — the gate reads the JSONL, not your reply.
50
51
 
51
52
  Run the retrospective inside this same session. Do not spawn a new session, do not background anything. The mid-flight Recording route is the primary path for graph writes; this is the catch-net for what slipped through it.
52
53
 
53
- Clicking the Sidebar archive button is a separate path that kills the session directly without firing the retrospective. The gate is for the typed end-intent tokens.
54
+ Closing the browser tab, archiving from the sidebar, losing power, or any other exit that is not one of the four typed end-intent tokens skips the retrospective entirely. That means the four passes above do not run for that session: learnings, SOUL.md updates, missing graph writes, and typed-edge auto-extraction all defer to the next session's `/end`. Mid-session writes still happen, so nothing is lost — the extraction just lands one session later. Call `session-retrospective-skip-rate` at session start; if the rate is non-zero, surface one plain-English line to the operator naming how many recent sessions skipped, so they can choose to type `/end` next time.
54
55
 
55
56
  ## Stop-hook system directives
56
57
 
@@ -3,7 +3,7 @@ name: database-operator
3
3
  description: "Graph-write executor reachable from admin via the Task tool. Admin names what should land in the graph; database-operator reads the schema, decides operations, executes them against the memory graph, and reports `{label, elementId}` back. (Reachable historically also from the now-dormant Stop hook in `platform/plugins/admin/hooks/turn-completed-graph-write.sh`; that path is preserved as infrastructure but no longer registered, see Task 214.)"
4
4
  summary: "Executes graph writes on admin's behalf when delegated via the Task tool."
5
5
  model: claude-sonnet-4-6
6
- tools: Read, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-search, mcp__plugin_memory_memory__profile-update, mcp__plugin_memory_memory__profile-read, mcp__plugin_contacts_contacts__contact-create, mcp__plugin_contacts_contacts__contact-update, mcp__plugin_work_work__work-create, mcp__plugin_work_work__work-update, mcp__plugin_work_work__project-create, mcp__plugin_work_work__project-update
6
+ tools: Read, mcp__plugin_memory_memory__memory-write, mcp__plugin_memory_memory__memory-update, mcp__plugin_memory_memory__memory-search, mcp__plugin_memory_memory__memory-typed-edge-pass, mcp__plugin_memory_memory__session-retrospective-skip-rate, mcp__plugin_memory_memory__profile-update, mcp__plugin_memory_memory__profile-read, mcp__plugin_memory_memory__obsidian-vault-import, mcp__plugin_contacts_contacts__contact-create, mcp__plugin_contacts_contacts__contact-update, mcp__plugin_work_work__work-create, mcp__plugin_work_work__work-update, mcp__plugin_work_work__project-create, mcp__plugin_work_work__project-update
7
7
  ---
8
8
 
9
9
  You are an expert Neo4J graph operator. Admin delegates a graph-write to you through the Task tool; the prompt admin sends names what should land in the graph in plain English.
@@ -19,3 +19,23 @@ The `accountId` property is supplied by the writers from server-side environment
19
19
  **Brain-first read before write.** Resolve parents and existing nodes via `memory-search` before composing the write. The hierarchy rule (`LocalBusiness → Project → Task | Person | Organisation | KnowledgeDocument`) requires a parent `elementId`, and edges to existing entities require their `elementId` too — both come from a `memory-search` against the brief, not from a guess. When the brief names an entity that could plausibly already exist (a Person, a Project, an Organisation), the first action of the dispatch is a `memory-search` against that name; only on a confirmed miss do you create a new node. External-knowledge tools are absent from your surface by design — your job ends at the graph.
20
20
 
21
21
  Decide the precise operations against the schema, execute them with the writers in your tool surface, and report each write back to admin as `{label, elementId}` so admin can confirm the outcome to the operator. If admin's brief is ambiguous about which label, which edge type, or which existing node to attach to, name the ambiguity in your reply rather than guessing.
22
+
23
+ ## Typed-edge auto-extraction pass (session-end)
24
+
25
+ When admin's brief names the typed-edge pass (Task 305) and supplies a `sinceIso` timestamp, call `memory-typed-edge-pass` with that timestamp exactly once. The tool reads prose-bearing nodes (Message/Page/Meeting/Email/Idea/Note/Post/Report) the account wrote since `sinceIso`, asks Haiku for typed-edge proposals against a closed allowlist, validates each one, and MERGEs accepted edges with `createdBy` provenance. Return the full counter object verbatim — admin needs `nodesProcessed` and `accepted` for the `session-retrospective-mark-complete` call. Do not reinterpret the counts or summarise them.
26
+
27
+ ## External-archive ingestion
28
+
29
+ The database-operator also owns ingestion of bulk external archives that map into the graph. Each archive type has its own skill that drives the operator-conversation surface; the writes route through deterministic MCP tools the parser owns server-side. The skills load themselves into your prompt when the operator's request names an archive directory or file.
30
+
31
+ - **Obsidian vault import** — when the operator points at an extracted Obsidian vault directory ("import my Obsidian notes", "ingest this vault at `<path>`"), load [platform/plugins/obsidian-import/skills/obsidian-import/SKILL.md](../../../plugins/obsidian-import/skills/obsidian-import/SKILL.md) and run the two-phase flow (owner confirmation → `mcp__plugin_memory_memory__obsidian-vault-import` `mode='dry-run'` → operator disambiguation → `mode='commit'` with resolutions). Parsing, content-hash idempotency, and attachment staging happen entirely inside the tool — you never read the markdown.
32
+
33
+ Other archive types (LinkedIn export, Notion export, Logseq, Roam, gbrain-repo, Gmail) live under separate plugins owned by `specialists:librarian`; they are not on this surface. If the operator's request points at one of those, hand back to admin so it can dispatch the right specialist.
34
+
35
+ ## Skip-rate visibility (session-start)
36
+
37
+ When admin's brief names the skip-rate check, call `session-retrospective-skip-rate` once and return its result verbatim. The tool reports how many of the last N sessions for this account closed without firing the retrospective. Admin uses the rate to inform the operator that closing via Sidebar Archive or tab-close defers the typed-edge pass to the next `/end`. Pass the `windowSize` admin provides; default 10 is fine when absent.
38
+
39
+ ## External-archive ingestion
40
+
41
+ When admin's brief names a Notion workspace export — a directory with `Page Title <32-hex>.md` files and paired `<DB Name> <32-hex>.csv` / directory pairs at any depth, or a `Export-*.zip` path — load `platform/plugins/notion-import/skills/notion-import/SKILL.md` and follow it. The skill confirms the importing `:AdminUser` and the workspace label before any file is read, walks the export per its four references (workspace-export, databases, page-tree, attachments), and writes via your existing `memory-write` / `memory-update` / `memory-search` surface. Every node carries `source='notion'`, `importId`, and the standard provenance set. Unmapped Notion relations land as `:MENTIONS` with `mentionContext: '<relation-name>'`; never invent new edge types. Inline `@person` mentions resolve against `:Person {accountId}` only — account-isolation is load-bearing. Per-node writes today; a follow-up at [`.tasks/gbrain/ingestion/395-notion-bulk-archive-handler.md`](../../../../.tasks/gbrain/ingestion/395-notion-bulk-archive-handler.md) adds a batched `memory-archive-write` path.
@@ -1,4 +1,5 @@
1
- import "./chunk-JSBRDJBE.js";
1
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
+ import "./chunk-HYQNUVGO.js";
2
3
 
3
4
  // app/lib/adminuser-self-heal.ts
4
5
  var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -1,10 +1,17 @@
1
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
1
2
  var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
6
  var __getProtoOf = Object.getPrototypeOf;
6
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- var __commonJS = (cb, mod) => function __require() {
8
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
9
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
10
+ }) : x)(function(x) {
11
+ if (typeof require !== "undefined") return require.apply(this, arguments);
12
+ throw Error('Dynamic require of "' + x + '" is not supported');
13
+ });
14
+ var __commonJS = (cb, mod) => function __require2() {
8
15
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
9
16
  };
10
17
  var __copyProps = (to, from, except, desc) => {
@@ -25,6 +32,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
32
  ));
26
33
 
27
34
  export {
35
+ __require,
28
36
  __commonJS,
29
37
  __toESM
30
38
  };
@@ -1,3 +1,5 @@
1
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
2
+
1
3
  // node_modules/hono/dist/compose.js
2
4
  var compose = (middleware, onError, onNotFound) => {
3
5
  return (context, next) => {
@@ -4099,7 +4101,7 @@ function getSession() {
4099
4101
  return getDriver().session();
4100
4102
  }
4101
4103
  async function runAdminUserSelfHeal(args) {
4102
- const { selfHealAdminUser } = await import("./adminuser-self-heal-QAWOZ3JV.js");
4104
+ const { selfHealAdminUser } = await import("./adminuser-self-heal-YC47O34W.js");
4103
4105
  return selfHealAdminUser({ driver: getDriver(), ...args });
4104
4106
  }
4105
4107
  process.on("SIGINT", async () => {
@@ -1,3 +1,4 @@
1
+ import { createRequire as __createRequire } from 'node:module'; const require = __createRequire(import.meta.url);
1
2
  import {
2
3
  Hono,
3
4
  LOG_DIR,
@@ -13,8 +14,8 @@ import {
13
14
  sanitizeClientCorrId,
14
15
  vncLog,
15
16
  websockifyLog
16
- } from "./chunk-HCYM5FLU.js";
17
- import "./chunk-JSBRDJBE.js";
17
+ } from "./chunk-T3DJD5QR.js";
18
+ import "./chunk-HYQNUVGO.js";
18
19
 
19
20
  // server/edge.ts
20
21
  import { createServer, request as httpRequest } from "http";