forge-openclaw-plugin 0.2.48 → 0.2.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +3 -3
  2. package/dist/assets/index-2_tuemtU.css +1 -0
  3. package/dist/assets/index-C9_gJvi6.js +91 -0
  4. package/dist/assets/index-C9_gJvi6.js.map +1 -0
  5. package/dist/index.html +2 -2
  6. package/dist/openclaw/parity.js +14 -0
  7. package/dist/openclaw/routes.js +42 -0
  8. package/dist/openclaw/session-registry.js +17 -0
  9. package/dist/openclaw/tools.js +3 -3
  10. package/dist/server/server/migrations/019_wiki_memory.sql +1 -1
  11. package/dist/server/server/migrations/052_agent_identity_tightening.sql +307 -0
  12. package/dist/server/server/migrations/053_agent_runtime_session_canonical_labels.sql +9 -0
  13. package/dist/server/server/migrations/054_sqlite_backed_wiki_memory.sql +8 -0
  14. package/dist/server/server/src/app.js +46 -14
  15. package/dist/server/server/src/db.js +0 -2
  16. package/dist/server/server/src/openapi.js +58 -3
  17. package/dist/server/server/src/repositories/agent-runtime-sessions.js +122 -16
  18. package/dist/server/server/src/repositories/model-settings.js +5 -0
  19. package/dist/server/server/src/repositories/notes.js +5 -2
  20. package/dist/server/server/src/repositories/settings.js +101 -13
  21. package/dist/server/server/src/repositories/users.js +23 -0
  22. package/dist/server/server/src/repositories/wiki-memory.js +16 -190
  23. package/dist/server/server/src/services/data-management.js +2 -9
  24. package/dist/server/server/src/types.js +13 -0
  25. package/openclaw.plugin.json +1 -1
  26. package/package.json +5 -2
  27. package/server/migrations/019_wiki_memory.sql +1 -1
  28. package/server/migrations/052_agent_identity_tightening.sql +307 -0
  29. package/server/migrations/053_agent_runtime_session_canonical_labels.sql +9 -0
  30. package/server/migrations/054_sqlite_backed_wiki_memory.sql +8 -0
  31. package/skills/forge-openclaw/SKILL.md +6 -6
  32. package/skills/forge-openclaw/entity_conversation_playbooks.md +49 -0
  33. package/skills/forge-openclaw/psyche_entity_playbooks.md +32 -0
  34. package/dist/assets/index-Bv9FWWsZ.js +0 -91
  35. package/dist/assets/index-Bv9FWWsZ.js.map +0 -1
  36. package/dist/assets/index-DtEvFzXp.css +0 -1
@@ -2643,7 +2643,7 @@ const AGENT_ONBOARDING_ENTITY_CATALOG = [
2643
2643
  }),
