pmx-canvas 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/extensions/pmx-canvas/runtime.json +1 -1
- package/.github/extensions/pmx-canvas/tools.json +601 -22
- package/CHANGELOG.md +49 -0
- package/Readme.md +131 -532
- package/dist/canvas/global.css +2724 -476
- package/dist/canvas/index.js +168 -107
- package/dist/canvas/mcp-app.html +2899 -596
- package/dist/json-render/index.js +20 -20
- package/dist/types/client/canvas/BarHint.d.ts +12 -2
- package/dist/types/client/canvas/BoardLinkDialog.d.ts +3 -1
- package/dist/types/client/canvas/BoardPinButton.d.ts +10 -0
- package/dist/types/client/canvas/BoardSwitcher.d.ts +1 -1
- package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
- package/dist/types/client/canvas/CommandBar.d.ts +3 -4
- package/dist/types/client/canvas/ConnectionBanner.d.ts +2 -1
- package/dist/types/client/canvas/ContextChip.d.ts +3 -0
- package/dist/types/client/canvas/CrossBoardReferences.d.ts +5 -0
- package/dist/types/client/canvas/EdgeLayer.d.ts +14 -3
- package/dist/types/client/canvas/EmptyState.d.ts +4 -4
- package/dist/types/client/canvas/HomeGraphView.d.ts +2 -0
- package/dist/types/client/canvas/HomeMapView.d.ts +1 -0
- package/dist/types/client/canvas/HomeReviewView.d.ts +6 -0
- package/dist/types/client/canvas/HomeView.d.ts +9 -7
- package/dist/types/client/canvas/KnowledgeDialog.d.ts +4 -0
- package/dist/types/client/canvas/LifecycleChrome.d.ts +5 -0
- package/dist/types/client/canvas/LifecycleFields.d.ts +7 -0
- package/dist/types/client/canvas/LifecyclePanel.d.ts +1 -0
- package/dist/types/client/canvas/MemoryInspector.d.ts +7 -0
- package/dist/types/client/canvas/NearPinLayer.d.ts +14 -0
- package/dist/types/client/canvas/NodeContextMark.d.ts +40 -7
- package/dist/types/client/canvas/RelationPresetsDialog.d.ts +4 -0
- package/dist/types/client/canvas/RelationsPanel.d.ts +4 -0
- package/dist/types/client/canvas/SessionPanel.d.ts +2 -0
- package/dist/types/client/canvas/canvas-area.d.ts +1 -1
- package/dist/types/client/canvas/graph-geometry.d.ts +27 -0
- package/dist/types/client/canvas/graph-model.d.ts +37 -0
- package/dist/types/client/canvas/memory-layout.d.ts +23 -0
- package/dist/types/client/canvas/use-chrome-height.d.ts +2 -0
- package/dist/types/client/canvas/use-focus-trap.d.ts +1 -0
- package/dist/types/client/canvas/use-memory-graph.d.ts +8 -0
- package/dist/types/client/canvas/use-pan-zoom.d.ts +4 -1
- package/dist/types/client/icons.d.ts +0 -10
- package/dist/types/client/nodes/AxStepControls.d.ts +1 -1
- package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +4 -4
- package/dist/types/client/nodes/MarkdownNode.d.ts +5 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +4 -4
- package/dist/types/client/nodes/viewer-runtime-state.d.ts +1 -1
- package/dist/types/client/state/boards-store.d.ts +81 -9
- package/dist/types/client/state/canvas-store.d.ts +14 -1
- package/dist/types/client/state/context-chip-store.d.ts +22 -0
- package/dist/types/client/state/context-status-store.d.ts +35 -0
- package/dist/types/client/state/intent-bridge.d.ts +11 -8
- package/dist/types/client/state/knowledge-store.d.ts +22 -0
- package/dist/types/client/state/near-pin-store.d.ts +44 -1
- package/dist/types/client/state/presence-store.d.ts +4 -4
- package/dist/types/client/state/presentation.d.ts +28 -0
- package/dist/types/client/state/relation-store.d.ts +10 -0
- package/dist/types/client/state/session-store.d.ts +41 -2
- package/dist/types/client/state/workspace-settings.d.ts +7 -0
- package/dist/types/client/types.d.ts +13 -1
- package/dist/types/client/utils/keyboard.d.ts +2 -0
- package/dist/types/client/utils/time-ago.d.ts +4 -0
- package/dist/types/mcp/context-uri-template.d.ts +3 -1
- package/dist/types/server/agent-presence.d.ts +10 -3
- package/dist/types/server/ax-context.d.ts +1 -1
- package/dist/types/server/canvas-db.d.ts +30 -7
- package/dist/types/server/canvas-operations.d.ts +24 -2
- package/dist/types/server/canvas-provenance.d.ts +22 -2
- package/dist/types/server/canvas-serialization.d.ts +2 -0
- package/dist/types/server/canvas-state.d.ts +93 -15
- package/dist/types/server/canvas-validation.d.ts +1 -1
- package/dist/types/server/context-brief.d.ts +94 -6
- package/dist/types/server/context-reads.d.ts +33 -7
- package/dist/types/server/edit-summary.d.ts +20 -0
- package/dist/types/server/index.d.ts +71 -10
- package/dist/types/server/knowledge-lifecycle.d.ts +221 -0
- package/dist/types/server/lifecycle-status.d.ts +10 -0
- package/dist/types/server/memory-graph.d.ts +9 -0
- package/dist/types/server/mutation-history.d.ts +2 -0
- package/dist/types/server/operations/ops/ax-shared.d.ts +1 -1
- package/dist/types/server/operations/ops/boards.d.ts +2 -1
- package/dist/types/server/operations/ops/lifecycle.d.ts +2 -0
- package/dist/types/server/operations/ops/memory-graph.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +5 -0
- package/dist/types/server/operations/ops/query.d.ts +23 -0
- package/dist/types/server/operations/registry.d.ts +1 -0
- package/dist/types/server/placement.d.ts +7 -0
- package/dist/types/server/scope-fence.d.ts +2 -1
- package/dist/types/server/spatial-analysis.d.ts +6 -3
- package/dist/types/server/tour-control.d.ts +32 -0
- package/dist/types/server/wiki-links.d.ts +14 -0
- package/dist/types/server/workbench-auth.d.ts +2 -0
- package/dist/types/shared/agent-context.d.ts +2 -0
- package/dist/types/shared/agent-presence.d.ts +15 -12
- package/dist/types/shared/auto-arrange.d.ts +1 -0
- package/dist/types/shared/boards.d.ts +4 -4
- package/dist/types/shared/knowledge-lifecycle.d.ts +41 -0
- package/dist/types/shared/knowledge-presets.d.ts +6 -0
- package/dist/types/shared/lifecycle-status.d.ts +24 -0
- package/dist/types/shared/memory-graph.d.ts +53 -0
- package/dist/types/shared/relations.d.ts +68 -0
- package/dist/types/shared/semantic-attention.d.ts +2 -0
- package/dist/types/shared/tour.d.ts +38 -0
- package/dist/types/shared/workspace-settings.d.ts +11 -0
- package/docs/RELEASE.md +21 -3
- package/docs/adr-001-bun-only-runtime.md +7 -1
- package/docs/adr-002-shared-product-separate-runtimes.md +93 -0
- package/docs/api-reference.md +109 -0
- package/docs/ax-host-adapter-contract.md +1 -1
- package/docs/ax-state-contract.md +5 -4
- package/docs/cli.md +153 -15
- package/docs/demos/pmx-canvas-demo.mp4 +0 -0
- package/docs/design/AgentContext.dc.html +3 -3
- package/docs/design/BoardPins.dc.html +37 -22
- package/docs/design/Categories.dc.html +208 -0
- package/docs/design/CategoryEditor.dc.html +33 -0
- package/docs/design/Chrome.dc.html +52 -9
- package/docs/design/ContextChip.dc.html +224 -0
- package/docs/design/ContextChip600.dc.html +136 -0
- package/docs/design/EmptyState.dc.html +45 -0
- package/docs/design/Flows.dc.html +23 -1
- package/docs/design/HeaderMarks.dc.html +183 -0
- package/docs/design/Home.dc.html +19 -1
- package/docs/design/HomeGraph.dc.html +86 -202
- package/docs/design/HomeMap.dc.html +23 -1
- package/docs/design/HomeTeam.dc.html +32 -14
- package/docs/design/Lifecycle.dc.html +22 -0
- package/docs/design/Lifecycle600.dc.html +18 -0
- package/docs/design/LifecycleVariants.dc.html +157 -0
- package/docs/design/LinkAuthorship.dc.html +139 -0
- package/docs/design/LinksChanged.dc.html +203 -0
- package/docs/design/LinksOptions.dc.html +137 -0
- package/docs/design/LinksOptions600.dc.html +134 -0
- package/docs/design/NearPin.dc.html +9 -10
- package/docs/design/Pane600.dc.html +4 -3
- package/docs/design/Relations.dc.html +3 -2
- package/docs/design/Restore.dc.html +152 -0
- package/docs/design/SeeChange.dc.html +267 -0
- package/docs/design/Uses.dc.html +12 -6
- package/docs/design/canvas.json +146 -19
- package/docs/design.md +449 -16
- package/docs/environment.md +0 -1
- package/docs/evals/curation-effect.md +128 -0
- package/docs/http-api.md +459 -37
- package/docs/mcp.md +146 -18
- package/docs/node-types.md +58 -3
- package/docs/plans/design-gaps-2026-10-08.md +53 -0
- package/docs/plans/plan-009-tech-debt-backlog.md +379 -0
- package/docs/plans/plan-016-context-made-visible-wave-1.md +150 -5
- package/docs/plans/plan-017-knowledge-categories-and-lifecycle.md +175 -0
- package/docs/plans/plan-018-connected-memory-wave-2.md +594 -0
- package/docs/plans/plan-019-post-0.9-roadmap.md +198 -0
- package/docs/product-context-vision-2026-09.md +17 -1
- package/docs/product-vision-2026-09.md +1082 -43
- package/docs/product-vision-review-2026-09.md +13 -1
- package/docs/screenshots/workbench-live-dark.png +0 -0
- package/docs/screenshots/workbench-live-light.png +0 -0
- package/docs/sdk.md +62 -2
- package/package.json +1 -1
- package/plugins/pmx-canvas/com.github.copilot/extensions/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/plugin.json +1 -1
- package/plugins/pmx-canvas/runtime.json +1 -1
- package/plugins/pmx-canvas/tools.json +601 -22
- package/skills/pmx-canvas/SKILL.md +216 -42
- package/skills/pmx-canvas/references/full-reference.md +120 -21
- package/skills/pmx-canvas/references/github-copilot-app-adapter.md +2 -2
- package/skills/pmx-canvas/references/importing-context.md +132 -0
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +6 -6
- package/skills/pmx-canvas/references/tours-and-recording.md +16 -1
- package/skills/pmx-canvas-testing/SKILL.md +20 -0
- package/src/cli/agent.ts +2 -0
- package/src/cli/commands/ax.ts +12 -0
- package/src/cli/commands/boards.ts +87 -11
- package/src/cli/commands/edges.ts +28 -0
- package/src/cli/commands/groups.ts +7 -0
- package/src/cli/commands/nodes.ts +1 -0
- package/src/cli/commands/query.ts +34 -2
- package/src/cli/commands/record.ts +199 -48
- package/src/cli/index.ts +6 -1
- package/src/client/App.tsx +177 -70
- package/src/client/canvas/AttentionHistory.tsx +0 -2
- package/src/client/canvas/BarHint.tsx +40 -2
- package/src/client/canvas/BoardCopyDialog.tsx +1 -1
- package/src/client/canvas/BoardFolderDialog.tsx +7 -7
- package/src/client/canvas/BoardLinkDialog.tsx +138 -33
- package/src/client/canvas/BoardPinButton.tsx +33 -0
- package/src/client/canvas/BoardSwitcher.tsx +26 -40
- package/src/client/canvas/CanvasNode.tsx +360 -237
- package/src/client/canvas/CanvasViewport.tsx +45 -7
- package/src/client/canvas/CommandBar.tsx +29 -23
- package/src/client/canvas/CommandPalette.tsx +5 -12
- package/src/client/canvas/ConnectionBanner.tsx +3 -3
- package/src/client/canvas/ContextChip.tsx +296 -0
- package/src/client/canvas/ContextMenu.tsx +63 -5
- package/src/client/canvas/CrossBoardReferences.tsx +60 -0
- package/src/client/canvas/EdgeLayer.tsx +339 -76
- package/src/client/canvas/EmptyState.tsx +33 -18
- package/src/client/canvas/ExpandedNodeOverlay.tsx +72 -40
- package/src/client/canvas/ExternalWriters.tsx +57 -42
- package/src/client/canvas/HomeGraphView.tsx +942 -0
- package/src/client/canvas/HomeMapView.tsx +641 -0
- package/src/client/canvas/HomeReviewView.tsx +167 -0
- package/src/client/canvas/HomeView.tsx +687 -160
- package/src/client/canvas/IntentLayer.tsx +19 -12
- package/src/client/canvas/KnowledgeDialog.tsx +488 -0
- package/src/client/canvas/LifecycleChrome.tsx +133 -0
- package/src/client/canvas/LifecycleFields.tsx +91 -0
- package/src/client/canvas/LifecyclePanel.tsx +533 -0
- package/src/client/canvas/MemoryInspector.tsx +104 -0
- package/src/client/canvas/Minimap.tsx +3 -3
- package/src/client/canvas/NearPinLayer.tsx +187 -0
- package/src/client/canvas/NodeContextMark.tsx +175 -97
- package/src/client/canvas/Presentation.tsx +23 -57
- package/src/client/canvas/RelationPresetsDialog.tsx +413 -0
- package/src/client/canvas/RelationsPanel.tsx +82 -0
- package/src/client/canvas/SelectionBar.tsx +3 -6
- package/src/client/canvas/SessionPanel.tsx +90 -50
- package/src/client/canvas/SessionReceipt.tsx +311 -39
- package/src/client/canvas/ShortcutOverlay.tsx +1 -1
- package/src/client/canvas/SnapshotPanel.tsx +27 -36
- package/src/client/canvas/ToolRail.tsx +367 -192
- package/src/client/canvas/TopBar.tsx +354 -124
- package/src/client/canvas/canvas-area.ts +10 -3
- package/src/client/canvas/graph-geometry.ts +195 -0
- package/src/client/canvas/graph-model.ts +254 -0
- package/src/client/canvas/memory-layout.ts +106 -0
- package/src/client/canvas/use-chrome-height.ts +20 -0
- package/src/client/canvas/use-focus-trap.ts +31 -5
- package/src/client/canvas/use-memory-graph.ts +58 -0
- package/src/client/canvas/use-node-drag.ts +2 -1
- package/src/client/canvas/use-pan-zoom.ts +64 -7
- package/src/client/icons.tsx +0 -63
- package/src/client/mcp-app/style.css +0 -6
- package/src/client/nodes/AxStepControls.tsx +74 -50
- package/src/client/nodes/BoardNode.tsx +5 -1
- package/src/client/nodes/ContextNode.tsx +18 -11
- package/src/client/nodes/ExtAppFrame.tsx +0 -1
- package/src/client/nodes/FileNode.tsx +46 -42
- package/src/client/nodes/HtmlNode.tsx +32 -0
- package/src/client/nodes/ImageNode.tsx +1 -1
- package/src/client/nodes/InlineFormatBar.tsx +6 -9
- package/src/client/nodes/InlineMarkdownEditor.tsx +56 -17
- package/src/client/nodes/MarkdownNode.tsx +377 -109
- package/src/client/nodes/McpAppNode.tsx +50 -20
- package/src/client/nodes/MdFormatBar.tsx +21 -18
- package/src/client/nodes/PromptNode.tsx +1 -1
- package/src/client/nodes/ResponseNode.tsx +1 -1
- package/src/client/nodes/viewer-runtime-state.ts +54 -22
- package/src/client/state/attention-bridge.ts +64 -15
- package/src/client/state/boards-store.ts +193 -39
- package/src/client/state/canvas-store.ts +99 -12
- package/src/client/state/context-chip-store.ts +70 -0
- package/src/client/state/context-status-store.ts +53 -0
- package/src/client/state/intent-bridge.ts +51 -30
- package/src/client/state/knowledge-store.ts +43 -0
- package/src/client/state/near-pin-store.ts +115 -6
- package/src/client/state/presence-store.ts +7 -10
- package/src/client/state/presentation.ts +91 -0
- package/src/client/state/relation-store.ts +81 -0
- package/src/client/state/session-store.ts +144 -18
- package/src/client/state/sse-bridge.ts +206 -29
- package/src/client/state/workspace-settings.ts +11 -0
- package/src/client/theme/global.css +2724 -476
- package/src/client/types.ts +12 -0
- package/src/client/utils/keyboard.ts +10 -0
- package/src/client/utils/time-ago.ts +14 -0
- package/src/json-render/renderer/index.tsx +31 -0
- package/src/mcp/canvas-app.ts +12 -2
- package/src/mcp/context-uri-template.ts +7 -4
- package/src/mcp/server.ts +63 -41
- package/src/mcp/workbench-app.ts +39 -6
- package/src/server/agent-presence.ts +128 -28
- package/src/server/ax-context.ts +27 -13
- package/src/server/board-export.ts +12 -1
- package/src/server/canvas-db.ts +331 -42
- package/src/server/canvas-operations.ts +195 -83
- package/src/server/canvas-provenance.ts +53 -22
- package/src/server/canvas-serialization.ts +2 -0
- package/src/server/canvas-state.ts +950 -122
- package/src/server/canvas-validation.ts +9 -2
- package/src/server/context-brief.ts +345 -38
- package/src/server/context-reads.ts +178 -30
- package/src/server/demo-state.json +355 -24
- package/src/server/edit-summary.ts +74 -0
- package/src/server/html-surface.ts +8 -1
- package/src/server/index.ts +181 -11
- package/src/server/knowledge-lifecycle.ts +304 -0
- package/src/server/lifecycle-status.ts +232 -0
- package/src/server/memory-graph.ts +166 -0
- package/src/server/mutation-history.ts +18 -6
- package/src/server/operations/composites.ts +32 -11
- package/src/server/operations/http.ts +7 -2
- package/src/server/operations/index.ts +4 -0
- package/src/server/operations/mcp.ts +26 -1
- package/src/server/operations/ops/ax-presence.ts +1 -1
- package/src/server/operations/ops/ax-read.ts +57 -6
- package/src/server/operations/ops/ax-timeline.ts +28 -2
- package/src/server/operations/ops/backup.ts +5 -3
- package/src/server/operations/ops/batch.ts +35 -4
- package/src/server/operations/ops/boards.ts +188 -47
- package/src/server/operations/ops/canvas-wire.ts +31 -1
- package/src/server/operations/ops/edges.ts +36 -2
- package/src/server/operations/ops/groups.ts +6 -1
- package/src/server/operations/ops/lifecycle.ts +262 -0
- package/src/server/operations/ops/memory-graph.ts +45 -0
- package/src/server/operations/ops/nodes.ts +107 -24
- package/src/server/operations/ops/query.ts +281 -68
- package/src/server/operations/ops/snapshots.ts +73 -1
- package/src/server/operations/ops/validate.ts +5 -1
- package/src/server/operations/ops/viewport.ts +97 -3
- package/src/server/operations/registry.ts +74 -25
- package/src/server/placement.ts +29 -0
- package/src/server/scope-fence.ts +78 -6
- package/src/server/server.ts +115 -16
- package/src/server/spatial-analysis.ts +48 -23
- package/src/server/tour-control.ts +82 -0
- package/src/server/wiki-links.ts +137 -0
- package/src/server/workbench-auth.ts +11 -0
- package/src/shared/agent-context.ts +3 -0
- package/src/shared/agent-presence.ts +15 -15
- package/src/shared/auto-arrange.ts +2 -0
- package/src/shared/boards.ts +4 -4
- package/src/shared/knowledge-lifecycle.ts +22 -0
- package/src/shared/knowledge-presets.ts +80 -0
- package/src/shared/lifecycle-status.ts +26 -0
- package/src/shared/memory-graph.ts +74 -0
- package/src/shared/relations.ts +98 -0
- package/src/shared/semantic-attention.ts +15 -1
- package/src/shared/tour.ts +23 -5
- package/src/shared/workspace-settings.ts +13 -0
- package/dist/types/client/canvas/ContextPinBar.d.ts +0 -1
- package/dist/types/client/canvas/FocusFieldLayer.d.ts +0 -1
- package/dist/types/client/canvas/kind-colors.d.ts +0 -3
- package/dist/types/server/agent-context.d.ts +0 -22
- package/src/client/canvas/ContextPinBar.tsx +0 -25
package/src/server/server.ts
CHANGED
|
@@ -39,13 +39,16 @@
|
|
|
39
39
|
|
|
40
40
|
import { spawnSync } from 'node:child_process';
|
|
41
41
|
import { randomUUID } from 'node:crypto';
|
|
42
|
-
import { isWorkbenchToken, workbenchToken } from './workbench-auth.js';
|
|
42
|
+
import { isWorkbenchToken, refuseStaleWorkbenchWrite, workbenchToken } from './workbench-auth.js';
|
|
43
43
|
import { withCurrentActor } from './attribution.js';
|
|
44
44
|
import { existsSync, readFileSync, realpathSync, statSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
45
45
|
import { readFile } from 'node:fs/promises';
|
|
46
46
|
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
47
47
|
import * as marked from 'marked';
|
|
48
|
+
import { renderWikiMarkdown, resolveWikiLinks, wikiReferences } from './wiki-links.js';
|
|
49
|
+
import { wikiLinksFromData } from '../shared/memory-graph.js';
|
|
48
50
|
import { type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
|
|
51
|
+
import { boardCatalogEpoch, boardsPayload } from './operations/ops/boards.js';
|
|
49
52
|
import {
|
|
50
53
|
buildAxBridge,
|
|
51
54
|
buildAxStateBridge,
|
|
@@ -83,6 +86,7 @@ import { MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_NAME_LENGTH } from './document-imp
|
|
|
83
86
|
import { dispatchOperationRoute, setOperationEventEmitter } from './operations/index.js';
|
|
84
87
|
import { intentRegistry } from './intent-registry.js';
|
|
85
88
|
import { agentPresence } from './agent-presence.js';
|
|
89
|
+
import { cardText, changeExcerpts, describeEdit } from './edit-summary.js';
|
|
86
90
|
import { HUMAN_STARTED_SESSION_LABEL } from '../shared/agent-presence.js';
|
|
87
91
|
import { diffLayouts } from './mutation-history.js';
|
|
88
92
|
import { humanPresence } from './human-presence.js';
|
|
@@ -159,6 +163,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
|
|
|
159
163
|
// or every node when the board was empty at attach (no snapshot was taken).
|
|
160
164
|
let addedIds = startSnapshotId ? new Set<string>() : new Set(layout.nodes.map((node) => node.id));
|
|
161
165
|
let modifiedIds = new Set<string>();
|
|
166
|
+
let beforeNodes = new Map<string, CanvasNodeState>();
|
|
162
167
|
// A session that changed NOTHING on the board leaves no snapshot behind —
|
|
163
168
|
// session churn (idle timeouts, receipt tests, attach/detach cycles) was
|
|
164
169
|
// flooding the History drawer with identical boards.
|
|
@@ -168,6 +173,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
|
|
|
168
173
|
const diff = diffLayouts(snapData.name, snapData, layout);
|
|
169
174
|
addedIds = new Set(diff.addedNodes.map((node) => node.id));
|
|
170
175
|
modifiedIds = new Set(diff.modifiedNodes.map((node) => node.id));
|
|
176
|
+
beforeNodes = new Map(snapData.nodes.map((node) => [node.id, node]));
|
|
171
177
|
if (
|
|
172
178
|
diff.addedNodes.length === 0 &&
|
|
173
179
|
diff.removedNodes.length === 0 &&
|
|
@@ -197,7 +203,7 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
|
|
|
197
203
|
rejected: ax.approvalGates.filter((gate) => gate.status === 'rejected').length,
|
|
198
204
|
held: ax.approvalGates.filter((gate) => gate.status === 'held').length,
|
|
199
205
|
};
|
|
200
|
-
const context = sessionContextActivity(layout.nodes, startedAt, addedIds, modifiedIds);
|
|
206
|
+
const context = sessionContextActivity(layout.nodes, startedAt, addedIds, modifiedIds, beforeNodes);
|
|
201
207
|
// Reading or pinning is something the human wants to see even when the
|
|
202
208
|
// board itself did not change ("did the agent read what I pinned?").
|
|
203
209
|
if (context.read.length > 0 || context.pinned.length > 0) unchanged = false;
|
|
@@ -205,12 +211,15 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
|
|
|
205
211
|
// workbench session and would overwrite it. The label is what the receipt shows.
|
|
206
212
|
emitPrimaryWorkbenchEvent('agent-session-ended', {
|
|
207
213
|
label: presence.label,
|
|
214
|
+
// The writer key (the envelope reserves `sessionId`): the client's live lens matches on it.
|
|
215
|
+
writer: presence.sessionId,
|
|
208
216
|
parentAgentId: presence.parentAgentId ?? null,
|
|
217
|
+
startedAt,
|
|
209
218
|
endedAt,
|
|
210
219
|
endedBy,
|
|
211
220
|
counts,
|
|
212
221
|
unchanged,
|
|
213
|
-
snapshot: snapshot ? { id: snapshot.id, name: snapshot.name } : null,
|
|
222
|
+
snapshot: snapshot ? { id: snapshot.id, name: snapshot.name, boardId: canvasState.activeBoardId } : null,
|
|
214
223
|
context,
|
|
215
224
|
});
|
|
216
225
|
});
|
|
@@ -218,7 +227,13 @@ agentPresence.setSessionEndListener((presence, startSnapshotId, endedBy, started
|
|
|
218
227
|
interface ReceiptNode {
|
|
219
228
|
id: string;
|
|
220
229
|
title: string;
|
|
230
|
+
boardId: string | null;
|
|
221
231
|
reason?: string;
|
|
232
|
+
/** Edited cards: what the edit did ("rewrote the second paragraph"). */
|
|
233
|
+
change?: string;
|
|
234
|
+
/** Edited cards: the text before and after, cut around the first difference, for "See change". */
|
|
235
|
+
before?: string;
|
|
236
|
+
after?: string;
|
|
222
237
|
}
|
|
223
238
|
|
|
224
239
|
/**
|
|
@@ -231,6 +246,7 @@ function sessionContextActivity(
|
|
|
231
246
|
startedAt: string | null,
|
|
232
247
|
addedIds: Set<string>,
|
|
233
248
|
modifiedIds: Set<string>,
|
|
249
|
+
beforeNodes: Map<string, CanvasNodeState>,
|
|
234
250
|
): {
|
|
235
251
|
read: ReceiptNode[];
|
|
236
252
|
pinned: ReceiptNode[];
|
|
@@ -239,11 +255,12 @@ function sessionContextActivity(
|
|
|
239
255
|
changedSinceRead: ReceiptNode[];
|
|
240
256
|
} {
|
|
241
257
|
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
258
|
+
const boardId = canvasState.activeBoardId;
|
|
242
259
|
const entry = (node: CanvasNodeState): ReceiptNode => ({
|
|
243
260
|
id: node.id,
|
|
261
|
+
boardId,
|
|
244
262
|
title: typeof node.data.title === 'string' && node.data.title.trim() ? node.data.title : node.id,
|
|
245
263
|
});
|
|
246
|
-
const boardId = canvasState.activeBoardId;
|
|
247
264
|
const read = startedAt
|
|
248
265
|
? canvasState.getReadNodeIdsSince(boardId, startedAt).flatMap((id) => {
|
|
249
266
|
const node = byId.get(id);
|
|
@@ -256,7 +273,14 @@ function sessionContextActivity(
|
|
|
256
273
|
return [{ ...entry(node), ...(meta.reason ? { reason: meta.reason } : {}) }];
|
|
257
274
|
});
|
|
258
275
|
const created = nodes.filter((node) => addedIds.has(node.id) && node.createdBy?.actor === 'agent').map(entry);
|
|
259
|
-
const edited = nodes
|
|
276
|
+
const edited = nodes
|
|
277
|
+
.filter((node) => modifiedIds.has(node.id) && node.lastEditedBy?.actor === 'agent')
|
|
278
|
+
.map((node) => {
|
|
279
|
+
const before = beforeNodes.get(node.id);
|
|
280
|
+
return before
|
|
281
|
+
? { ...entry(node), change: describeEdit(before, node), ...changeExcerpts(cardText(before), cardText(node)) }
|
|
282
|
+
: entry(node);
|
|
283
|
+
});
|
|
260
284
|
const readStatus = new Map(canvasState.getNodeReadStatus(boardId).map((status) => [status.nodeId, status]));
|
|
261
285
|
const changedSinceRead = [...canvasState.contextPinnedNodeIds].flatMap((id) => {
|
|
262
286
|
const node = byId.get(id);
|
|
@@ -898,19 +922,45 @@ function toSseFrame(event: string, payload: PrimaryWorkbenchEventPayload): Uint8
|
|
|
898
922
|
// open and never flush it to the browser, so an EventSource client behind one
|
|
899
923
|
// receives nothing and the canvas never boots. Every broadcast event is also
|
|
900
924
|
// recorded here with a monotonic seq; GET /api/workbench/poll replays them as
|
|
901
|
-
// a normal,
|
|
925
|
+
// a normal, completed JSON response that any proxy delivers. Bounded:
|
|
902
926
|
// a client whose `since` fell off the ring gets a fresh connect snapshot,
|
|
903
927
|
// which fully rebuilds client state (same events an SSE connect sends).
|
|
904
928
|
const WORKBENCH_EVENT_RING_MAX = 500;
|
|
905
929
|
let workbenchEventSeq = 0;
|
|
906
930
|
const workbenchEventRing: Array<{ seq: number; event: string; payload: PrimaryWorkbenchEventPayload }> = [];
|
|
931
|
+
const workbenchPollWaiters = new Set<() => void>();
|
|
932
|
+
let remoteEdgesSignature = '';
|
|
907
933
|
|
|
908
934
|
function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPayload): void {
|
|
935
|
+
let remoteEdgesChanged = false;
|
|
936
|
+
if (event === 'canvas-layout-update') {
|
|
937
|
+
// Remote footer titles come from the catalog. Refresh it on endpoint changes
|
|
938
|
+
// (including SDK writes and Undo), never on ordinary geometry broadcasts.
|
|
939
|
+
const signature = JSON.stringify([
|
|
940
|
+
canvasState.activeBoardId,
|
|
941
|
+
canvasState.boardGeneration,
|
|
942
|
+
canvasState
|
|
943
|
+
.getEdges()
|
|
944
|
+
.filter((edge) => edge.toBoardId)
|
|
945
|
+
.map((edge) => [edge.id, edge.from, edge.toBoardId, edge.to]),
|
|
946
|
+
]);
|
|
947
|
+
remoteEdgesChanged = signature !== remoteEdgesSignature;
|
|
948
|
+
remoteEdgesSignature = signature;
|
|
949
|
+
}
|
|
950
|
+
if (event === 'canvas-layout-update' || event === 'context-pins-changed') {
|
|
951
|
+
payload = {
|
|
952
|
+
...payload,
|
|
953
|
+
catalogEpoch: boardCatalogEpoch,
|
|
954
|
+
boardGeneration: canvasState.boardGeneration,
|
|
955
|
+
boardId: canvasState.activeBoardId,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
909
958
|
workbenchEventSeq += 1;
|
|
910
959
|
workbenchEventRing.push({ seq: workbenchEventSeq, event, payload });
|
|
911
960
|
if (workbenchEventRing.length > WORKBENCH_EVENT_RING_MAX) {
|
|
912
961
|
workbenchEventRing.splice(0, workbenchEventRing.length - WORKBENCH_EVENT_RING_MAX);
|
|
913
962
|
}
|
|
963
|
+
for (const wake of workbenchPollWaiters) wake();
|
|
914
964
|
const frame = toSseFrame(event, payload);
|
|
915
965
|
for (const [subscriberId, controller] of workbenchSubscribers.entries()) {
|
|
916
966
|
try {
|
|
@@ -925,6 +975,7 @@ function broadcastWorkbenchEvent(event: string, payload: PrimaryWorkbenchEventPa
|
|
|
925
975
|
syncCanvasBrowserOpenedFromSubscribers();
|
|
926
976
|
}
|
|
927
977
|
}
|
|
978
|
+
if (remoteEdgesChanged) emitPrimaryWorkbenchEvent('boards-changed', boardsPayload());
|
|
928
979
|
}
|
|
929
980
|
|
|
930
981
|
function setPrimaryWorkbenchPath(safePath: string, source: string): void {
|
|
@@ -2201,6 +2252,9 @@ function buildWorkbenchConnectSnapshot(
|
|
|
2201
2252
|
continuity,
|
|
2202
2253
|
path: primaryWorkbenchPath,
|
|
2203
2254
|
theme: canvasState.theme,
|
|
2255
|
+
workspaceSettings: canvasState.getWorkspaceSettings(),
|
|
2256
|
+
workbenchToken,
|
|
2257
|
+
catalogEpoch: boardCatalogEpoch,
|
|
2204
2258
|
// Lets a long-lived client detect that the server was upgraded under it
|
|
2205
2259
|
// (stale in-memory SPA — 0.4.5 report Finding W) and reload once. The
|
|
2206
2260
|
// bundle stamp catches DEV rolls too: a rebuild+restart never bumps the
|
|
@@ -2243,6 +2297,9 @@ function buildWorkbenchConnectSnapshot(
|
|
|
2243
2297
|
events.push({
|
|
2244
2298
|
event: 'canvas-layout-update',
|
|
2245
2299
|
payload: {
|
|
2300
|
+
catalogEpoch: boardCatalogEpoch,
|
|
2301
|
+
boardGeneration: canvasState.boardGeneration,
|
|
2302
|
+
boardId: canvasState.activeBoardId,
|
|
2246
2303
|
layout: canvasState.getLayout(),
|
|
2247
2304
|
sessionId: primaryWorkbenchSessionId,
|
|
2248
2305
|
timestamp: new Date().toISOString(),
|
|
@@ -2251,6 +2308,7 @@ function buildWorkbenchConnectSnapshot(
|
|
|
2251
2308
|
events.push({
|
|
2252
2309
|
event: 'context-pins-changed',
|
|
2253
2310
|
payload: {
|
|
2311
|
+
boardId: canvasState.activeBoardId,
|
|
2254
2312
|
count: canvasState.contextPinnedNodeIds.size,
|
|
2255
2313
|
nodeIds: Array.from(canvasState.contextPinnedNodeIds),
|
|
2256
2314
|
sessionId: primaryWorkbenchSessionId,
|
|
@@ -2294,15 +2352,32 @@ function buildWorkbenchConnectSnapshot(
|
|
|
2294
2352
|
* Proxy-safe transport (Amp orb portals and any proxy that buffers streaming
|
|
2295
2353
|
* responses): returns the buffered events after `since` — or the full connect
|
|
2296
2354
|
* snapshot when `since` is absent, from a previous server run, or already
|
|
2297
|
-
* evicted from the ring — as a normal JSON response
|
|
2298
|
-
*
|
|
2299
|
-
*
|
|
2355
|
+
* evicted from the ring — as a normal JSON response. A caught-up client may
|
|
2356
|
+
* wait up to one second for an event instead of leaving a gap between reads.
|
|
2357
|
+
* Each response closes so buffering proxies flush it. `seq` is the next cursor.
|
|
2300
2358
|
*/
|
|
2301
|
-
function handleWorkbenchPoll(url: URL): Response {
|
|
2359
|
+
async function handleWorkbenchPoll(req: Request, url: URL): Promise<Response> {
|
|
2302
2360
|
rotatePrimaryWorkbenchSessionIfNeeded();
|
|
2303
2361
|
const requestedSessionId = url.searchParams.get('session')?.trim() ?? '';
|
|
2304
2362
|
const sinceRaw = url.searchParams.get('since');
|
|
2305
2363
|
const since = sinceRaw === null ? Number.NaN : Number(sinceRaw);
|
|
2364
|
+
const waitMs = Math.min(1000, Math.max(0, Number(url.searchParams.get('waitMs')) || 0));
|
|
2365
|
+
if (waitMs > 0 && since === workbenchEventSeq && !req.signal.aborted) {
|
|
2366
|
+
await new Promise<void>((resolve) => {
|
|
2367
|
+
const finish = (): void => {
|
|
2368
|
+
if (!workbenchPollWaiters.delete(finish)) return;
|
|
2369
|
+
clearTimeout(timer);
|
|
2370
|
+
req.signal.removeEventListener('abort', finish);
|
|
2371
|
+
resolve();
|
|
2372
|
+
};
|
|
2373
|
+
const timer = setTimeout(finish, waitMs);
|
|
2374
|
+
workbenchPollWaiters.add(finish);
|
|
2375
|
+
req.signal.addEventListener('abort', finish, { once: true });
|
|
2376
|
+
if (req.signal.aborted) finish();
|
|
2377
|
+
});
|
|
2378
|
+
}
|
|
2379
|
+
if (req.signal.aborted || !server) return new Response(null, { status: 204 });
|
|
2380
|
+
// Recheck retention after waiting: an event burst can evict this cursor.
|
|
2306
2381
|
const ringHeadSeq =
|
|
2307
2382
|
workbenchEventRing.length > 0 ? (workbenchEventRing[0] as { seq: number }).seq : workbenchEventSeq + 1;
|
|
2308
2383
|
const usableSince = Number.isInteger(since) && since >= 0 && since <= workbenchEventSeq && since + 1 >= ringHeadSeq;
|
|
@@ -2378,6 +2453,8 @@ function handleWorkbenchEvents(req: Request): Response {
|
|
|
2378
2453
|
}
|
|
2379
2454
|
|
|
2380
2455
|
async function handleSave(req: Request): Promise<Response> {
|
|
2456
|
+
const staleWrite = refuseStaleWorkbenchWrite(req);
|
|
2457
|
+
if (staleWrite) return staleWrite;
|
|
2381
2458
|
const body = await readJson(req);
|
|
2382
2459
|
if (body === null) return malformedJsonResponse();
|
|
2383
2460
|
const pathLike = typeof body.path === 'string' ? body.path : '';
|
|
@@ -2397,12 +2474,31 @@ async function handleSave(req: Request): Promise<Response> {
|
|
|
2397
2474
|
async function handleRender(req: Request): Promise<Response> {
|
|
2398
2475
|
const body = await readJson(req);
|
|
2399
2476
|
if (body === null) return malformedJsonResponse();
|
|
2477
|
+
if (body.expectedBoardId !== undefined && body.expectedBoardId !== canvasState.activeBoardId) {
|
|
2478
|
+
return responseJson({ ok: false, error: 'The active board changed. Refresh before retrying this action.' }, 409);
|
|
2479
|
+
}
|
|
2400
2480
|
const markdown = typeof body.markdown === 'string' ? body.markdown : '';
|
|
2401
|
-
const
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2481
|
+
const node = typeof body.nodeId === 'string' ? canvasState.getNode(body.nodeId) : undefined;
|
|
2482
|
+
const previous = node?.type === 'markdown' ? wikiLinksFromData(node.data) : [];
|
|
2483
|
+
// The editor sends its draft through this same endpoint. Only changed/unsaved
|
|
2484
|
+
// text may resolve titles; persisted text must replay even unresolved bindings.
|
|
2485
|
+
const persisted = node?.type === 'markdown' && node.data.content === markdown;
|
|
2486
|
+
const initializedRefs =
|
|
2487
|
+
node?.type === 'markdown' && node.data.wikiLinks !== undefined && typeof node.data.content === 'string'
|
|
2488
|
+
? wikiReferences(node.data.content)
|
|
2489
|
+
: [];
|
|
2490
|
+
const refs = wikiReferences(markdown).filter(
|
|
2491
|
+
(ref) => !initializedRefs.includes(ref) || previous.some((link) => link.ref === ref),
|
|
2492
|
+
);
|
|
2493
|
+
const needsResolution = !persisted && refs.some((ref) => !previous.some((link) => link.ref === ref && link.target));
|
|
2494
|
+
const catalog = needsResolution ? canvasState.wikiCatalog() : [];
|
|
2495
|
+
const links = persisted ? previous : refs.map((ref) => resolveWikiLinks(`[[${ref}]]`, previous, catalog)[0]);
|
|
2496
|
+
const targets = new Map<string, ReturnType<typeof canvasState.readBoard>>();
|
|
2497
|
+
const html = renderWikiMarkdown(normalizeMarkdownExternalUrls(markdown), links, (target) => {
|
|
2498
|
+
if (!targets.has(target.boardId)) targets.set(target.boardId, canvasState.readBoard(target.boardId, false));
|
|
2499
|
+
const board = targets.get(target.boardId);
|
|
2500
|
+
return !!board && (!target.cardId || board.layout.nodes.some((card) => card.id === target.cardId));
|
|
2501
|
+
});
|
|
2406
2502
|
return responseJson({ html });
|
|
2407
2503
|
}
|
|
2408
2504
|
|
|
@@ -3354,6 +3450,8 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3354
3450
|
// browser only (an MCP notification would make a read cause more reads).
|
|
3355
3451
|
canvasState.setContextReadListener((boardId) => {
|
|
3356
3452
|
emitPrimaryWorkbenchEvent('context-status-changed', { boardId });
|
|
3453
|
+
// The live session lens counts reads (AgentContext.dc.html).
|
|
3454
|
+
agentPresence.noteRead();
|
|
3357
3455
|
});
|
|
3358
3456
|
|
|
3359
3457
|
// Ensure direct HTTP server usage records undo/redo history, not just PmxCanvas.start().
|
|
@@ -3517,7 +3615,7 @@ export function startCanvasServer(options: CanvasServerOptions = {}): string | n
|
|
|
3517
3615
|
}
|
|
3518
3616
|
|
|
3519
3617
|
if (url.pathname === '/api/workbench/poll' && req.method === 'GET') {
|
|
3520
|
-
return handleWorkbenchPoll(url);
|
|
3618
|
+
return handleWorkbenchPoll(req, url);
|
|
3521
3619
|
}
|
|
3522
3620
|
|
|
3523
3621
|
if (url.pathname === '/api/workbench/intent' && req.method === 'POST') {
|
|
@@ -3677,6 +3775,7 @@ export function stopCanvasServer(): void {
|
|
|
3677
3775
|
server.stop(true);
|
|
3678
3776
|
server = null;
|
|
3679
3777
|
}
|
|
3778
|
+
for (const wake of workbenchPollWaiters) wake();
|
|
3680
3779
|
}
|
|
3681
3780
|
|
|
3682
3781
|
export function getCanvasServerPort(): number | null {
|
|
@@ -72,17 +72,8 @@ export interface SpatialContext {
|
|
|
72
72
|
|
|
73
73
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
74
74
|
|
|
75
|
-
/** Euclidean distance between two node centers */
|
|
76
|
-
export function centerDistance(a: CanvasNodeState, b: CanvasNodeState): number {
|
|
77
|
-
const ax = a.position.x + a.size.width / 2;
|
|
78
|
-
const ay = a.position.y + a.size.height / 2;
|
|
79
|
-
const bx = b.position.x + b.size.width / 2;
|
|
80
|
-
const by = b.position.y + b.size.height / 2;
|
|
81
|
-
return Math.sqrt((ax - bx) ** 2 + (ay - by) ** 2);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
75
|
/** Gap distance — how far apart two nodes are edge-to-edge (0 if overlapping) */
|
|
85
|
-
function gapDistance(a: CanvasNodeState, b: CanvasNodeState): number {
|
|
76
|
+
export function gapDistance(a: CanvasNodeState, b: CanvasNodeState): number {
|
|
86
77
|
const aRight = a.position.x + a.size.width;
|
|
87
78
|
const aBottom = a.position.y + a.size.height;
|
|
88
79
|
const bRight = b.position.x + b.size.width;
|
|
@@ -259,7 +250,8 @@ export function detectClusters(nodes: CanvasNodeState[], proximityThreshold = 20
|
|
|
259
250
|
}
|
|
260
251
|
|
|
261
252
|
/**
|
|
262
|
-
* Find the nearest unpinned nodes to each
|
|
253
|
+
* Find the nearest unpinned nodes by their edge-to-edge gap from each pin.
|
|
254
|
+
* The inclusive distance limit is in world pixels, independent of card size.
|
|
263
255
|
*/
|
|
264
256
|
export function findNeighborhoods(
|
|
265
257
|
nodes: CanvasNodeState[],
|
|
@@ -272,7 +264,7 @@ export function findNeighborhoods(
|
|
|
272
264
|
|
|
273
265
|
return pinned.map((pin) => {
|
|
274
266
|
const withDist = unpinned
|
|
275
|
-
.map((n) => ({ node: n, distance:
|
|
267
|
+
.map((n) => ({ node: n, distance: gapDistance(pin, n) }))
|
|
276
268
|
.filter((d) => d.distance <= maxDistance)
|
|
277
269
|
.sort((a, b) => a.distance - b.distance)
|
|
278
270
|
.slice(0, maxNeighbors);
|
|
@@ -290,6 +282,38 @@ export function findNeighborhoods(
|
|
|
290
282
|
});
|
|
291
283
|
}
|
|
292
284
|
|
|
285
|
+
/** Shared lexical relevance for cards and library metadata. Scores are ranking details. */
|
|
286
|
+
export function searchRelevance(title: string, fields: Array<[string, number]>, query: string): number {
|
|
287
|
+
const normalize = (text: string) => text.toLowerCase().trim().replace(/\s+/g, ' ');
|
|
288
|
+
const q = normalize(query);
|
|
289
|
+
if (!q) return 0;
|
|
290
|
+
const terms = [...new Set(q.split(' '))];
|
|
291
|
+
const texts = [[normalize(title), 3] as const, ...fields.map(([text, weight]) => [normalize(text), weight] as const)];
|
|
292
|
+
const whole = terms.map((term) => {
|
|
293
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
294
|
+
// JS \b is ASCII-only; letters, marks and numbers in any script remain part of a word.
|
|
295
|
+
return new RegExp(`(?<![\\p{L}\\p{M}\\p{N}_])${escaped}(?![\\p{L}\\p{M}\\p{N}_])`, 'u');
|
|
296
|
+
});
|
|
297
|
+
let weight = 0;
|
|
298
|
+
for (const [text, importance] of texts) {
|
|
299
|
+
for (const term of terms) if (text.includes(term)) weight += importance;
|
|
300
|
+
}
|
|
301
|
+
if (!weight) return 0;
|
|
302
|
+
const wholeTerms = whole.filter((pattern) => texts.some(([text]) => pattern.test(text))).length;
|
|
303
|
+
const tier =
|
|
304
|
+
texts[0][0] === q
|
|
305
|
+
? 5
|
|
306
|
+
: texts.some(([text]) => text === q)
|
|
307
|
+
? 4
|
|
308
|
+
: wholeTerms === terms.length
|
|
309
|
+
? 3
|
|
310
|
+
: wholeTerms > 0
|
|
311
|
+
? 2
|
|
312
|
+
: 1;
|
|
313
|
+
// Field evidence breaks ties but can never overwhelm a better match tier.
|
|
314
|
+
return tier + weight / (weight + 1);
|
|
315
|
+
}
|
|
316
|
+
|
|
293
317
|
/**
|
|
294
318
|
* Full-text search across node titles and content.
|
|
295
319
|
* Returns matching nodes with relevance score.
|
|
@@ -322,16 +346,17 @@ export function searchNodes(
|
|
|
322
346
|
).toLowerCase();
|
|
323
347
|
const url = ((node.data.url as string) ?? '').toLowerCase();
|
|
324
348
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
349
|
+
const score = searchRelevance(
|
|
350
|
+
title,
|
|
351
|
+
[
|
|
352
|
+
[path, 2],
|
|
353
|
+
[url, 2],
|
|
354
|
+
[description, 1],
|
|
355
|
+
[summary, 1],
|
|
356
|
+
[content, 1],
|
|
357
|
+
],
|
|
358
|
+
q,
|
|
359
|
+
);
|
|
335
360
|
|
|
336
361
|
if (score === 0) continue;
|
|
337
362
|
|
|
@@ -359,7 +384,7 @@ export function searchNodes(
|
|
|
359
384
|
});
|
|
360
385
|
}
|
|
361
386
|
|
|
362
|
-
results.sort((a, b) => b.score - a.score);
|
|
387
|
+
results.sort((a, b) => b.score - a.score || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
363
388
|
return results;
|
|
364
389
|
}
|
|
365
390
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driving a board tour: the shared stop cursor and eased camera moves.
|
|
3
|
+
*
|
|
4
|
+
* Navigation, not editing: nothing here records undo history, and the cursor
|
|
5
|
+
* is in-memory (like presence) — a restart or board switch starts over at the
|
|
6
|
+
* first stop. The browser animates; the server only decides WHERE to go and
|
|
7
|
+
* broadcasts it, so an agent and every presenting viewer agree on the stop.
|
|
8
|
+
*
|
|
9
|
+
* This module must never import server.ts or index.ts.
|
|
10
|
+
*/
|
|
11
|
+
import { derivedTour, resolveStop, tourStopSchema, type Camera, type Tour, type TourStop } from '../shared/tour.js';
|
|
12
|
+
import { canvasState } from './canvas-state.js';
|
|
13
|
+
import { getClientViewportSize } from './canvas-operations.js';
|
|
14
|
+
import { OperationError } from './operations/types.js';
|
|
15
|
+
|
|
16
|
+
export type TourStep = number | 'next' | 'previous';
|
|
17
|
+
|
|
18
|
+
export interface TourStepResult {
|
|
19
|
+
index: number;
|
|
20
|
+
total: number;
|
|
21
|
+
stop: TourStop;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let cursor: { boardGeneration: number; tour: string; index: number } | null = null;
|
|
25
|
+
|
|
26
|
+
export function currentTour(): Tour {
|
|
27
|
+
return canvasState.getTour() ?? derivedTour(canvasState.getLayout().nodes);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The stop the tour is on for the active board, or null when not touring. */
|
|
31
|
+
export function tourPosition(tour: Tour = currentTour()): number | null {
|
|
32
|
+
if (cursor && (cursor.boardGeneration !== canvasState.boardGeneration || cursor.tour !== JSON.stringify(tour))) {
|
|
33
|
+
cursor = null;
|
|
34
|
+
}
|
|
35
|
+
return cursor?.index ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Move the shared cursor. `next`/`previous` clamp at the ends; an explicit index must exist. */
|
|
39
|
+
export function goToTourStop(step: TourStep): TourStepResult {
|
|
40
|
+
const tour = currentTour();
|
|
41
|
+
const { stops } = tour;
|
|
42
|
+
const at = tourPosition(tour);
|
|
43
|
+
if (stops.length === 0) {
|
|
44
|
+
throw new OperationError('This board has no tour stops. Save a tour or add groups.', 400);
|
|
45
|
+
}
|
|
46
|
+
let index: number;
|
|
47
|
+
if (step === 'next') index = at === null ? 0 : Math.min(stops.length - 1, at + 1);
|
|
48
|
+
else if (step === 'previous') index = at === null ? 0 : Math.max(0, Math.min(stops.length - 1, at - 1));
|
|
49
|
+
else if (Number.isInteger(step) && step >= 0 && step < stops.length) index = step;
|
|
50
|
+
else throw new OperationError(`Tour stop ${step} does not exist (stops 0–${stops.length - 1}).`, 400);
|
|
51
|
+
cursor = { boardGeneration: canvasState.boardGeneration, tour: JSON.stringify(tour), index };
|
|
52
|
+
return { index, total: stops.length, stop: stops[index] as TourStop };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function exitTour(): void {
|
|
56
|
+
cursor = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve an eased camera move and store its end camera as the board viewport
|
|
61
|
+
* (unrecorded). The size is the last window a browser reported, so this is the
|
|
62
|
+
* server's best estimate; the browser re-resolves against its real canvas area.
|
|
63
|
+
*/
|
|
64
|
+
export function moveCamera(input: unknown): { stop: TourStop; viewport: Camera } {
|
|
65
|
+
const parsed = tourStopSchema.safeParse(input);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
throw new OperationError('Give exactly one of nodeId, rect {x,y,width,height} or viewport {x,y,scale}.', 400);
|
|
68
|
+
}
|
|
69
|
+
const stop = parsed.data;
|
|
70
|
+
if ('nodeId' in stop.target && !canvasState.getNode(stop.target.nodeId)) {
|
|
71
|
+
throw new OperationError(`Node "${stop.target.nodeId}" not found.`, 404);
|
|
72
|
+
}
|
|
73
|
+
const size = getClientViewportSize() ?? { width: 1440, height: 900 };
|
|
74
|
+
let viewport: Camera;
|
|
75
|
+
try {
|
|
76
|
+
viewport = resolveStop(stop, canvasState.getLayout().nodes, size.width, size.height);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
throw new OperationError(error instanceof Error ? error.message : String(error), 400);
|
|
79
|
+
}
|
|
80
|
+
canvasState.withSuppressedRecording(() => canvasState.setViewport(viewport));
|
|
81
|
+
return { stop, viewport };
|
|
82
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Marked, type Token, type Tokens } from 'marked';
|
|
2
|
+
import type { MemoryTarget, WikiLink } from '../shared/memory-graph.js';
|
|
3
|
+
|
|
4
|
+
interface WikiToken extends Tokens.Generic {
|
|
5
|
+
type: 'wiki';
|
|
6
|
+
ref: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function wiki(token: Token): token is WikiToken {
|
|
11
|
+
return token.type === 'wiki';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const escapeHtml = (text: string) =>
|
|
15
|
+
text.replace(
|
|
16
|
+
/[&<>"']/g,
|
|
17
|
+
(c) =>
|
|
18
|
+
({
|
|
19
|
+
'&': '&',
|
|
20
|
+
'<': '<',
|
|
21
|
+
'>': '>',
|
|
22
|
+
'"': '"',
|
|
23
|
+
"'": ''',
|
|
24
|
+
})[c]!,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
// Same language as Marked 15's blockSkip, with ordinary destination characters
|
|
28
|
+
// consumed in runs instead of one regex iteration per byte of an inline image.
|
|
29
|
+
const blockSkip = /\[[^[\]]*?\]\([^\\()]*(?:\\.[^\\()]*|\([^\\()]*(?:\\.[^\\()]*)*\)[^\\()]*)*\)|`[^`]*?`|<[^<>]*?>/g;
|
|
30
|
+
|
|
31
|
+
/** Marked's inline tokenizer keeps wiki syntax out of code spans/fences and HTML attributes. */
|
|
32
|
+
function parser(render: (token: WikiToken) => string): Marked {
|
|
33
|
+
let taskIndex = 0;
|
|
34
|
+
return new Marked(
|
|
35
|
+
{ gfm: true, breaks: true },
|
|
36
|
+
{
|
|
37
|
+
renderer: {
|
|
38
|
+
checkbox({ checked }) {
|
|
39
|
+
return `<input data-pmx-task="${taskIndex++}" type="checkbox" disabled${checked ? ' checked' : ''}>`;
|
|
40
|
+
},
|
|
41
|
+
html({ text }) {
|
|
42
|
+
// This attribute belongs to generated tasks, never author-supplied HTML.
|
|
43
|
+
return text.replace(/\bdata-pmx-task\b/gi, 'data-pmx-raw-task');
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
tokenizer: {
|
|
47
|
+
space() {
|
|
48
|
+
// Lexer installs rules after constructing the tokenizer. Copy rather
|
|
49
|
+
// than mutating shared rules, before it queues inline parsing.
|
|
50
|
+
this.rules.inline = { ...this.rules.inline, blockSkip };
|
|
51
|
+
return false;
|
|
52
|
+
},
|
|
53
|
+
lheading(src) {
|
|
54
|
+
// Setext headings cannot cross a blank line. Avoid Marked 15's costly
|
|
55
|
+
// unmatched long-line regex unless a possible underline is present.
|
|
56
|
+
const blank = /\n\s*?\n/.exec(src);
|
|
57
|
+
const paragraph = blank ? src.slice(0, blank.index + 1) : src;
|
|
58
|
+
if (!/\n {0,3}(?:=+|-+) *(?:\n|$)/.test(paragraph)) return undefined;
|
|
59
|
+
return false;
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
extensions: [
|
|
63
|
+
{
|
|
64
|
+
name: 'wiki',
|
|
65
|
+
level: 'inline',
|
|
66
|
+
start: (src) => src.indexOf('[['),
|
|
67
|
+
tokenizer(src) {
|
|
68
|
+
const match = /^\[\[([^\]\n]+)\]\]/.exec(src);
|
|
69
|
+
if (!match) return undefined;
|
|
70
|
+
const [ref, ...alias] = match[1].split('|');
|
|
71
|
+
if (!ref.trim()) return undefined;
|
|
72
|
+
return { type: 'wiki', raw: match[0], ref: ref.trim(), label: alias.join('|').trim() || ref.trim() };
|
|
73
|
+
},
|
|
74
|
+
renderer: (token) => (wiki(token) ? render(token) : false),
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function wikiReferences(content: string): string[] {
|
|
82
|
+
const marked = parser(() => '');
|
|
83
|
+
const refs = new Set<string>();
|
|
84
|
+
marked.walkTokens(marked.lexer(content), (token) => {
|
|
85
|
+
if (wiki(token)) refs.add(token.ref);
|
|
86
|
+
});
|
|
87
|
+
return [...refs];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface WikiBoard {
|
|
91
|
+
id: string;
|
|
92
|
+
name: string;
|
|
93
|
+
cards: Array<{ id: string; title: string }>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** IDs win over names; title matches must be unique. Never retarget an already bound link. */
|
|
97
|
+
export function resolveWikiLinks(content: string, previous: WikiLink[], boards: WikiBoard[]): WikiLink[] {
|
|
98
|
+
const resolve = (ref: string): MemoryTarget | undefined => {
|
|
99
|
+
const separator = ref.indexOf('#');
|
|
100
|
+
const boardName = (separator < 0 ? ref : ref.slice(0, separator)).trim();
|
|
101
|
+
const cardName = separator < 0 ? null : ref.slice(separator + 1).trim();
|
|
102
|
+
const exact = boards.find((board) => board.id === boardName);
|
|
103
|
+
const matches = exact
|
|
104
|
+
? [exact]
|
|
105
|
+
: boards.filter((board) => board.name.toLocaleLowerCase() === boardName.toLocaleLowerCase());
|
|
106
|
+
if (matches.length !== 1) return undefined;
|
|
107
|
+
const board = matches[0];
|
|
108
|
+
if (cardName === null) return { boardId: board.id };
|
|
109
|
+
const exactCard = board.cards.find((card) => card.id === cardName);
|
|
110
|
+
const cards = exactCard
|
|
111
|
+
? [exactCard]
|
|
112
|
+
: board.cards.filter((card) => card.title.toLocaleLowerCase() === cardName.toLocaleLowerCase());
|
|
113
|
+
return cards.length === 1 ? { boardId: board.id, cardId: cards[0].id } : undefined;
|
|
114
|
+
};
|
|
115
|
+
return wikiReferences(content).map((ref) => {
|
|
116
|
+
const target = previous.find((link) => link.ref === ref)?.target ?? resolve(ref);
|
|
117
|
+
return { ref, ...(target ? { target } : {}) };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Only server-owned bindings may navigate; the client still sanitizes rendered HTML. */
|
|
122
|
+
export function renderWikiMarkdown(
|
|
123
|
+
content: string,
|
|
124
|
+
links: WikiLink[],
|
|
125
|
+
exists: (target: MemoryTarget) => boolean,
|
|
126
|
+
): string {
|
|
127
|
+
return parser((token) => {
|
|
128
|
+
const target = links.find((link) => link.ref === token.ref)?.target;
|
|
129
|
+
const attributes =
|
|
130
|
+
target && exists(target)
|
|
131
|
+
? `data-wiki-board="${escapeHtml(target.boardId)}"${target.cardId ? ` data-wiki-card="${escapeHtml(target.cardId)}"` : ''}`
|
|
132
|
+
: target
|
|
133
|
+
? 'data-wiki-missing="true"'
|
|
134
|
+
: '';
|
|
135
|
+
return `<a href="#" class="wiki-link${target && exists(target) ? '' : ' is-unresolved'}" ${attributes} data-wiki-ref="${escapeHtml(token.ref)}" data-wiki-markdown="${escapeHtml(token.raw)}" data-wiki-label="${escapeHtml(token.label)}">${escapeHtml(token.label)}</a>`;
|
|
136
|
+
}).parse(content) as string;
|
|
137
|
+
}
|
|
@@ -9,3 +9,14 @@ export function isWorkbenchToken(value: string | null): boolean {
|
|
|
9
9
|
const expected = Buffer.from(workbenchToken);
|
|
10
10
|
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/** Refuse an old tab's writes after a daemon restart, without downgrading its actor. */
|
|
14
|
+
export function refuseStaleWorkbenchWrite(req: Request): Response | null {
|
|
15
|
+
const token = req.headers.get('x-pmx-workbench-token');
|
|
16
|
+
if (req.method === 'GET' || req.headers.get('x-pmx-workbench') !== '1' || token === null || isWorkbenchToken(token))
|
|
17
|
+
return null;
|
|
18
|
+
return Response.json(
|
|
19
|
+
{ ok: false, error: 'Workbench connection changed. Reconnect before saving.' },
|
|
20
|
+
{ status: 409, headers: { 'Cache-Control': 'no-store' } },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -10,6 +10,8 @@ export interface AgentContextNode {
|
|
|
10
10
|
kind: string;
|
|
11
11
|
title: string | null;
|
|
12
12
|
content: string | null;
|
|
13
|
+
/** Context excerpts do not serialize the card's complete incident relations. */
|
|
14
|
+
linksComplete?: false;
|
|
13
15
|
metadata?: Record<string, unknown>;
|
|
14
16
|
position?: { x: number; y: number };
|
|
15
17
|
}
|
|
@@ -421,6 +423,7 @@ export function serializeNodeForAgentContext(
|
|
|
421
423
|
kind: getCanvasNodeKind(node),
|
|
422
424
|
title: typeof node.data.title === 'string' ? node.data.title : null,
|
|
423
425
|
content: summarizeNodeForAgentContext(node, options) || null,
|
|
426
|
+
linksComplete: false,
|
|
424
427
|
...(metadata ? { metadata } : {}),
|
|
425
428
|
...(options.includePosition ? { position: node.position } : {}),
|
|
426
429
|
};
|