feat(node-graph): Obsidian-style knowledge map - #1214
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolves three additive conflicts: - `chat-store-types.ts` — keep the `nodeGraph` member on `AppRoute` alongside develop's new completion-attention and scheduled-activity types. - `Workbench.tsx` / `WorkbenchContent.tsx` — keep `openNodeGraphView` alongside develop's `openConnectWeixin` wiring. Also picks up develop's dependency additions, so `package-lock.json` now reflects an install on the merged manifest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`@tiptap/markdown` escapes every `[` it writes as plain text, so a link inserted through the rich editor's `[[` menu reached the file as `\[\[target\]\]`. The knowledge indexer never matches that form, so the link was silently never drawn in the node graph — the `[[` menu shipped working in CodeMirror and inert in TipTap. The same escaping also made every note that already contained a wikilink fail `auditWriteMarkdownFidelity`: parse → serialize was not idempotent, so the note was classed "unstable" and pushed into CodeMirror. In an Obsidian-style vault that is most of the vault. Restore the bare form in one place. `serializeWriteMarkdown` now runs the unescape, the fidelity audit serializes through the same helper so both passes agree, and `WriteRichEditor` emits through it on change instead of calling `manager.serialize` directly. Found while re-recording the feature end to end in the real desktop app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
XingYu-Zhong
left a comment
There was a problem hiding this comment.
Review summary
I reviewed the current PR head (f10b157). The feature direction is valuable and the targeted unit coverage is a good start, but I do not think this is ready to merge yet. There are several scalability and correctness issues that can surface in normal multi-workspace usage.
Blocking issues
1. Folder graph indexing has no global resource budget
The folder route accepts an arbitrary number of repeated root parameters, and NodeGraphService.loadFolder() starts readyFolderIndex(..., { verifyFreshness: true }) for every unique root through one Promise.all().
- Route:
Kun/kun/src/server/routes/node-graph.ts
Lines 43 to 54 in f10b157
- Service:
Kun/kun/src/node-graph/node-graph-service.ts
Lines 97 to 125 in f10b157
The per-root limits do not provide a global limit. With many saved Work roots, a single default "all workspaces" load can concurrently scan thousands of files and tens or hundreds of MB, including PDF/Office parsing. This can create a large CPU, I/O, and memory spike in the Kun runtime.
Suggested fix:
- Cap the number of roots accepted by the route.
- Apply one global file/byte budget across the request, not only per-root budgets.
- Use bounded concurrency instead of an unbounded
Promise.all(). - Return an explicit truncation diagnostic when the global budget is reached.
2. WikiLink target discovery performs a full scan on every editor mount
useWikilinkTargets() invokes request() from an effect as soon as the editor mounts. It does not wait until the user first opens [[ completion. The cache is held inside each hook instance, while both editor implementations instantiate the hook.
- Hook:
Kun/src/renderer/src/write/wikilink/use-wikilink-targets.ts
Lines 29 to 87 in f10b157
- Multi-root scan:
Kun/src/renderer/src/write/wikilink/wikilink-scan.ts
Lines 113 to 126 in f10b157
Consequently, opening several editor groups can repeat the complete scan. The limits are again per root (200 directories / 800 files), and the renderer may issue thousands of sequential directory-list IPC calls for each mounted editor.
Suggested fix:
- Defer discovery until completion is requested for the first time.
- Move caching/deduplication to a shared workspace-level service rather than a hook-local cache.
- Add a global scan budget across roots and invalidate the cache from filesystem/workspace events.
3. Changed-file loading reads all graph runs and can omit the selected workspace
loadChangedFiles() calls runs.list() without a query or limit. FileGraphRunStore.list() loads every candidate's full snapshot before sorting. maxRuns is only applied after that full read, and the 2.5 second timeout does not cancel the scan.
There is also a correctness problem: the code slices the globally newest runs before checking workspaceOf.has(run.threadId). If 40 newer runs belong to other workspaces, older runs belonging to the selected workspace disappear from the graph.
- Node graph filtering:
Kun/kun/src/node-graph/node-graph-service.ts
Lines 220 to 266 in f10b157
- Run store implementation:
Kun/kun/src/graph/graph-run-store.ts
Lines 291 to 305 in f10b157
Suggested fix:
- Filter by the scoped thread/workspace IDs before applying the limit.
- Push the scope and limit into the run-store query so unrelated snapshots are never loaded.
- Make timeout/cancellation stop the underlying work, or deduplicate concurrent scans.
Correctness issues
4. Background refresh can continuously invalidate itself and preserve stale metadata
The view refreshes every four seconds. Each loadFolder() increments the global loadToken, including background refreshes, but background requests do not enter a loading state. When a scan takes longer than four seconds, the next poll invalidates the previous response and starts another scan; on a sufficiently large workspace, no refresh result may ever be applied.
Additionally, projectionSignature() only includes node ID/degree, edge IDs, truncation, and diagnostics. It ignores labels, subtitles, paths, timestamps, file sizes, states, and counts. A content-only change with the same topology is therefore treated as the same projection, leaving the inspector and metadata stale.
- Store and signature:
Kun/src/renderer/src/node-graph/node-graph-store.ts
Lines 118 to 132 in f10b157
- Folder load/token handling:
Kun/src/renderer/src/node-graph/node-graph-store.ts
Lines 191 to 222 in f10b157
- Refresh interval:
Kun/src/renderer/src/node-graph/use-node-graph-auto-refresh.ts
Lines 23 to 56 in f10b157
Suggested fix: allow only one folder refresh in flight, coalesce/abort subsequent requests, and include all user-visible node metadata in the comparison (or use an index revision/hash supplied by the runtime).
5. Edge-only topology changes do not restart the force simulation
setGraph() considers the structure changed only when the node count changes or a new node is seeded. Adding/removing a WikiLink changes edges without changing nodes. If the simulation has already settled at alpha = 0, the new edge does not affect layout.
- Simulation update:
Kun/src/renderer/src/node-graph/node-graph-simulation.ts
Lines 107 to 129 in f10b157
I reproduced this against the PR head: after settling the simulation, applying the same nodes with a new edge left alpha at 0 and tick() reported no further movement.
Suggested fix: compare the edge identity/endpoints as part of structureChanged and reheat/restart the simulation when edges are added, removed, or rewired.
6. Rich-editor serialization changes deliberately escaped WikiLinks
The Markdown manager globally converts escaped \[\[...\]\] sequences back to [[...]]. It cannot distinguish an editor-created WikiLink from literal text deliberately written as \[\[foo\]\]. Opening and saving such a document in the rich editor can turn literal Markdown into an active WikiLink and change document semantics.
- Serialization logic:
Kun/src/renderer/src/write/tiptap/markdown-manager.ts
Lines 142 to 169 in f10b157
Suggested fix: preserve this distinction in the Tiptap schema/Markdown token representation rather than post-processing the entire serialized document with a global regex. Please also add a round-trip test for deliberately escaped brackets.
7. Cross-drive WikiLinks are invalid on Windows
The relative-path implementation manually compares slash-separated segments. For an active file such as C:/work/current.md and a target such as D:/notes/target.md, it produces a value like ../../D:/notes/target, which is not a valid Windows relative path and cannot resolve to the target.
- Path generation:
Kun/src/renderer/src/write/wikilink/wikilink-targets.ts
Lines 96 to 112 in f10b157
Suggested fix: use platform-aware path semantics and explicitly detect different volume roots. For cross-volume targets, use a supported absolute/workspace-qualified representation or disable that completion with a clear explanation. Add a Windows cross-drive test.
Reviewability and merge readiness
8. Two TypeScript files contain literal NUL bytes
NodeGraphView.tsx and node-graph-analysis.ts contain actual 0x00 bytes. Git classifies these TypeScript sources as binary, so their core changes are not visible in the normal GitHub diff and common source-search/review tools may skip them.
Please replace the literal bytes with source escapes such as '\0', then verify that GitHub renders both files as text:
src/renderer/src/components/node-graph/NodeGraphView.tsxsrc/renderer/src/node-graph/node-graph-analysis.ts
9. The branch needs to be refreshed and revalidated before merge
At review time, the PR head is 40 commits behind the current develop. A trial merge was textually clean, but the visible successful checks are the four packaging jobs from 2026-08-21 and therefore do not validate the PR against the current base. The PR description also notes that the feature has not yet been verified in the real Electron app.
Before merging, I recommend:
- Update the branch to the latest
develop. - Add regression tests for the cases above.
- Run typecheck, the relevant desktop/Kun test suites, and packaging checks on the updated head.
- Smoke-test the Work graph and both Write editors in the real Electron app, including many roots, a slow/large workspace, and Windows cross-drive workspaces.
For context, I ran the targeted node-graph/WikiLink tests on the current head: 93 tests passed. That is useful baseline coverage, but it does not exercise the failure modes listed above.
Once the blocking scan/query issues and the correctness cases are addressed, I am happy to take another pass.
…hed-down scope Review items 1 and 3 (XingYu-Zhong, PR KunAgent#1214). Folder projections: the route rejects more than 64 root parameters, the service projects at most 12 roots (the rest dropped with a diagnostic and truncated: true), roots index through a 2-wide worker pool instead of an unbounded Promise.all, and every root shares one scan budget (1,600 files / 96MB per request) layered over the indexer's per-root caps. The budget is charged only when a rebuild is actually due — a fingerprint match stays free — and a refused root serves its last built index as 'stale' with a 'scan budget reached' diagnostic instead of scheduling the same work in the background. Changed files: GraphRunListFilter gained threadIds and limit, applied to the index entries before any snapshot is read, so listing never loads unrelated or older runs. The projection passes its thread scope with the cap, which also fixes a correctness bug: the newest-40 slice was taken globally before the workspace filter, so a burst of runs in another workspace crowded out this workspace's older runs. Concurrent projections of the same scope now share one in-flight scan. The manager store's list schema accepts the new fields, so a remote runtime is not silently stripped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…obally Review item 2 (XingYu-Zhong, PR KunAgent#1214). useWikilinkTargets no longer scans when an editor mounts; the scan starts the first time a menu asks for completions, which both editors already do via onRequestTargets. The cache moved out of the hook into a module-level service shared by every mounted editor, so opening several editor groups costs one walk instead of one per editor, and concurrent requests share the in-flight scan. The walk is now bounded globally as well as per root — 800 directories and 3,200 files across all roots on top of the existing 200/800 per root — so many saved workspaces cannot multiply into thousands of directory-list IPC calls. The cache is invalidated by file create, rename, and delete actions, by workspace-list changes, and by a 60s TTL that catches edits made outside the app; a same-roots rescan keeps the old list visible while it runs, a roots change resets it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tadata Review item 4 (XingYu-Zhong, PR KunAgent#1214). Only one background folder poll runs at a time: a tick that lands mid-scan is dropped rather than queued, and a poll adopts the current load token instead of bumping it, so a scan slower than the 4s interval still applies its own result — before, each new poll invalidated the previous one and a large workspace could refresh forever without ever landing — and a background poll can never discard a foreground load. The change signature now compares the whole projection minus builtAt instead of ids/degrees/edge-ids, so labels, subtitles, paths, timestamps, sizes, states, and counts all participate: a content-only change with identical topology refreshes the inspector instead of being treated as the same projection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review item 5 (XingYu-Zhong, PR KunAgent#1214). setGraph treated structure as node count plus newly seeded nodes, so adding or removing a wikilink — edges only — left a settled layout at alpha 0 and the new spring never pulled. The link set now contributes a canonical endpoint-pair signature (order- and direction-insensitive) to the structure comparison, so added, removed, or rewired edges reheat the layout while a reordered but identical edge list does not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rvive Review item 6 (XingYu-Zhong, PR KunAgent#1214). The rich editor restored [[wikilinks]] with a global regex that un-escaped every \[\[...\]\] in serialized output, which also rewrote brackets the author escaped on purpose — literal text silently became an active link on the next save. The distinction now lives in the document model. A WriteWikilink mark with a marked tokenizer matches bare [[target]] / [[target|label]] (the same shape the knowledge indexer extracts) at parse; source-escaped brackets never form a [[ token because marked's escape rule consumes each \[ first, so they stay plain text. On the way out the mark declares code: true, which the markdown manager already honours by writing covered text verbatim — marked brackets reach disk bare while unmarked brackets keep the default escaping. The menu applies the mark to the whole inserted reference, and an input rule marks a hand-typed [[...]] as its closing bracket lands. The covered text keeps its brackets, so nothing changes visually. Round-trip tests cover deliberately escaped brackets (serialization and the fidelity audit), the alias form, a document mixing real and escaped links, menu insertion, and the input rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review item 7 (XingYu-Zhong, PR KunAgent#1214). For an active file on C: and a target on D:, the segment-based relative walk produced ../../D:/notes/target — not a valid Windows path, so the link could never resolve. pathVolumeRoot identifies the volume of an absolute path (drive letter, UNC server/share pair, or '' for POSIX, case-insensitively) and ranking withholds targets on a different volume from the menu, since no relative path can reach them. buildWikilinkInsertion guards the same case for any other caller by emitting the absolute path instead of a broken .. walk. Windows cross-drive and UNC cases are covered by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review item 8 (XingYu-Zhong, PR KunAgent#1214). NodeGraphView.tsx and node-graph-analysis.ts each used a literal 0x00 byte as a join separator, which made git classify the TypeScript sources as binary — their diffs were invisible on GitHub and opaque to search and review tooling. The bytes are now written as '\u0000' escapes; runtime-identical strings, plain-text sources. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ink semantics Review follow-up (XingYu-Zhong, PR KunAgent#1214): the runtime and renderer docs now describe the folder-request bounds (route cap 64, service cap 12, concurrency 2, 1,600-file/96MB shared budget), the run-store scope and limit push-down, the shared deferred wikilink target service and its global scan budget and invalidation triggers, poll coalescing and the widened change signature, cross-volume link handling, and the schema-mark serialization that preserves deliberately escaped brackets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@XingYu-Zhong Thank you for the thorough review — every finding was real. All nine items are addressed on the updated head, each with regression tests. Point by point: Blocking issues1. Folder graph indexing now has a global resource budget —
2. WikiLink target discovery is deferred and shared —
3. Changed-file loading is scoped, capped, and deduplicated at the store —
Correctness issues4. Background refresh coalesces; the signature covers all metadata —
5. Edge-only topology changes reheat the simulation —
6. Deliberately escaped WikiLinks survive the rich editor —
7. Cross-drive WikiLinks are disabled with a valid fallback —
Reviewability and merge readiness8. NUL bytes removed —
9. Branch refreshed and revalidated — merge commit
Ready for another pass whenever you are. |
|
Thanks for the substantial follow up. I reviewed the latest head, 983f3ef, and most of the issues from my previous review have been addressed well. In particular, the following areas look fixed now: Changed file queries are scoped and limited before loading run snapshots. Thank you for working through those points. The implementation has improved significantly. I still found several issues that I think should be resolved before merge:
The shared file and byte budget is checked only after scanKnowledgeSources() has already completed. Directory traversal, readdir, stat, realpath, and file discovery therefore still run in full for every root. This is especially costly because the Work graph refreshes every four seconds. Even when a rebuild is refused by the budget, the next refresh can repeat the complete metadata scan. Please pass a scan budget into scanKnowledgeSources() itself, including limits for directories, entries, files, and metadata operations. Once the budget is exhausted, additional roots should not begin scanning.
loadKnowledgeBases() uses Promise.all() across every unique mount. This path has no root cap, concurrency limit, or shared scan budget. Opening the global Code graph can therefore trigger many knowledge base scans and background rebuilds at the same time. Please apply bounded concurrency and a shared request budget here as well.
requestWikilinkTargets() returns immediately whenever any scan is already in flight, before comparing the requested roots key. This can produce the following sequence: Root set A starts scanning. A similar problem occurs when invalidation happens during a scan. The completed scan unconditionally clears the stale flag, so the invalidation can be lost. Please add a generation token or requested key check before publishing, and schedule another scan when roots or invalidation change during an active request.
folderMountId() uses a 32 bit polynomial hash. This has practical collisions. For example, roots ending in Aa and BB produce the same hash. Since graph node IDs include this mount ID, files with the same relative path can be silently merged by the accumulator. Root deduplication is also lexical only. Equivalent paths such as /vault, /vault/., and a symlink to /vault may be indexed as separate roots. Parent and child roots can also duplicate the same files. Additionally, roots are sorted before applying the root cap. With more than 12 Work roots, the currently active workspace can be dropped from the default all workspaces graph. Please use a canonical physical path for identity, use a collision resistant stable digest, define behavior for nested roots, and always retain the active root when truncating.
Cross root resolution checks the generic URI scheme expression before checking isAbsolute(). A Windows path such as C:/notes/a.md matches the scheme expression because of C:, so it is discarded before absolute path handling runs. Please recognize drive letter and UNC absolute paths before applying the URI scheme filter. Path lookup keys should also use Windows case insensitive normalization.
The timeout only resolves the outer Promise.race(). It does not cancel the underlying runs.list() operation. If that promise never settles, it remains in changedFilesScans permanently. Every future refresh then reuses the same hung promise and times out again until the runtime restarts. Please support cancellation through an AbortSignal, or at minimum evict the timed out generation so a later request can start a fresh scan. A regression test with a never resolving run source would be useful. I also found a few lower severity issues: PageRank and clustering are synchronously recomputed for display and physics setting changes, which can make large graphs and search input noticeably slow. There are also merge readiness concerns outside the feature code: The branch has diverged from the current develop branch again. I am requesting changes for now. Once the six blocking issues are addressed, the branch is updated, CI is green, and the real application smoke test is completed, I will be happy to review it again. |
Review follow-up (XingYu-Zhong, PR KunAgent#1214, items 1-2). The shared budget was checked only after scanKnowledgeSources() had already completed, so directory traversal, readdir, stat, and realpath still ran in full for every root on every 4s Work-graph refresh. The budget now carries traversal allowances (directories, entries, metadata ops, discovered files) that the walk charges as it goes; bytes remain the rebuild's charge. A spent budget stops later roots before they touch the filesystem, a walk stopped mid-tree is discarded rather than trusted (its file list and fingerprint describe a partial tree), and the non-blocking readyIndex path — used by the Code graph's knowledge-base loads — passes the budget through its status inspection and into the background rebuilds it schedules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… links, scan eviction Review follow-up (XingYu-Zhong, PR KunAgent#1214, items 2 and 4-6): - loadKnowledgeBases() no longer fans out through an unbounded Promise.all: mounts are capped (24), loaded 2 at a time, and share one scan budget with a diagnostic plus truncated flag when it runs out. - Folder roots resolve to canonical physical paths (realpath), so /vault, /vault/., and a symlink to /vault are one root; Windows-style paths compare case-insensitively; a root nested inside another requested root merges into its ancestor; and the root cap keeps request order so the active workspace can never be the one truncated away. - folderMountId() digests the canonical identity with SHA-256 — the 32-bit polynomial hash collided on inputs as short as Aa/BB, silently merging same-named files from different roots. - Cross-root link resolution recognizes drive-letter and UNC absolute paths before the URI-scheme filter (C:/notes/a.md is a path, not a scheme), and path lookup keys fold case for Windows-style paths. - A timed-out changed-file scan is evicted from the in-flight map, so a run source that never settles cannot be reused by every later refresh until the runtime restarts. - includeChangedFiles is normalized once, so the cache key and the load can no longer disagree about an omitted flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… scans Review follow-up (XingYu-Zhong, PR KunAgent#1214, item 3 and the scan-visibility note). requestWikilinkTargets() returned as soon as any scan was in flight, before comparing the requested roots key — a request for workspace set B issued during set A's scan was silently discarded, and A's completion also cleared an invalidation that arrived mid-scan. The service now remembers the latest request, publishes a scan's result only for the roots it started with, and follows up with another scan when the requested key or the invalidation generation moved while it ran. Scans also stop swallowing what they could not see: caps, budgets, and unreadable directories mark the snapshot truncated with a failed-directory count, and both editors show a distinct 'the scan was partial' empty state so a partial walk cannot read as an empty or exhaustively searched vault. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… Work-graph controls Review follow-up (XingYu-Zhong, PR KunAgent#1214, lower-severity items). The visible subgraph — and the PageRank/cluster analysis hanging off it — was memoized on the whole settings object, so every display or physics slider tick recomputed all of it. The view now depends only on the filter fields (NodeGraphFilterSettings), and the search term feeds it through useDeferredValue so typing stays responsive on large graphs. The Work (folder) graph also stops offering Code-graph-only controls: the workspace/thread toggles and the changed-files toggle are withheld there, and toggling includeChangedFiles no longer forces a pointless folder rescan — the refetch happens only when a workspace projection is on screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce semantics Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… and the markdown manager
Found by smoke-testing the real Electron app, exactly the gap the review
flagged: the mounted rich editor listed its schema extensions separately from
the markdown manager's, so the WriteWikilink mark existed only on the
manager's side. Parsing a note containing [[wikilinks]] produced a mark the
editor schema rejected ('There is no mark type wikilink in this schema'),
tiptap discarded the content as invalid, and every such note opened blank in
Rich text mode — and saving from that state wrote the escaped form back.
The schema-bearing roster now comes from one builder used by both sides, with
the editor's per-instance local-image callbacks passed in as configuration. A
regression test mounts an editor from the shared roster and asserts it
accepts a manager-parsed wikilink document.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@XingYu-Zhong Thanks for the thorough second pass. All six blocking issues, the four lower-severity notes, and the merge-readiness items are resolved on the current head ( Blocking issues1. Scan budgets now bound the walk itself (32adb73) — 2. Code-graph knowledge-base loads are bounded (1959ddb) — 3. WikiLink target cache race (8706bab) — 4. Folder root identity and truncation (1959ddb) — roots resolve through 5. Windows absolute paths (1959ddb) — cross-root resolution recognizes drive-letter and UNC absolute paths before the URI-scheme filter, and path lookup keys fold case for Windows-style paths. Tested with 6. Timed-out changed-file scans (1959ddb) — a timeout evicts the scan from the in-flight map, so a run source that never settles cannot be reused by every later refresh. Regression test uses a never-resolving source and asserts the second refresh starts fresh. Lower-severity items
Merge readiness
The smoke test also caught a real bug the unit suites could not see — exactly the class you flagged: the mounted rich editor listed its schema extensions separately from the markdown manager's, so the new wikilink mark existed only on the manager side and every note containing a |
Summary
Adds Node Graph, a read-only Obsidian-style knowledge map on a new
nodeGraphcenter route, plus the
[[wikilink autocomplete that feeds it. One canvas showshow a vault actually hangs together: workspaces, conversations, knowledge-base
documents and their
[[wikilinks]], folders, sections, memories, tags, agents,and the files a Graph Mode run changed.
kun-node-graph-walkthrough.mp4
kun-node-graph.mp4
It is deliberately not Graph Mode. Graph Mode orchestrates multi-agent runs and
writes runtime state; Node Graph schedules nothing and mutates nothing — it only
projects what already exists. The two keep separate directories, contracts and
routes, and
AGENTS.mdnow records that boundary so the vocabularies do not merge.Why
Kun already stores the relationships — thread parents and forks, workspace mounts,
knowledge-base references, memory owners, tags — but there was no way to see them.
Answering "what links to this note?", "which conversations touch this workspace?",
or "is this document stranded?" meant opening surfaces one at a time. A projection
plus a canvas answers all three at once, and the same projection serves the Work
tab over a folder of markdown files.
Changes
Kun runtime
kun/src/contracts/node-graph.ts— the projection contract: 10 node kinds, 10edge kinds, counts, diagnostics, truncation.
kun/src/node-graph/— inputs, an idempotent accumulator with priority-tiertruncation, per-source collectors, the workspace builder, a multi-root folder
builder that resolves links across roots, and the caching service. The builder
performs no I/O and is deterministic for a given input.
kun/src/knowledge/— the indexer now recordsexternalReferences(links thatescape the base root, previously discarded, which is what left cross-workspace
links undrawn), keeps zero-byte notes as document nodes rather than skipping
them, and carries
birthtimeMs. Schema v2 → v3.readyIndexgained anon-demand freshness check so an edit is visible without a manual refresh.
GET /v1/node-graphandGET /v1/node-graph/folder, both behind the sameauthorize()bearer guard as every other route.Desktop bridge
src/shared/kun-endpoints.ts, allowlisted GET-only in theruntime:requestIPC schema.runtime:requestis an allowlist, not apassthrough — a renderer-side fetch of an unlisted path fails by design.
Renderer
src/renderer/src/node-graph/— types, runtime client, the Obsidian-style querylanguage (
kind:,path:,folder:,tag:,state:,workspace:, leading-to exclude), settings and the group model, the view filter, a hand-writtendeterministic force simulation (phyllotaxis seeding, grid-accelerated repulsion,
no
Math.random()), undirected PageRank / label-propagation clusters / BFSpaths, the store, folder auto-refresh, Kun style, and motion timing.
src/renderer/src/components/node-graph/— the 2D canvas renderer and painter,control rail, node-kind legend that doubles as the kind filter, group editor,
insights, inspector, top command row, overview map, zoom bar, and context menu.
AppRoute, store action, navigation controller, lazy stage,sidebar row, command palette entry.
the existing fallback.
Write /
[[autocomplete[[query detection, target ranking and insertion, a boundedcross-workspace markdown scan (depth 6, 200 dirs, 800 files per root), and one
shared menu view driving both editors — CodeMirror (live preview) and TipTap
(rich text). Keyboard and mouse selection, and links may point at a note in
another Work workspace.
Work tab
navigable without leaving Work.
Decisions worth a reviewer's attention
nodes can wear Kun artwork instead of a coloured silhouette, and this follows the
existing sidebar Focus toggle, whose own tooltip already reads "Focus mode:
quiet Kun animations". Focus off → icons on and the colour controls stand down;
Focus on → colour-and-shape encoding returns. One switch, one meaning, in both
places. Under Kun style the group editor, "Color by cluster", and the
right-click swatches are withheld rather than disabled, because each would
edit a colour nothing paints; the rail explains which switch brings them back.
data:URIs, not emitted as asset files. Thepackaged app loads the renderer from
file://, where every file is its ownopaque origin — drawing a
file://image taints the canvas and makestoBlobthrow, so Save as PNG would have broken the moment an icon was painted.
Verified in Chromium both ways; a test asserts the sources are
data:so aplain import cannot come back.
edge, palette-quantized: 104KB of asset instead of 716KB, and a 244KB lazy chunk
instead of 871KB. Worst mean channel difference against the original art at
34/120/240px is under 2%, alpha unchanged, and the
viewBox,<image>dimensions and
<use>offset are untouched so geometry is identical.icon box are inscribed in the node radius. Artwork drawn to the full diameter
would be visible in corners that cannot be clicked.
prefers-reduced-motionby collapsing to end state, and theframe loop still idles when nothing is animating rather than spinning a core.
docs/node-graph.md(contract and runtime) anddocs/node-graph-renderer.md(renderer) because together they exceeded the700-line file limit; splitting was preferred over compaction per repo hygiene.
Tests
New unit coverage, all deterministic:
folder projections, cross-root link resolution, service caching and freshness,
route auth and query handling, indexer schema v3 and empty-file handling
simulation determinism, centrality/clusters/paths, store transitions, auto
refresh, Kun style derivation, motion timing and reversal, painter output
(icon vs silhouette, hover halo, eased dimming, dash flow), shape geometry
inscribed in the hit radius, titlebar inset, IPC allowlist including negative
cases for POST/PATCH/DELETE
[[query detection, ranking, insertion text, bounded scan, menuplacement, and both editor integrations
Validation
npm run typecheck— clean (web, node, and kun projects)npm run build— succeeds, includingcheck:packaged-runtime-depsnpm run lint— 0 errors. The 26 warnings are all pre-existingreact-hooks/exhaustive-depswarnings in files this branch does not touch; everyfile this branch does touch passes at
--max-warnings=0.npm run check:file-lines— passes on 5,751 tracked text filesgit diff --check— cleannpm run test— 7,518 desktop tests pass. 19 test files fail, and all 19 arepre-existing. Verified by checking out a pristine worktree at the merge base
and running the same files there: the identical 19 fail with none of this
branch's changes. They are
runtime-data-dir-migration,git-service/git-checkpoint-service,storage-relocation, and design-canvas suites — noneof which this branch touches.
package.json,kun/package.jsonand both lockfiles areuntouched.
Not verified by me:
npm run devin the real Electron app. The recording wasproduced by driving the shipping
NodeGraphView, store, painter and[[extensionin Chromium with only
runtimeRequeststubbed, because an isolated Electron profilecannot take the runtime data directory while a dev instance owns it. The Work-tab
folder projection and the
[[menu are exercised end-to-end in that harness — anote is added, two links are written through the real menu, and the graph goes from
9 nodes/14 links to 10/17 with both
linkedges present — but a maintainer shouldstill click through it in a real build.
Notes
docs. Verified lossless: the tree hash after the final commit is byte-identical
to the validated worktree, and no commit imports a file a later commit adds.
develop.