2644
2644
  enrichOnboardingEntityGuide({
2645
2645
  entityType: "wiki_page",
2646
- purpose: "A file-backed Forge wiki page or evidence page.",
2646
+ purpose: "A SQLite-backed Forge wiki page or evidence page.",
2647
2647
  minimumCreateFields: ["title", "contentMarkdown"],
2648
2648
  relationshipRules: [
2649
2649
  "Wiki pages live on the wiki surface and use specialized page upsert routes rather than batch CRUD.",
@@ -2772,6 +2772,12 @@ const AGENT_ONBOARDING_CONVERSATION_RULES = [
2772
2772
  "When useful, help the user name, define, and connect the record in that order: offer a working label, clarify what belongs inside it, then ask about links only after the record itself feels steady.",
2773
2773
  "When the meaning is clearer than the wording, offer a tentative title or formulation yourself and invite correction instead of forcing the user to wordsmith alone.",
2774
2774
  "For direct update or review requests, the next question should usually narrow the saved object, timeframe, or route family instead of reopening the whole meaning-making arc.",
2775
+ "For updates, start with the smallest thing that now feels wrong, newly true, or newly visible rather than restarting the whole story.",
2776
+ "For review requests, ask what practical question the user wants the read to answer before you ask for more scope.",
2777
+ "The opening question should help the user understand what they are actually trying to save, decide, review, or change, not make them perform the schema out loud.",
2778
+ "If the user already named the exact correction in usable language, confirm only the missing scope, timing, or route-selecting detail that still matters, then act.",
2779
+ "Once a specialized-surface lane is clear, speak in route-relevant nouns such as timeline, overlay, weekday template, published output, run detail, or node result instead of generic record language.",
2780
+ "If the next answer would not change the route, wording, timing, or write payload in a meaningful way, stop asking and act.",
2775
2781
  "Before saving, briefly summarize the working formulation in the user's own language when that would reduce ambiguity.",
2776
2782
  "Once the record is clear enough to name, stop exploring broadly and ask only for the last structural detail that still matters.",
2777
2783
  "If the record is already clear enough to save, save it instead of performing a ceremonial extra question.",
@@ -3546,7 +3552,7 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
3546
3552
  requiredFields: [],
3547
3553
  notes: [
3548
3554
  "Semantic search is optional and profile-driven.",
3549
- "The wiki is file-first, so spaces map to local vault directories."
3555
+ "The wiki is SQLite-backed, so pages and evidence live in Forge's database."
3550
3556
  ],
3551
3557
  example: "{}"
3552
3558
  },
@@ -3588,13 +3594,13 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
3588
3594
  },
3589
3595
  {
3590
3596
  toolName: "forge_upsert_wiki_page",
3591
- summary: "Create a new wiki page or update an existing one through the file-backed wiki surface.",
3597
+ summary: "Create a new wiki page or update an existing one through the SQLite-backed wiki surface.",
3592
3598
  whenToUse: "Use when the user explicitly wants wiki memory persisted or reorganized.",
3593
3599
  inputShape: '{ pageId?: string, kind?: "wiki"|"evidence", title: string, slug?: string, summary?: string, aliases?: string[], contentMarkdown: string, author?: string|null, tags?: string[], spaceId?: string, frontmatter?: object, links?: Array<{ entityType, entityId, anchorKey? }> }',
3594
3600
  requiredFields: ["title", "contentMarkdown"],
3595
3601
  notes: [
3596
3602
  "When pageId is omitted, Forge creates a new page.",
3597
- "When pageId is present, Forge patches the existing page and rewrites the canonical file."
3603
+ "When pageId is present, Forge patches the existing SQLite note record."
3598
3604
  ],
3599
3605
  example: '{"title":"Taste map","contentMarkdown":"# Taste map\\n\\n[[forge:goal:goal_123|Core goal]] influences this page.","spaceId":"wiki_space_shared"}'
3600
3606
  },
@@ -3605,19 +3611,19 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
3605
3611
  inputShape: "{ spaceId?: string }",
3606
3612
  requiredFields: [],
3607
3613
  notes: [
3608
- "This is the explicit health surface for the file-first wiki vault.",
3614
+ "This is the explicit health surface for the SQLite-backed wiki memory layer.",
3609
3615
  "Use it before proposing cleanup work or auto-maintenance."
3610
3616
  ],
3611
3617
  example: '{"spaceId":"wiki_space_shared"}'
3612
3618
  },
3613
3619
  {
3614
3620
  toolName: "forge_sync_wiki_vault",
3615
- summary: "Resync Markdown files from the local wiki vault into Forge metadata.",
3616
- whenToUse: "Use after out-of-band file edits or imported file changes that should be reflected back in Forge.",
3621
+ summary: "Rebuild SQLite wiki search, link, and metadata indexes.",
3622
+ whenToUse: "Use after large SQLite wiki changes or maintenance work that should refresh derived metadata.",
3617
3623
  inputShape: "{ spaceId?: string }",
3618
3624
  requiredFields: [],
3619
3625
  notes: [
3620
- "Forge treats the vault as a first-class local artifact, so this route is the bridge back into app metadata."
3626
+ "Forge treats SQLite as the canonical wiki store; this route refreshes derived indexes."
3621
3627
  ],
3622
3628
  example: '{"spaceId":"wiki_space_shared"}'
3623
3629
  },
@@ -3636,11 +3642,11 @@ const AGENT_ONBOARDING_TOOL_INPUT_CATALOG = [
3636
3642
  {
3637
3643
  toolName: "forge_ingest_wiki_source",
3638
3644
  summary: "Ingest raw text, local files, or URLs into the wiki, preserving a raw source artifact and returning page plus proposal outputs.",
3639
- whenToUse: "Use when the operator wants source material compiled into file-first wiki memory and optional Forge-entity proposals.",
3645
+ whenToUse: "Use when the operator wants source material compiled into SQLite-backed wiki memory and optional Forge-entity proposals.",
3640
3646
  inputShape: '{ spaceId?: string, titleHint?: string, sourceKind: "raw_text"|"local_path"|"url", sourceText?: string, sourcePath?: string, sourceUrl?: string, mimeType?: string, llmProfileId?: string, parseStrategy?: "auto"|"text_only"|"multimodal", entityProposalMode?: "none"|"suggest", createAsKind?: "wiki"|"evidence", linkedEntityHints?: Array<{ entityType, entityId, anchorKey? }> }',
3641
3647
  requiredFields: ["sourceKind", "sourceText/sourcePath/sourceUrl"],
3642
3648
  notes: [
3643
- "Forge preserves a raw artifact under the wiki space's raw directory.",
3649
+ "Forge preserves a raw ingest artifact separately from SQLite page content.",
3644
3650
  "Entity proposals are suggestions only; they are not auto-applied."
3645
3651
  ],
3646
3652
  example: '{"sourceKind":"url","sourceUrl":"https://example.com/article","titleHint":"Research import","parseStrategy":"auto","entityProposalMode":"suggest"}'
@@ -4013,7 +4019,7 @@ function buildAgentOnboardingPayload(request) {
4013
4019
  task: "A concrete actionable work item. Task status is board state, not proof of live work.",
4014
4020
  taskRun: "A live work session attached to a task. Start, heartbeat, focus, complete, and release runs instead of faking work with status alone.",
4015
4021
  note: "A Markdown work note that can link to one or many entities. Use notes for progress evidence, context, and close-out summaries.",
4016
- wiki: "Forge Wiki is the file-first memory layer: local Markdown pages plus media, backlinks, optional embeddings, explicit spaces, and structured links back to Forge entities.",
4022
+ wiki: "Forge Wiki is the SQLite-backed memory layer: Markdown content in notes rows plus media, backlinks, optional embeddings, explicit spaces, and structured links back to Forge entities.",
4017
4023
  sleepSession: "A sleep session is a first-class health record with timing, sleep and bed duration, stage breakdown, recovery metrics, annotations, and Forge links back to planning or Psyche context.",
4018
4024
  workoutSession: "A workout session is a first-class sports record imported from HealthKit or generated from a habit. It holds workout type, timing, energy or distance when available, subjective effort, narrative context, and Forge links.",
4019
4025
  preferences: "Forge Preferences is the explicit taste-modeling domain. It has workspaces, contexts, concept libraries, direct items, pairwise judgments, direct signals, and inferred scores.",
@@ -4157,6 +4163,11 @@ function buildAgentOnboardingPayload(request) {
4157
4163
  specializedDomainSurfaces: {
4158
4164
  movement: {
4159
4165
  summary: "Dedicated movement workspace API. Use these routes for stays, trips, time-in-place questions, visited places, trip detail, selection aggregates, user-defined overlays, and repair actions on already-recorded movement data.",
4166
+ routeSelectionQuestions: [
4167
+ "Is the user asking for a day, month, all-time, timeline, place, trip detail, or selected-span answer?",
4168
+ "Is this a missing-gap overlay, a saved-overlay repair, or an edit to one already-recorded stay, trip, or trip point?",
4169
+ "If the target is already known, what one time, place, or saved-object detail is still missing before acting?"
4170
+ ],
4160
4171
  readRoutes: {
4161
4172
  day: "/api/v1/movement/day",
4162
4173
  month: "/api/v1/movement/month",
@@ -4194,6 +4205,11 @@ function buildAgentOnboardingPayload(request) {
4194
4205
  },
4195
4206
  lifeForce: {
4196
4207
  summary: "Dedicated life-force API. Use it to read the current energy budget, drains, recommendations, and warnings, then patch only the parts that are meant to be user-controlled.",
4208
+ routeSelectionQuestions: [
4209
+ "Is the user trying to understand the overview, change durable profile assumptions, change a weekday curve, or log a right-now fatigue signal?",
4210
+ "Are they describing a repeatable weekly shape or a one-off current state?",
4211
+ "If the lane is already clear, what one weekday, profile field, or signal detail is still missing?"
4212
+ ],
4197
4213
  readRoutes: {
4198
4214
  overview: "/api/v1/life-force"
4199
4215
  },
@@ -4206,12 +4222,18 @@ function buildAgentOnboardingPayload(request) {
4206
4222
  "Life Force is a focused domain surface, not a batch CRUD entity type.",
4207
4223
  "Use GET /api/v1/life-force for the current overview payload with stats, drains, recommendations, and current-curve state.",
4208
4224
  "Patch the profile only for durable personal settings, update weekday templates only for the curve itself, and post fatigue signals for real-time tired or recovered observations.",
4225
+ "If the user says something like 'I always dip on Tuesdays after lunch', treat that as a weekday-template change rather than a one-off fatigue signal.",
4209
4226
  "If the user is asking what changed after a profile, template, or fatigue write, read the overview back so the effect stays visible.",
4210
4227
  "If the user already knows they want a profile change, weekday-template edit, or right-now fatigue signal, skip the broad lane question and ask only for the missing weekday, profile field, or signal detail."
4211
4228
  ]
4212
4229
  },
4213
4230
  workbench: {
4214
4231
  summary: "Dedicated graph-flow API. Use it for flow catalog reads, flow CRUD, execution, run history, published outputs, node results, and latest successful node outputs.",
4232
+ routeSelectionQuestions: [
4233
+ "Is the job flow discovery, flow editing, execution, published output, run detail, node result, latest node output, or flow chat follow-up?",
4234
+ "Does the user need a stable public contract or one execution artifact?",
4235
+ "If the flow is already known, what one run, node, or output scope detail is still missing before acting?"
4236
+ ],
4215
4237
  readRoutes: {
4216
4238
  listFlows: "/api/v1/workbench/flows",
4217
4239
  flowById: "/api/v1/workbench/flows/:id",
@@ -4237,6 +4259,7 @@ function buildAgentOnboardingPayload(request) {
4237
4259
  "Use the flow routes when the agent needs stable public input contracts, published outputs, node-level results, or reusable execution history.",
4238
4260
  "If the user is still figuring out inputs or editable structure, read flow detail or box catalog before asking them to author a payload from memory.",
4239
4261
  "Prefer the dedicated output and node-result routes over reverse-engineering raw traces.",
4262
+ "If the user only wants a published output, latest node output, or run detail, do not reopen a flow-edit intake before reading that artifact.",
4240
4263
  "If the user already named the flow and wants one output or one run, skip the broad lane question and ask only for the missing run, node, or output scope."
4241
4264
  ]
4242
4265
  }
@@ -4358,17 +4381,26 @@ function buildAgentOnboardingPayload(request) {
4358
4381
  movementTimeline: "/api/v1/movement/timeline",
4359
4382
  movementAllTime: "/api/v1/movement/all-time",
4360
4383
  movementPlaces: "/api/v1/movement/places",
4384
+ movementBoxDetail: "/api/v1/movement/boxes/:id",
4385
+ movementSettings: "/api/v1/movement/settings",
4386
+ movementSettingsUpdate: "/api/v1/movement/settings",
4361
4387
  movementTripDetail: "/api/v1/movement/trips/:id",
4362
4388
  movementSelection: "/api/v1/movement/selection",
4363
4389
  movementUserBoxPreflight: "/api/v1/movement/user-boxes/preflight",
4364
4390
  movementUserBoxUpdate: "/api/v1/movement/user-boxes/:id",
4391
+ movementUserBoxDelete: "/api/v1/movement/user-boxes/:id",
4365
4392
  movementAutomaticBoxInvalidate: "/api/v1/movement/automatic-boxes/:id/invalidate",
4366
4393
  movementStayUpdate: "/api/v1/movement/stays/:id",
4394
+ movementStayDelete: "/api/v1/movement/stays/:id",
4367
4395
  movementTripUpdate: "/api/v1/movement/trips/:id",
4396
+ movementTripDelete: "/api/v1/movement/trips/:id",
4368
4397
  movementTripPointUpdate: "/api/v1/movement/trips/:id/points/:pointId",
4398
+ movementTripPointDelete: "/api/v1/movement/trips/:id/points/:pointId",
4399
+ workbenchBoxCatalog: "/api/v1/workbench/catalog/boxes",
4369
4400
  workbenchFlows: "/api/v1/workbench/flows",
4370
4401
  workbenchFlowBySlug: "/api/v1/workbench/flows/by-slug/:slug",
4371
4402
  workbenchPublishedOutput: "/api/v1/workbench/flows/:id/output",
4403
+ workbenchRuns: "/api/v1/workbench/flows/:id/runs",
4372
4404
  workbenchRunDetail: "/api/v1/workbench/flows/:id/runs/:runId",
4373
4405
  workbenchNodeResult: "/api/v1/workbench/flows/:id/runs/:runId/nodes/:nodeId",
4374
4406
  workbenchLatestNodeOutput: "/api/v1/workbench/flows/:id/nodes/:nodeId/output",
@@ -4444,9 +4476,9 @@ function buildAgentOnboardingPayload(request) {
4444
4476
  saveSuggestionPlacement: "end_of_message",
4445
4477
  saveSuggestionTone: "gentle_optional",
4446
4478
  maxQuestionsPerTurn: 1,
4447
- psycheExplorationRule: "When a Psyche entity needs understanding first, begin with one exploratory question before any working formulation, replacement belief, suggested title, or save pitch. Keep the opening reflection to one or two short sentences, stay in plain prose instead of bullets or numbered lists, keep that first reply short, do not mention Forge search or save structure yet, avoid colons or list-shaped phrasing, prefer what/when/how over why until the experience is grounded, wait for the user's answer before offering a fuller formulation, ask permission before moving from charged exploration into naming or challenge when needed, do not widen into adjacent entities until the current one has a working sentence the user recognizes, and once the lived experience is coherent stop deepening and help the user name it cleanly. When the user is updating a Psyche record because of one fresh episode, anchor in that episode before renaming the durable formulation. If the user accepts the wording, move toward the save instead of reopening deeper exploration.",
4448
- specializedSurfaceRule: "For Movement, Life Force, and Workbench, clarify the lane first, then name the dedicated route family in plain language and do not guess at a generic CRUD path. If the truth of the current state is still uncertain, read the relevant specialized view before you mutate it. When the user already named a precise correction or review target, confirm only the route-selecting detail that is still missing. After a concrete specialized-surface correction, read the relevant specialized view back when the user is trying to understand the result rather than just store it. The canonical runtime routes stay under /api/v1/*, and the OpenClaw HTTP mirror exposes the same families under /forge/v1/movement, /forge/v1/life-force, and /forge/v1/workbench.",
4449
- reviewShortcutRule: "When the user is reviewing or correcting an existing record, narrow the saved object, timeframe, or route family first. Do not reopen the whole intake unless the user is actually redefining the record.",
4479
+ psycheExplorationRule: "When a Psyche entity needs understanding first, begin with one exploratory question before any working formulation, replacement belief, suggested title, or save pitch. Keep the opening reflection to one or two short sentences, stay in plain prose instead of bullets or numbered lists, keep that first reply short, do not mention Forge search or save structure yet, avoid colons or list-shaped phrasing, prefer what/when/how over why until the experience is grounded, wait for the user's answer before offering a fuller formulation, ask permission before moving from charged exploration into naming or challenge when needed, make the next question help the user feel more able to name the experience rather than more examined, do not widen into adjacent entities until the current one has a working sentence the user recognizes, and once the lived experience is coherent stop deepening and help the user name it cleanly. When the user is updating a Psyche record because of one fresh episode, anchor in that episode before renaming the durable formulation, begin with the smallest part of the old wording that no longer fits, and do not reopen the full origin story unless the new understanding is truly structural. If the user accepts the wording, move toward the save instead of reopening deeper exploration.",
4480
+ specializedSurfaceRule: "For Movement, Life Force, and Workbench, clarify the lane first, then name the dedicated route family in plain language and do not guess at a generic CRUD path. Use specializedDomainSurfaces.routeSelectionQuestions when they are present so the next follow-up stays route-selective instead of generic. Once the lane is clear, talk in route-relevant nouns such as timeline, overlay, weekday template, published output, run detail, or node result rather than generic record language. If the truth of the current state is still uncertain, read the relevant specialized view before you mutate it. When the user already named a precise correction or review target, confirm only the route-selecting detail that is still missing. After a concrete specialized-surface correction, read the relevant specialized view back when the user is trying to understand the result rather than just store it. The canonical runtime routes stay under /api/v1/*, and the OpenClaw HTTP mirror exposes the same families under /forge/v1/movement, /forge/v1/life-force, and /forge/v1/workbench.",
4481
+ reviewShortcutRule: "When the user is reviewing or correcting an existing record, ask what practical question they want the read or correction to answer, then narrow the saved object, timeframe, or route family first. Do not reopen the whole intake unless the user is actually redefining the record.",
4450
4482
  readModelWriteRule: "Self-observation is note-backed and should be written through observed notes with frontmatter.observedAt. Sleep and workout sessions stay on batch CRUD by default; use the reflective review helpers only when enriching one already-known record after review.",
4451
4483
  psycheOpeningQuestionRule: "Prefer a concrete opening question tied to the entity: ask when the value mattered, what happened the last time the pattern appeared, what cue or body signal came first before the behavior, what the belief starts saying about self or outcome, what feels most at risk inside the mode, what the part is trying to get the user to do or stop doing, or where the shift began in the incident. Reflect briefly before the question, choose one follow-up lane at a time, say what is becoming clearer before the next deeper question, and if several Psyche entities are visible hold the adjacent ones lightly until the main container is clear.",
4452
4484
  duplicateCheckRoute: "/api/v1/entities/search",
@@ -34,14 +34,12 @@ function resolveLegacyDatabasePath(root = dataRoot) {
34
34
  function hasCanonicalRuntimeLayout(root = dataRoot) {
35
35
  const canonicalRoot = resolveCanonicalDataDir(root);
36
36
  return (existsSync(resolveCanonicalDatabasePath(root)) ||
37
- existsSync(path.join(canonicalRoot, "wiki")) ||
38
37
  existsSync(path.join(canonicalRoot, "wiki-ingest")) ||
39
38
  existsSync(path.join(canonicalRoot, ".forge-secrets.key")));
40
39
  }
41
40
  function hasLegacyRuntimeLayout(root = dataRoot) {
42
41
  const legacyRoot = resolveLegacyDataDir(root);
43
42
  return (existsSync(resolveLegacyDatabasePath(root)) ||
44
- existsSync(path.join(legacyRoot, "wiki")) ||
45
43
  existsSync(path.join(legacyRoot, "wiki-ingest")) ||
46
44
  existsSync(path.join(legacyRoot, ".forge-secrets.key")));
47
45
  }
@@ -2137,6 +2137,11 @@ export function buildOpenApiDocument() {
2137
2137
  "id",
2138
2138
  "label",
2139
2139
  "agentType",
2140
+ "identityKey",
2141
+ "provider",
2142
+ "machineKey",
2143
+ "personaKey",
2144
+ "linkedUsers",
2140
2145
  "trustLevel",
2141
2146
  "autonomyMode",
2142
2147
  "approvalMode",
@@ -2150,6 +2155,20 @@ export function buildOpenApiDocument() {
2150
2155
  id: { type: "string" },
2151
2156
  label: { type: "string" },
2152
2157
  agentType: { type: "string" },
2158
+ identityKey: nullable({ type: "string" }),
2159
+ provider: nullable({ type: "string", enum: ["openclaw", "hermes", "codex"] }),
2160
+ machineKey: nullable({ type: "string" }),
2161
+ personaKey: nullable({ type: "string" }),
2162
+ linkedUsers: arrayOf({
2163
+ type: "object",
2164
+ additionalProperties: false,
2165
+ required: ["userId", "role", "user"],
2166
+ properties: {
2167
+ userId: { type: "string" },
2168
+ role: { type: "string" },
2169
+ user: nullable({ $ref: "#/components/schemas/UserSummary" })
2170
+ }
2171
+ }),
2153
2172
  trustLevel: {
2154
2173
  type: "string",
2155
2174
  enum: ["standard", "trusted", "autonomous"]
@@ -3234,17 +3253,26 @@ export function buildOpenApiDocument() {
3234
3253
  "movementTimeline",
3235
3254
  "movementAllTime",
3236
3255
  "movementPlaces",
3256
+ "movementBoxDetail",
3257
+ "movementSettings",
3258
+ "movementSettingsUpdate",
3237
3259
  "movementTripDetail",
3238
3260
  "movementSelection",
3239
3261
  "movementUserBoxPreflight",
3240
3262
  "movementUserBoxUpdate",
3263
+ "movementUserBoxDelete",
3241
3264
  "movementAutomaticBoxInvalidate",
3242
3265
  "movementStayUpdate",
3266
+ "movementStayDelete",
3243
3267
  "movementTripUpdate",
3268
+ "movementTripDelete",
3244
3269
  "movementTripPointUpdate",
3270
+ "movementTripPointDelete",
3271
+ "workbenchBoxCatalog",
3245
3272
  "workbenchFlows",
3246
3273
  "workbenchFlowBySlug",
3247
3274
  "workbenchPublishedOutput",
3275
+ "workbenchRuns",
3248
3276
  "workbenchRunDetail",
3249
3277
  "workbenchNodeResult",
3250
3278
  "workbenchLatestNodeOutput",
@@ -3273,17 +3301,26 @@ export function buildOpenApiDocument() {
3273
3301
  movementTimeline: { type: "string" },
3274
3302
  movementAllTime: { type: "string" },
3275
3303
  movementPlaces: { type: "string" },
3304
+ movementBoxDetail: { type: "string" },
3305
+ movementSettings: { type: "string" },
3306
+ movementSettingsUpdate: { type: "string" },
3276
3307
  movementTripDetail: { type: "string" },
3277
3308
  movementSelection: { type: "string" },
3278
3309
  movementUserBoxPreflight: { type: "string" },
3279
3310
  movementUserBoxUpdate: { type: "string" },
3311
+ movementUserBoxDelete: { type: "string" },
3280
3312
  movementAutomaticBoxInvalidate: { type: "string" },
3281
3313
  movementStayUpdate: { type: "string" },
3314
+ movementStayDelete: { type: "string" },
3282
3315
  movementTripUpdate: { type: "string" },
3316
+ movementTripDelete: { type: "string" },
3283
3317
  movementTripPointUpdate: { type: "string" },
3318
+ movementTripPointDelete: { type: "string" },
3319
+ workbenchBoxCatalog: { type: "string" },
3284
3320
  workbenchFlows: { type: "string" },
3285
3321
  workbenchFlowBySlug: { type: "string" },
3286
3322
  workbenchPublishedOutput: { type: "string" },
3323
+ workbenchRuns: { type: "string" },
3287
3324
  workbenchRunDetail: { type: "string" },
3288
3325
  workbenchNodeResult: { type: "string" },
3289
3326
  workbenchLatestNodeOutput: { type: "string" },
@@ -4811,6 +4848,24 @@ export function buildOpenApiDocument() {
4811
4848
  }
4812
4849
  }
4813
4850
  },
4851
+ "/api/v1/movement/boxes/{id}": {
4852
+ get: {
4853
+ summary: "Read one movement box with projected detail, provenance, and raw linked evidence",
4854
+ responses: {
4855
+ "200": jsonResponse({
4856
+ type: "object",
4857
+ required: ["movement"],
4858
+ properties: {
4859
+ movement: {
4860
+ type: "object",
4861
+ additionalProperties: true
4862
+ }
4863
+ }
4864
+ }, "Movement box detail"),
4865
+ "404": { $ref: "#/components/responses/Error" }
4866
+ }
4867
+ }
4868
+ },
4814
4869
  "/api/v1/movement/trips/{id}": {
4815
4870
  get: {
4816
4871
  summary: "Read one movement trip with its full detail",
@@ -5241,7 +5296,7 @@ export function buildOpenApiDocument() {
5241
5296
  }
5242
5297
  },
5243
5298
  post: {
5244
- summary: "Create a wiki page through the file-backed wiki surface",
5299
+ summary: "Create a wiki page through the SQLite-backed wiki surface",
5245
5300
  responses: {
5246
5301
  "200": jsonResponse({
5247
5302
  type: "object",
@@ -5269,7 +5324,7 @@ export function buildOpenApiDocument() {
5269
5324
  }
5270
5325
  },
5271
5326
  patch: {
5272
- summary: "Update an existing wiki page through the file-backed surface",
5327
+ summary: "Update an existing wiki page through the SQLite-backed surface",
5273
5328
  responses: {
5274
5329
  "200": jsonResponse({
5275
5330
  type: "object",
@@ -5315,7 +5370,7 @@ export function buildOpenApiDocument() {
5315
5370
  },
5316
5371
  "/api/v1/wiki/sync": {
5317
5372
  post: {
5318
- summary: "Resync markdown files from the local wiki vault into Forge metadata",
5373
+ summary: "Rebuild SQLite wiki search, link, and metadata indexes",
5319
5374
  responses: {
5320
5375
  "200": jsonResponse({
5321
5376
  type: "object",
@@ -1,7 +1,8 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { getDatabase, runInTransaction } from "../db.js";
3
3
  import { recordActivityEvent } from "./activity-events.js";
4
4
  import { listAgentActions } from "./collaboration.js";
5
+ import { ensureBotUser, getUserById } from "./users.js";
5
6
  import { agentActionSchema, agentRuntimeEventLevelSchema, agentRuntimeReconnectPlanSchema, agentRuntimeSessionEventSchema, agentRuntimeSessionSchema, createAgentRuntimeSessionEventSchema, createAgentRuntimeSessionSchema, disconnectAgentRuntimeSessionSchema, heartbeatAgentRuntimeSessionSchema, reconnectAgentRuntimeSessionSchema } from "../types.js";
6
7
  function parseMetadata(raw) {
7
8
  try {
@@ -211,7 +212,95 @@ function ensureCurrentSessionInstance(row, externalSessionId) {
211
212
  }
212
213
  return true;
213
214
  }
214
- function disconnectSupersededSingletonSessions(parsed, sessionId, now) {
215
+ function normalizeIdentityPart(value) {
216
+ return (value
217
+ ?.trim()
218
+ .toLowerCase()
219
+ .replace(/[^a-z0-9._:]+/g, "_")
220
+ .replace(/^_+|_+$/g, "") ?? "");
221
+ }
222
+ function shortHash(value) {
223
+ return createHash("sha1").update(value).digest("hex").slice(0, 12);
224
+ }
225
+ function canonicalRuntimeAgentLabel(provider) {
226
+ if (provider === "openclaw") {
227
+ return "Forge OpenClaw";
228
+ }
229
+ if (provider === "hermes") {
230
+ return "Forge Hermes";
231
+ }
232
+ return "Forge Codex";
233
+ }
234
+ function canonicalRuntimeDescription(provider) {
235
+ return `${canonicalRuntimeAgentLabel(provider)} runtime agent with stable Forge identity and linked Kanban user.`;
236
+ }
237
+ function canonicalAgentUserSpec(provider) {
238
+ if (provider === "openclaw") {
239
+ return {
240
+ id: "user_agent_openclaw",
241
+ handle: "openclaw",
242
+ displayName: "OpenClaw",
243
+ description: "OpenClaw runtime actor linked to Forge agent identity and Kanban ownership.",
244
+ accentColor: "#38bdf8"
245
+ };
246
+ }
247
+ if (provider === "hermes") {
248
+ return {
249
+ id: "user_agent_hermes",
250
+ handle: "hermes",
251
+ displayName: "Hermes",
252
+ description: "Hermes runtime actor linked to Forge agent identity and Kanban ownership.",
253
+ accentColor: "#a78bfa"
254
+ };
255
+ }
256
+ return {
257
+ id: "user_agent_codex",
258
+ handle: "codex",
259
+ displayName: "Codex",
260
+ description: "Codex runtime actor linked to Forge agent identity and Kanban ownership.",
261
+ accentColor: "#22c55e"
262
+ };
263
+ }
264
+ function deriveMachineKey(input) {
265
+ const explicit = normalizeIdentityPart(input.machineKey);
266
+ if (explicit) {
267
+ return explicit;
268
+ }
269
+ const source = [
270
+ normalizeText(input.dataRoot) ?? "",
271
+ normalizeText(input.baseUrl) ?? "local"
272
+ ].join("|");
273
+ return `machine_${shortHash(source)}`;
274
+ }
275
+ function derivePersonaKey(input) {
276
+ return (normalizeIdentityPart(input.personaKey) ||
277
+ normalizeIdentityPart(input.agentType) ||
278
+ "default");
279
+ }
280
+ function deriveAgentIdentityKey(input) {
281
+ const explicit = normalizeIdentityPart(input.agentIdentityKey);
282
+ if (explicit) {
283
+ return explicit;
284
+ }
285
+ return `runtime:${input.provider}:${deriveMachineKey(input)}:${derivePersonaKey(input)}`;
286
+ }
287
+ function linkAgentIdentityUsers(agentId, provider, linkedUserIds, now) {
288
+ const primaryUser = ensureBotUser(canonicalAgentUserSpec(provider));
289
+ const normalizedUserIds = Array.from(new Set([primaryUser.id, ...linkedUserIds.map((id) => id.trim()).filter(Boolean)]));
290
+ for (const userId of normalizedUserIds) {
291
+ if (!getUserById(userId)) {
292
+ continue;
293
+ }
294
+ getDatabase()
295
+ .prepare(`INSERT INTO agent_identity_users (agent_id, user_id, role, created_at, updated_at)
296
+ VALUES (?, ?, ?, ?, ?)
297
+ ON CONFLICT(agent_id, user_id) DO UPDATE SET
298
+ role = excluded.role,
299
+ updated_at = excluded.updated_at`)
300
+ .run(agentId, userId, userId === primaryUser.id ? "primary" : "linked", now, now);
301
+ }
302
+ }
303
+ function disconnectSupersededSingletonSessions(parsed, sessionId, agentId, now) {
215
304
  if (!parsed.metadata?.singleton) {
216
305
  return;
217
306
  }
@@ -224,11 +313,11 @@ function disconnectSupersededSingletonSessions(parsed, sessionId, now) {
224
313
  created_at, updated_at
225
314
  FROM agent_runtime_sessions
226
315
  WHERE provider = ?
227
- AND agent_label = ?
316
+ AND agent_id = ?
228
317
  AND coalesce(base_url, '') = coalesce(?, '')
229
318
  AND coalesce(data_root, '') = coalesce(?, '')
230
319
  AND id <> ?`)
231
- .all(parsed.provider, parsed.agentLabel, normalizeText(parsed.baseUrl), normalizeText(parsed.dataRoot), sessionId);
320
+ .all(parsed.provider, agentId, normalizeText(parsed.baseUrl), normalizeText(parsed.dataRoot), sessionId);
232
321
  for (const row of rows) {
233
322
  if (row.status === "disconnected" && row.ended_at) {
234
323
  continue;
@@ -251,29 +340,45 @@ function disconnectSupersededSingletonSessions(parsed, sessionId, now) {
251
340
  }
252
341
  }
253
342
  function upsertRuntimeAgentIdentity(input) {
343
+ const identityKey = deriveAgentIdentityKey(input);
344
+ const machineKey = deriveMachineKey(input);
345
+ const personaKey = derivePersonaKey(input);
346
+ const label = canonicalRuntimeAgentLabel(input.provider);
254
347
  const existing = getDatabase()
255
348
  .prepare(`SELECT id
256
349
  FROM agent_identities
257
- WHERE lower(label) = lower(?)
350
+ WHERE identity_key = ?
351
+ OR (
352
+ (identity_key IS NULL OR machine_key IS NULL OR machine_key = 'legacy' OR identity_key LIKE 'runtime:%:legacy:%')
353
+ AND (
354
+ provider = ?
355
+ OR lower(agent_type) = lower(?)
356
+ OR lower(label) = lower(?)
357
+ )
358
+ )
258
359
  LIMIT 1`)
259
- .get(input.agentLabel);
360
+ .get(identityKey, input.provider, input.provider, label);
260
361
  const now = new Date().toISOString();
261
- const description = `${input.provider[0].toUpperCase()}${input.provider.slice(1)} runtime session participant registered through Forge.`;
362
+ const description = canonicalRuntimeDescription(input.provider);
262
363
  if (existing) {
263
364
  getDatabase()
264
365
  .prepare(`UPDATE agent_identities
265
- SET agent_type = ?, updated_at = ?
366
+ SET label = ?, agent_type = ?, identity_key = ?, provider = ?,
367
+ machine_key = ?, persona_key = ?, description = ?, updated_at = ?
266
368
  WHERE id = ?`)
267
- .run(input.agentType || input.provider, now, existing.id);
369
+ .run(label, input.agentType || input.provider, identityKey, input.provider, machineKey, personaKey, description, now, existing.id);
370
+ linkAgentIdentityUsers(existing.id, input.provider, input.linkedUserIds, now);
268
371
  return existing.id;
269
372
  }
270
373
  const agentId = `agt_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
271
374
  getDatabase()
272
375
  .prepare(`INSERT INTO agent_identities (
273
- id, label, agent_type, trust_level, autonomy_mode, approval_mode,
376
+ id, label, agent_type, identity_key, provider, machine_key, persona_key,
377
+ trust_level, autonomy_mode, approval_mode,
274
378
  description, created_at, updated_at
275
- ) VALUES (?, ?, ?, 'trusted', 'approval_required', 'approval_by_default', ?, ?, ?)`)
276
- .run(agentId, input.agentLabel, input.agentType || input.provider, description, now, now);
379
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'trusted', 'approval_required', 'approval_by_default', ?, ?, ?)`)
380
+ .run(agentId, label, input.agentType || input.provider, identityKey, input.provider, machineKey, personaKey, description, now, now);
381
+ linkAgentIdentityUsers(agentId, input.provider, input.linkedUserIds, now);
277
382
  return agentId;
278
383
  }
279
384
  function insertSessionEvent(sessionId, input, now = new Date().toISOString()) {
@@ -321,6 +426,7 @@ export function registerAgentRuntimeSession(input) {
321
426
  return runInTransaction(() => {
322
427
  const now = new Date().toISOString();
323
428
  const agentId = upsertRuntimeAgentIdentity(parsed);
429
+ const agentLabel = canonicalRuntimeAgentLabel(parsed.provider);
324
430
  const existing = getSessionRowByCompositeKey(parsed.provider, parsed.sessionKey);
325
431
  const sessionId = existing?.id ?? `ags_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
326
432
  if (existing) {
@@ -332,7 +438,7 @@ export function registerAgentRuntimeSession(input) {
332
438
  last_error = ?, last_seen_at = ?, last_heartbeat_at = ?, started_at = ?,
333
439
  ended_at = NULL, metadata_json = ?, updated_at = ?
334
440
  WHERE id = ?`)
335
- .run(agentId, parsed.agentLabel, parsed.agentType || parsed.provider, parsed.sessionLabel || parsed.sessionKey, parsed.actorLabel, parsed.connectionMode, parsed.status === "error" ? "error" : "connected", normalizeText(parsed.baseUrl), normalizeText(parsed.webUrl), normalizeText(parsed.dataRoot), normalizeText(parsed.externalSessionId), parsed.staleAfterSeconds, normalizeText(parsed.lastError), now, now, now, JSON.stringify(parsed.metadata), now, sessionId);
441
+ .run(agentId, agentLabel, parsed.agentType || parsed.provider, parsed.sessionLabel || parsed.sessionKey, parsed.actorLabel, parsed.connectionMode, parsed.status === "error" ? "error" : "connected", normalizeText(parsed.baseUrl), normalizeText(parsed.webUrl), normalizeText(parsed.dataRoot), normalizeText(parsed.externalSessionId), parsed.staleAfterSeconds, normalizeText(parsed.lastError), now, now, now, JSON.stringify(parsed.metadata), now, sessionId);
336
442
  insertSessionEvent(sessionId, {
337
443
  eventType: "session_registered",
338
444
  title: "Session re-registered",
@@ -349,7 +455,7 @@ export function registerAgentRuntimeSession(input) {
349
455
  last_seen_at, last_heartbeat_at, started_at, ended_at, metadata_json,
350
456
  created_at, updated_at
351
457
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, NULL, ?, ?, ?)`)
352
- .run(sessionId, agentId, parsed.agentLabel, parsed.agentType || parsed.provider, parsed.provider, parsed.sessionKey, parsed.sessionLabel || parsed.sessionKey, parsed.actorLabel, parsed.connectionMode, parsed.status === "error" ? "error" : "connected", normalizeText(parsed.baseUrl), normalizeText(parsed.webUrl), normalizeText(parsed.dataRoot), normalizeText(parsed.externalSessionId), parsed.staleAfterSeconds, normalizeText(parsed.lastError), now, now, now, JSON.stringify(parsed.metadata), now, now);
458
+ .run(sessionId, agentId, agentLabel, parsed.agentType || parsed.provider, parsed.provider, parsed.sessionKey, parsed.sessionLabel || parsed.sessionKey, parsed.actorLabel, parsed.connectionMode, parsed.status === "error" ? "error" : "connected", normalizeText(parsed.baseUrl), normalizeText(parsed.webUrl), normalizeText(parsed.dataRoot), normalizeText(parsed.externalSessionId), parsed.staleAfterSeconds, normalizeText(parsed.lastError), now, now, now, JSON.stringify(parsed.metadata), now, now);
353
459
  insertSessionEvent(sessionId, {
354
460
  eventType: "session_registered",
355
461
  title: "Session registered",
@@ -357,12 +463,12 @@ export function registerAgentRuntimeSession(input) {
357
463
  metadata: parsed.metadata
358
464
  }, now);
359
465
  }
360
- disconnectSupersededSingletonSessions(parsed, sessionId, now);
466
+ disconnectSupersededSingletonSessions(parsed, sessionId, agentId, now);
361
467
  recordActivityEvent({
362
468
  entityType: "session",
363
469
  entityId: sessionId,
364
470
  eventType: "agent_session_registered",
365
- title: `Agent session registered: ${parsed.agentLabel}`,
471
+ title: `Agent session registered: ${agentLabel}`,
366
472
  description: `${parsed.provider} registered a live agent session.`,
367
473
  actor: parsed.actorLabel,
368
474
  source: "agent",
@@ -47,6 +47,11 @@ export function buildConnectionAgentIdentity(connection) {
47
47
  id: connection.agentId,
48
48
  label: connection.agentLabel,
49
49
  agentType: connection.provider,
50
+ identityKey: `model:${connection.id}`,
51
+ provider: null,
52
+ machineKey: null,
53
+ personaKey: connection.provider,
54
+ linkedUsers: [],
50
55
  trustLevel: "trusted",
51
56
  autonomyMode: "approval_required",
52
57
  approvalMode: "approval_by_default",
@@ -40,6 +40,9 @@ function normalizeTags(tags) {
40
40
  return true;
41
41
  });
42
42
  }
43
+ function canonicalNoteSourcePath() {
44
+ return "";
45
+ }
43
46
  function parseTagsJson(raw) {
44
47
  try {
45
48
  const parsed = JSON.parse(raw);
@@ -438,7 +441,7 @@ export function createNote(input, context) {
438
441
  source_path, frontmatter_json, revision_hash, last_synced_at, created_at, updated_at
439
442
  )
440
443
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
441
- .run(id, wikiFields.kind, wikiFields.title, wikiFields.slug, wikiFields.spaceId, wikiFields.parentSlug, wikiFields.indexOrder, wikiFields.showInIndex ? 1 : 0, JSON.stringify(wikiFields.aliases), wikiFields.summary, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, JSON.stringify(parsed.tags), parsed.destroyAt, parsed.sourcePath, JSON.stringify(parsed.frontmatter), parsed.revisionHash, parsed.lastSyncedAt ?? null, now, now);
444
+ .run(id, wikiFields.kind, wikiFields.title, wikiFields.slug, wikiFields.spaceId, wikiFields.parentSlug, wikiFields.indexOrder, wikiFields.showInIndex ? 1 : 0, JSON.stringify(wikiFields.aliases), wikiFields.summary, parsed.contentMarkdown, contentPlain, parsed.author ?? context.actor ?? null, context.source, JSON.stringify(parsed.tags), parsed.destroyAt, canonicalNoteSourcePath(), JSON.stringify(parsed.frontmatter), parsed.revisionHash, parsed.lastSyncedAt ?? null, now, now);
442
445
  insertLinks(id, parsed.links, now);
443
446
  setEntityOwner("note", id, parsed.userId, parsed.author ?? context.actor ?? null);
444
447
  clearDeletedEntityRecord("note", id);
@@ -504,7 +507,7 @@ export function updateNote(noteId, input, context) {
504
507
  existing
505
508
  });
506
509
  const nextFrontmatter = patch.frontmatter === undefined ? existing.frontmatter : patch.frontmatter;
507
- const nextSourcePath = patch.sourcePath === undefined ? existing.sourcePath : patch.sourcePath;
510
+ const nextSourcePath = canonicalNoteSourcePath();
508
511
  const nextRevisionHash = patch.revisionHash === undefined
509
512
  ? existing.revisionHash
510
513
  : patch.revisionHash;