Skip to content

feat(node-graph): Obsidian-style knowledge map - #1214

Open
MohamedWaelBishr wants to merge 84 commits into
KunAgent:developfrom
MohamedWaelBishr:codex/node-graph
Open

feat(node-graph): Obsidian-style knowledge map#1214
MohamedWaelBishr wants to merge 84 commits into
KunAgent:developfrom
MohamedWaelBishr:codex/node-graph

Conversation

@MohamedWaelBishr

@MohamedWaelBishr MohamedWaelBishr commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Node Graph, a read-only Obsidian-style knowledge map on a new nodeGraph
center route, plus the [[ wikilink autocomplete that feeds it. One canvas shows
how 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.md now 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, 10
    edge kinds, counts, diagnostics, truncation.
  • kun/src/node-graph/ — inputs, an idempotent accumulator with priority-tier
    truncation, 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 records externalReferences (links that
    escape 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. readyIndex gained an
    on-demand freshness check so an edit is visible without a manual refresh.
  • GET /v1/node-graph and GET /v1/node-graph/folder, both behind the same
    authorize() bearer guard as every other route.

Desktop bridge

  • Endpoint paths in src/shared/kun-endpoints.ts, allowlisted GET-only in the
    runtime:request IPC schema. runtime:request is an allowlist, not a
    passthrough — a renderer-side fetch of an unlisted path fails by design.

Renderer

  • src/renderer/src/node-graph/ — types, runtime client, the Obsidian-style query
    language (kind:, path:, folder:, tag:, state:, workspace:, leading
    - to exclude), settings and the group model, the view filter, a hand-written
    deterministic force simulation (phyllotaxis seeding, grid-accelerated repulsion,
    no Math.random()), undirected PageRank / label-propagation clusters / BFS
    paths, 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.
  • Route wiring: AppRoute, store action, navigation controller, lazy stage,
    sidebar row, command palette entry.
  • English and Chinese copy authored in full; other locales inherit English through
    the existing fallback.

Write / [[ autocomplete

  • Caret-scoped [[ query detection, target ranking and insertion, a bounded
    cross-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

  • The same graph over a folder of markdown files, so a vault of notes is
    navigable without leaving Work.

Decisions worth a reviewer's attention

  • Kun style has no switch of its own. Workspace, thread, folder and document
    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.
  • The artwork is inlined as data: URIs, not emitted as asset files. The
    packaged app loads the renderer from file://, where every file is its own
    opaque origin — drawing a file:// image taints the canvas and makes toBlob
    throw, 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 a
    plain import cannot come back.
  • The artwork is sized for what is drawn. Source PNGs are 256px on the long
    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.
  • Hit testing stays a single radius comparison, so every silhouette and the
    icon box are inscribed in the node radius. Artwork drawn to the full diameter
    would be visible in corners that cannot be clicked.
  • Motion honours prefers-reduced-motion by collapsing to end state, and the
    frame loop still idles when nothing is animating rather than spinning a core.
  • Docs are split into docs/node-graph.md (contract and runtime) and
    docs/node-graph-renderer.md (renderer) because together they exceeded the
    700-line file limit; splitting was preferred over compaction per repo hygiene.

Tests

New unit coverage, all deterministic:

  • runtime: contract parsing, accumulator truncation and idempotence, workspace and
    folder projections, cross-root link resolution, service caching and freshness,
    route auth and query handling, indexer schema v3 and empty-file handling
  • renderer: query language, settings persistence and clamping, view filter,
    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
  • write: [[ query detection, ranking, insertion text, bounded scan, menu
    placement, and both editor integrations

Validation

  • npm run typecheck — clean (web, node, and kun projects)
  • npm run build — succeeds, including check:packaged-runtime-deps
  • npm run lint — 0 errors. The 26 warnings are all pre-existing
    react-hooks/exhaustive-deps warnings in files this branch does not touch; every
    file this branch does touch passes at --max-warnings=0.
  • npm run check:file-lines — passes on 5,751 tracked text files
  • git diff --check — clean
  • feature-scoped vitest — 994 desktop tests and 75 Kun tests pass
  • npm run test — 7,518 desktop tests pass. 19 test files fail, and all 19 are
    pre-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 — none
    of which this branch touches.
  • No dependency changes: package.json, kun/package.json and both lockfiles are
    untouched.

Not verified by me: npm run dev in the real Electron app. The recording was
produced by driving the shipping NodeGraphView, store, painter and [[ extension
in Chromium with only runtimeRequest stubbed, because an isolated Electron profile
cannot 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 — a
note is added, two links are written through the real menu, and the graph goes from
9 nodes/14 links to 10/17 with both link edges present — but a maintainer should
still click through it in a real build.

Notes

  • 66 commits, ordered runtime → bridge → renderer logic → components → wiring →
    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.
  • Targets develop.
  • No linked issue; this is a new feature rather than a fix.

MohamedWaelBishr and others added 30 commits August 19, 2026 11:15
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>
MohamedWaelBishr and others added 9 commits August 19, 2026 11:15
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 XingYu-Zhong left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
    export async function getNodeGraphFolder(
    service: NodeGraphService | undefined,
    request: Request
    ): Promise<JsonResponse> {
    if (!service) return ERRORS.unavailable('node graph projection is not available')
    const params = new URL(request.url).searchParams
    const roots = params.getAll('root').map((root) => root.trim()).filter(Boolean)
    if (roots.length === 0) return ERRORS.validation('a root query parameter is required')
    try {
    const projection = await service.projectFolder(roots, {
    refresh: params.get('refresh') === 'true'
    })
  • Service:
    async projectFolder(
    roots: readonly string[],
    options: { refresh?: boolean } = {}
    ): Promise<NodeGraphProjection> {
    const unique = [...new Set(roots.map((root) => root.trim()).filter(Boolean))].sort()
    const key = `folder|${unique.join('|')}`
    const ttl = this.options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS
    const cached = this.cache.get(key)
    if (!options.refresh && cached && Date.now() - cached.at < ttl) return cached.projection
    const service = this.options.knowledgeBaseService
    const diagnostics: string[] = []
    let loaded: NodeGraphFolderRoot[] = []
    if (!service) {
    diagnostics.push('folder indexing is not available in this runtime')
    } else {
    loaded = await Promise.all(unique.map(async (root) => {
    try {
    // Folder projections always verify freshness: this view exists to
    // reflect files on disk, and the projection cache above already keeps
    // repeated opens from re-scanning.
    const result = await service.readyFolderIndex(root, folderMountId(root), {
    verifyFreshness: true
    })
    return { root, index: result.index, ...(result.state ? { state: result.state } : {}) }
    } catch (error) {
    diagnostics.push(`folder index failed for "${root}": ${errorText(error)}`)
    return { root, index: null }
    }
    }))

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:
    export function useWikilinkTargets(): WikilinkTargetsHandle {
    const workspaceRoots = useWriteWorkspaceStore((state) => state.workspaceRoots)
    const [targets, setTargets] = useState<readonly WikilinkTarget[]>([])
    const [scanning, setScanning] = useState(false)
    const [error, setError] = useState<string | null>(null)
    const scanningRef = useRef(false)
    const scannedKeyRef = useRef('')
    const roots = useMemo<WikilinkScanRoot[]>(
    () => workspaceRoots.map((root) => ({ root, name: workspaceName(root) })),
    [workspaceRoots]
    )
    const rootsKey = useMemo(() => roots.map((entry) => entry.root).join(' '), [roots])
    const invalidate = useCallback(() => {
    scannedKeyRef.current = ''
    setTargets([])
    setError(null)
    }, [])
    // A workspace added or removed invalidates the cache; the next open rescans.
    useEffect(() => {
    invalidate()
    }, [invalidate, rootsKey])
    const request = useCallback(() => {
    if (scanningRef.current || scannedKeyRef.current === rootsKey) return
    const api = window.kunGui
    if (typeof api?.listWorkspaceDirectory !== 'function') {
    setError('workspace listing is unavailable')
    return
    }
    if (roots.length === 0) {
    setError('no Work workspace is open')
    return
    }
    scanningRef.current = true
    setScanning(true)
    setError(null)
    void scanAllWorkspaceMarkdown(roots, (input) => api.listWorkspaceDirectory(input))
    .then((found) => {
    scannedKeyRef.current = rootsKey
    setTargets(found)
    })
    .catch((scanError: unknown) => {
    // Swallowing this made a broken scan look identical to an empty vault.
    setError(scanError instanceof Error ? scanError.message : String(scanError))
    })
    .finally(() => {
    scanningRef.current = false
    setScanning(false)
    })
    }, [roots, rootsKey])
    // Scan as soon as an editor mounts, so the list is usually ready before the
    // first `[[` rather than arriving after it.
    useEffect(() => {
    request()
    }, [request])
  • Multi-root scan:
    export async function scanAllWorkspaceMarkdown(
    roots: readonly WikilinkScanRoot[],
    list: WikilinkDirectoryLister,
    limits: WikilinkScanLimits = DEFAULT_WIKILINK_SCAN_LIMITS
    ): Promise<WikilinkTarget[]> {
    const collected: WikilinkTarget[] = []
    const seenRoots = new Set<string>()
    for (const scanRoot of roots) {
    const key = toPosix(scanRoot.root).replace(/\/+$/, '')
    if (!key || seenRoots.has(key)) continue
    seenRoots.add(key)
    collected.push(...await scanWorkspaceMarkdown(scanRoot, list, limits))
    }
    return collected

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:
    private async loadChangedFiles(
    threads: readonly ThreadSummary[],
    diagnostics: string[]
    ): Promise<NodeGraphChangedFilesInput[]> {
    const runs = this.options.runs
    if (!runs) return []
    const workspaceOf = new Map(threads.map((thread) => [thread.id, thread.workspace]))
    const timeoutMs = this.options.changedFilesTimeoutMs ?? DEFAULT_CHANGED_FILES_TIMEOUT_MS
    let timer: ReturnType<typeof setTimeout> | undefined
    try {
    // The scan keeps running after a timeout, so its rejection must be
    // absorbed here or it surfaces as an unhandled rejection later.
    const scan = runs.list().catch((error: unknown) => {
    throw error instanceof Error ? error : new Error(String(error))
    })
    scan.catch(() => undefined)
    const listed = await Promise.race([
    scan,
    new Promise<null>((resolve) => {
    timer = setTimeout(() => resolve(null), timeoutMs)
    })
    ])
    if (listed === null) {
    diagnostics.push(`changed-file scan exceeded ${timeoutMs}ms and was skipped`)
    return []
    }
    const byThread = new Map<string, Set<string>>()
    for (const run of [...listed]
    .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
    .slice(0, this.options.maxRuns ?? DEFAULT_MAX_RUNS)) {
    if (!workspaceOf.has(run.threadId)) continue
    const files = run.summary?.changedFiles ?? []
    if (files.length === 0) continue
    const bucket = byThread.get(run.threadId) ?? new Set<string>()
    for (const file of files) bucket.add(file)
    byThread.set(run.threadId, bucket)
    }
    return [...byThread.entries()].map(([threadId, files]) => ({
    threadId,
    ...(workspaceOf.get(threadId) ? { workspace: workspaceOf.get(threadId)! } : {}),
    files: [...files]
    }))
    } catch (error) {
    diagnostics.push(`changed-file scan failed: ${errorText(error)}`)
    return []
    } finally {
    if (timer) clearTimeout(timer)
  • Run store implementation:
    async list(filter: GraphRunListFilter = {}): Promise<GraphRunV1[]> {
    await this.ensureRoot()
    const runs: GraphRunV1[] = []
    const candidates = await this.index.candidates(filter)
    for (const entry of candidates) {
    const run = await this.get(entry.runId).catch((error) => {
    const diagnostic = diagnosticForStoreError(entry.runId, error)
    this.issues.set(entry.runId, diagnostic)
    return null
    })
    if (!run) continue
    this.issues.delete(entry.runId)
    runs.push(run)
    }
    return runs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id))

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:
    * Cheap structural comparison: node ids with their degree, plus edge ids.
    * `builtAt` changes on every build, so comparing whole projections would always
    * report a difference and defeat the purpose.
    */
    function projectionSignature(projection: NodeGraphProjection): string {
    const nodes = projection.nodes.map((node) => `${node.id}:${node.degree}`).join('|')
    const edges = projection.edges.map((edge) => edge.id).join('|')
    return `${nodes}#${edges}#${projection.truncated}#${projection.diagnostics.join('~')}`
    }
    function sameProjection(left: NodeGraphProjection, right: NodeGraphProjection): boolean {
    if (left.nodes.length !== right.nodes.length) return false
    if (left.edges.length !== right.edges.length) return false
    return projectionSignature(left) === projectionSignature(right)
    }
  • Folder load/token handling:
    loadFolder: async (roots, options = {}) => {
    const token = ++loadToken
    const background = options.background === true
    // A background poll must not flip the UI into its loading state, or the
    // refresh button would spin every few seconds for no user-visible reason.
    set(background
    ? { source: { kind: 'folder', roots } }
    : { status: 'loading', error: null, source: { kind: 'folder', roots } })
    try {
    const projection = await fetchNodeGraphFolder(roots, {
    ...(options.refresh ? { refresh: true } : {})
    })
    if (token !== loadToken) return
    // An unchanged projection keeps its existing object, so nothing
    // downstream recomputes and the force layout never twitches on a poll
    // that found no edits.
    if (background && sameProjection(get().projection, projection)) {
    set({ status: 'ready', error: null })
    return
    }
    const present = new Set(projection.nodes.map((node) => node.id))
    const keep = (id: string | null): string | null => (id && present.has(id) ? id : null)
    const { selectedNodeId, focusNodeId, pathFrom, pathTo } = get()
    set({
    projection,
    status: 'ready',
    error: null,
    selectedNodeId: keep(selectedNodeId),
    focusNodeId: keep(focusNodeId),
    pathFrom: keep(pathFrom),
    pathTo: keep(pathTo)
    })
  • Refresh interval:
    export function useNodeGraphAutoRefresh({
    enabled,
    intervalMs = NODE_GRAPH_AUTO_REFRESH_MS,
    onRefresh
    }: Options): void {
    useEffect(() => {
    if (!enabled || typeof window === 'undefined') return
    let timer = 0
    const visible = (): boolean =>
    typeof document === 'undefined' || document.visibilityState !== 'hidden'
    const stop = (): void => {
    if (timer) window.clearInterval(timer)
    timer = 0
    }
    const start = (): void => {
    if (timer || !visible()) return
    timer = window.setInterval(() => {
    if (visible()) onRefresh()
    }, Math.max(1_000, intervalMs))
    }
    const onVisibilityChange = (): void => {
    if (visible()) {
    // Catch up immediately on return rather than waiting a full interval.
    onRefresh()
    start()
    } else stop()
    }
    start()
    document.addEventListener('visibilitychange', onVisibilityChange)
    return () => {
    stop()
    document.removeEventListener('visibilitychange', onVisibilityChange)
    }
    }, [enabled, intervalMs, onRefresh])

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:
    const degrees = new Map<string, number>()
    for (const edge of edges) {
    degrees.set(edge.from, (degrees.get(edge.from) ?? 0) + 1)
    degrees.set(edge.to, (degrees.get(edge.to) ?? 0) + 1)
    }
    this.links = []
    for (const edge of edges) {
    const source = nextById.get(edge.from)
    const target = nextById.get(edge.to)
    if (!source || !target || source === target) continue
    const sourceDegree = degrees.get(edge.from) ?? 1
    const targetDegree = degrees.get(edge.to) ?? 1
    this.links.push({
    source,
    target,
    bias: sourceDegree / (sourceDegree + targetDegree)
    })
    }
    const structureChanged = next.length !== previous.size || seeded > 0
    this.nodes = next
    this.byId = nextById
    if (structureChanged) this.reheat(1)
    }

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:
    return getWriteMarkdownManager().parse(markdown)
    }
    /**
    * `@tiptap/markdown` escapes every `[` it writes as plain text, so a
    * `[[wikilink]]` typed through the rich editor's `[[` menu reaches disk with a
    * backslash before every bracket — a form the knowledge indexer never matches,
    * so the link is silently never drawn in the node graph. Restore the bare form
    * on the way out. The same pass runs inside the fidelity audit, so a note
    * that already contains wikilinks round-trips cleanly and stays rich-editable
    * instead of being pushed into CodeMirror as "unstable".
    */
    const ESCAPED_WIKILINK_PATTERN = /\\\[\\\[([^\]\n]*?)\\\]\\\]/g
    export function restoreWikilinkBrackets(markdown: string): string {
    return markdown.replace(ESCAPED_WIKILINK_PATTERN, '[[$1]]')
    }
    function serializeDocument(manager: MarkdownManager, doc: JSONContent): string {
    return restoreWikilinkBrackets(manager.serialize(doc))
    }
    export function serializeWriteMarkdown(doc: JSONContent): string {
    return serializeDocument(getWriteMarkdownManager(), doc)
    }
    function collectPlainText(node: JSONContent | undefined, acc: string[]): string[] {
    if (!node) return acc

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:
    export function buildWikilinkInsertion(
    target: WikilinkTarget,
    context: WikilinkInsertionContext
    ): string {
    const activeDirectory = directoryOf(
    workspaceRelativePath(context.workspaceRoot, context.activePath)
    )
    if (target.workspaceRoot === context.workspaceRoot) {
    return shortenMarkdownPath(relativePathFrom(activeDirectory, target.relativePath))
    }
    const fromAbsolute = [toPosix(context.workspaceRoot).replace(/\/+$/, ''), activeDirectory]
    .filter(Boolean)
    .join('/')
    const toAbsolute = [toPosix(target.workspaceRoot).replace(/\/+$/, ''), target.relativePath]
    .filter(Boolean)
    .join('/')
    return shortenMarkdownPath(relativePathFrom(fromAbsolute, toAbsolute))

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.tsx
  • src/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:

  1. Update the branch to the latest develop.
  2. Add regression tests for the cases above.
  3. Run typecheck, the relevant desktop/Kun test suites, and packaging checks on the updated head.
  4. 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.

MohamedWaelBishr and others added 9 commits August 27, 2026 17:01
…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>
@MohamedWaelBishr

Copy link
Copy Markdown
Contributor Author

@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 issues

1. Folder graph indexing now has a global resource budgetb34ccee6

  • The route rejects more than 64 root parameters outright (400); below that, NodeGraphService projects at most 12 roots, dropping the rest with a diagnostic and truncated: true.
  • One shared scan budget per request (1,600 files / 96 MB) is threaded through readyFolderIndex → ensureIndex → buildOrLoad, layered over the indexer's per-root caps. It is charged only when a rebuild is actually due — a fingerprint match reuses the stored index for free — so cached workspaces stay cheap.
  • A root refused by the budget serves its last built index (state stale) with a scan budget reached diagnostic, and deliberately does not fall through to the status-inspection path, which would have scheduled the same work as an unbudgeted background rebuild.
  • Promise.all is replaced by an order-preserving worker pool (2 concurrent roots).
  • Tests: root cap + diagnostic + truncated, shared-budget identity across roots, concurrency ceiling, budget-exhaustion diagnostics (node-graph-service.test.ts); budget refusal/charging/fingerprint-free-pass (knowledge-base-service.test.ts); the 65-root 400 (routes/node-graph.test.ts).

2. WikiLink target discovery is deferred and shared95fe163b

  • The mount-time effect is gone. The scan starts the first time a menu asks for completions (onRequestTargets, which both editors already fire on [[). A test renders the hook and asserts zero IPC calls on mount.
  • The cache moved out of the hook into a module-level service (wikilink-target-service.ts) shared by every mounted editor — any number of editor groups pay for one walk, and concurrent requests share the in-flight scan.
  • The walk now has a global budget across roots (800 directories / 3,200 files) on top of the 200/800 per-root caps.
  • Invalidation: workspace-list changes, file create/rename/delete actions, and a 60 s TTL for edits made outside the app.

3. Changed-file loading is scoped, capped, and deduplicated at the storeb34ccee6

  • GraphRunListFilter gained threadIds and limit, applied to the index entries before any snapshot is read — the store never loads unrelated or older runs (asserted with a spy on snapshot loads).
  • This also fixes the correctness bug you called out: the newest-40 slice is now taken after the thread scope, so runs in another workspace can no longer crowd out the selected workspace's older runs (regression test with newer foreign runs).
  • Concurrent projections of the same scope share one in-flight scan; the 2.5 s time-box remains, now bounding work that is itself capped at limit snapshot loads.
  • The manager store's list schema accepts the new fields, so a remote runtime isn't silently stripped.

Correctness issues

4. Background refresh coalesces; the signature covers all metadatac5d12f30

  • Only one folder poll runs at a time; a tick landing mid-scan is dropped, not queued. A poll adopts the current load token instead of bumping it, so a scan slower than the 4 s interval still applies its own result, and a poll can never discard a foreground load (all three behaviors tested).
  • sameProjection now compares the whole projection minus builtAt — labels, subtitles, paths, timestamps, sizes, states, counts, diagnostics all participate — so a content-only change with identical topology refreshes the inspector (tested with a same-id renamed node).

5. Edge-only topology changes reheat the simulationf51a3fc1

  • setGraph now folds a canonical endpoint-pair signature of the link set into structureChanged. Your repro is the test: settle to alpha = 0, reapply the same nodes with one new edge → settled is false, tick() moves, and the linked pair ends closer. Removal/rewiring reheat too; the same edge list in a different order does not.

6. Deliberately escaped WikiLinks survive the rich editor3dfbc052

  • The global un-escaping regex is gone. Wikilinks are now a schema-level mark: a marked tokenizer matches bare [[target]] / [[target|label]] at parse (source-escaped \[\[ never forms a [[ token, so it stays plain text), and the mark's covered text serializes verbatim while unmarked brackets keep the default escaping.
  • The [[ menu applies the mark to the inserted reference; an input rule marks hand-typed links as ]] lands. Visually nothing changes — the marked text keeps its brackets.
  • The round-trip test you asked for is in: \[\[not-a-link\]\] serializes escaped, passes the fidelity audit, and a document mixing real and escaped links keeps both faithful.

7. Cross-drive WikiLinks are disabled with a valid fallback9641dcc0

  • pathVolumeRoot identifies the volume of an absolute path (drive letter, UNC //server/share, '' for POSIX, case-insensitive). Ranking withholds targets on a different volume from the menu — no relative path can reach them — and buildWikilinkInsertion guards any other caller by emitting the absolute path instead of ../../D:/notes/target. Windows cross-drive and UNC tests included.

Reviewability and merge readiness

8. NUL bytes removed42fb9d1a

  • Both separators are now written as '\u0000' source escapes (runtime-identical). git diff against develop shows both files as text (+429/+260 lines); GitHub will render them.

9. Branch refreshed and revalidated — merge commit 4829e69f

  • Merged current develop (was 125 commits behind by the time I got to it) and revalidated on the updated head:
    • npm run typecheck — clean (web, node, kun)
    • npm run lint on every touched file at --max-warnings=0 — clean
    • npm run check:file-lines — passes
    • feature-scoped vitest — all green (446 renderer node-graph / wikilink / TipTap tests across 28 files; 89 Kun node-graph / knowledge / route / run-store tests; 17 manager shared-data-store tests)
    • full desktop suite — 8,370 tests, 8,248 passed. 56 failures across 50 files, all pre-existing: I checked out a pristine worktree at the current develop head and ran the same 50 files there — 48 fail identically with none of this branch’s changes, and the remaining 2 (git-service, git-checkpoint-service) are load-flaky under the full parallel run and pass in isolation on this branch (32/32). None of the 50 touch node-graph, wikilink, TipTap, knowledge, or graph-run code.
    • npm run build including check:packaged-runtime-deps — succeeds (30 external packages verified in the production dependency graph)
  • Caveat, stated honestly: I still could not click through the real Electron app from this environment (the runtime data directory is owned by the dev instance, per the same constraint noted in the PR description). The Chromium harness previously recorded drives the shipping components end-to-end, and every behavior changed here is unit-tested, but a maintainer smoke-test of the Work graph and both Write editors in a real build remains worthwhile — particularly on Windows with a second drive, which I cannot exercise on this machine.

Ready for another pass whenever you are.

@XingYu-Zhong

Copy link
Copy Markdown
Collaborator

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.
Concurrent changed file scans are deduplicated.
Background folder refreshes are coalesced and no longer continuously invalidate each other.
Projection equality now includes user visible metadata.
Edge only changes reheat the force simulation.
Escaped WikiLinks are preserved through a schema level mark.
The TypeScript files containing literal NUL bytes are text files again.

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:

  1. Folder scan budgets are applied after the expensive filesystem scan

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.

  1. The Code graph still loads knowledge bases with unbounded concurrency

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.

  1. The shared WikiLink target cache has an async race

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.
The workspace set changes to B.
B requests targets, but the request is discarded because A is still running.
A completes and publishes targets for A into the global snapshot.
No scan for B is automatically scheduled.

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.

  1. Folder root identity and truncation still have correctness problems

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.

  1. Windows absolute paths are still rejected as URL schemes

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.

  1. A timed out changed file scan can become permanently stuck

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.
WikiLink scans silently swallow unreadable directories and truncation, so the UI cannot distinguish an empty workspace from a partial or failed scan.
The Work graph still shows Code graph only controls, including changed files. Toggling that option causes an unnecessary folder rescan and changes a globally persisted setting.
The default includeChangedFiles behavior and its cache key use different semantics when the option is omitted.

There are also merge readiness concerns outside the feature code:

The branch has diverged from the current develop branch again.
GitHub currently reports the PR as unmergeable.
The latest Windows and Linux packaging jobs are failing.
The feature still has not been smoke tested in the real Electron application.

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.

MohamedWaelBishr and others added 7 commits August 29, 2026 17:54
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>
@MohamedWaelBishr

Copy link
Copy Markdown
Contributor Author

@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 (2102be7f). Ready for another review.

Blocking issues

1. Scan budgets now bound the walk itself (32adb73) — KnowledgeScanBudget gained traversal allowances (directories, entries, metadata ops, discovered files) that scanKnowledgeSources() charges as it walks; bytes stay the rebuild's charge. Once the allowance is spent, later roots are not scanned at all, and a walk stopped mid-tree is discarded rather than trusted (a partial file list would produce a wrong fingerprint). Defaults per request: 1,600 dirs / 40,000 entries / 6,400 stat+realpath ops / 1,600 files / 96MB.

2. Code-graph knowledge-base loads are bounded (1959ddb) — loadKnowledgeBases() now caps mounts (24), loads them through the same bounded-concurrency helper (2 at a time), and hands every mount one shared scan budget. The budget flows through the non-blocking readyIndex path too: the status inspection's stat pass charges it, and the background rebuilds it schedules keep charging it, so one projection load can no longer fan out into unbounded off-request work. Exhaustion surfaces as a diagnostic plus truncated: true.

3. WikiLink target cache race (8706bab) — requestWikilinkTargets() now records the latest requested roots; a completing scan publishes only for the set it started with and re-checks that key, so a request for set B issued during set A's scan is followed up instead of dropped. Invalidations carry a generation counter: one that lands mid-scan survives the scan's completion and triggers one more pass. Both races have regression tests.

4. Folder root identity and truncation (1959ddb) — roots resolve through realpath to a canonical physical identity (/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 with a diagnostic; the root cap keeps request order, so the active workspace (sent first by the renderer) can never be the root truncated away; and folderMountId is now a truncated SHA-256 of the canonical identity — the Aa/BB collision is a regression test.

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 D:/notes/target.md, d:\Notes\Target.md, and URL targets. (Tested at unit level — I don't have a Windows machine for a cross-drive app smoke, though the Windows packaging job is green.)

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

  • Analysis recompute (f7a03bb) — the visible subgraph (and PageRank/clustering) is memoized on the filter fields only (NodeGraphFilterSettings), so display/physics slider ticks cost nothing; the search term feeds the memo through useDeferredValue so typing stays responsive.
  • WikiLink scan visibility (8706bab) — 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.
  • Work-graph controls (f7a03bb) — the Work graph no longer shows the workspace/thread toggles or the changed-files toggle, and flipping includeChangedFiles no longer rescans a folder projection (verified in the running app).
  • includeChangedFiles semantics (1959ddb) — normalized once, so an omitted flag and an explicit true share one cache key and one behavior.

Merge readiness

  • Branch is merged with the current develop; GitHub reports the PR mergeable.
  • All four packaging checks pass on this head (Windows NSIS, Linux x64, Linux ARM64, macOS).
  • Typecheck plus the targeted suites are green: 709 renderer tests and 94 kun tests across node-graph, knowledge, and write/wikilink.
  • Smoke-tested in the real Electron app (macOS, dev flavor, isolated runtime): Code graph route, the Work graph over two workspaces (folder nesting, wikilink edges, insights, truncation banner), and the [[ menu in both editors — deferred scan, ranked rows including nested paths, self-file exclusion, insertion, and Enter/click accept.

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 [[wikilink]] opened blank in Rich mode (There is no mark type wikilink in this schema). Fixed by sharing one extension roster between the manager and the editor (2102be7), with a lockstep regression test that mounts an editor from the shared roster and feeds it a manager-parsed wikilink document.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants