@lightcone-ai/daemon 0.10.3 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -12,6 +12,7 @@ import { homedir } from 'os';
12
12
  import path from 'path';
13
13
  import { fileURLToPath } from 'url';
14
14
  import { parseCodexLine, adaptCodexSystemPrompt } from './drivers/codex.js';
15
+ import { injectWorkspaceContext } from './drivers/claude.js';
15
16
  import { parseKimiLine, encodeKimiStdin } from './drivers/kimi.js';
16
17
  import { startSession, stopSession, stopAllSessions } from './browser-login.js';
17
18
  import { markInvalidatedLeases } from './governance-state.js';
@@ -427,6 +428,32 @@ export class AgentManager {
427
428
  };
428
429
  }
429
430
 
431
+ // Fetches the per-spawn workspace context bundle (Goal State + active
432
+ // context items) so the daemon can inject it into the agent's system
433
+ // prompt. Returns '' when there is nothing to inject; never throws — a
434
+ // network error or absent workspaceId just degrades to empty context, so
435
+ // spawn proceeds without the bundle rather than failing closed.
436
+ async _fetchWorkspaceContextPrompt({ agentId, workspaceId }) {
437
+ if (!workspaceId) return '';
438
+ if (!this.serverUrl || !this.machineApiKey) return '';
439
+ try {
440
+ const url = `${this.serverUrl}/internal/agent/${encodeURIComponent(agentId)}/context?workspaceId=${encodeURIComponent(workspaceId)}`;
441
+ const res = await fetch(url, {
442
+ headers: { 'Authorization': `Bearer ${this.machineApiKey}` },
443
+ });
444
+ if (!res.ok) {
445
+ const text = await res.text();
446
+ console.log(`[AgentManager] Workspace context fetch failed for ${agentId} (non-fatal): ${res.status} ${text.slice(0, 200)}`);
447
+ return '';
448
+ }
449
+ const payload = await res.json();
450
+ return typeof payload?.renderedPrompt === 'string' ? payload.renderedPrompt : '';
451
+ } catch (err) {
452
+ console.log(`[AgentManager] Workspace context fetch error for ${agentId} (non-fatal): ${err.message}`);
453
+ return '';
454
+ }
455
+ }
456
+
430
457
  async _fetchSpawnDirective({
431
458
  agentId,
432
459
  workspaceId,
@@ -642,10 +669,23 @@ export class AgentManager {
642
669
  ...credentialEnvVars,
643
670
  };
644
671
 
672
+ // Inject the assembled workspace context (Goal State + active context
673
+ // items) directly into the system prompt, replacing the legacy
674
+ // "read BRIEF.md on startup" handshake. Empty workspaces leave the prompt
675
+ // unchanged.
676
+ const renderedWorkspaceContext = await this._fetchWorkspaceContextPrompt({
677
+ agentId,
678
+ workspaceId,
679
+ });
680
+ const baseSystemPrompt = typeof directive?.system_prompt === 'string'
681
+ ? directive.system_prompt
682
+ : '';
683
+ directive.system_prompt = injectWorkspaceContext(baseSystemPrompt, renderedWorkspaceContext);
684
+
645
685
  const runtimeConfig = {
646
686
  ...config,
647
687
  authToken: this.machineApiKey,
648
- systemPrompt: typeof directive?.system_prompt === 'string' ? directive.system_prompt : '',
688
+ systemPrompt: directive.system_prompt,
649
689
  envVars: normalizeObject(directive?.env_vars),
650
690
  };
651
691
 
@@ -84,6 +84,10 @@ const DEFAULT_TOOL_CLASSIFICATION = {
84
84
  read_memory: 'local',
85
85
  upload_image: 'local',
86
86
  read_file_base64: 'local',
87
+ write_governance_decision: 'local',
88
+ write_governance_correction: 'local',
89
+ get_orchestrate_context: 'local',
90
+ complete_orchestrate_trigger: 'local',
87
91
 
88
92
  send_message: 'mandatory',
89
93
  create_tasks: 'mandatory',
@@ -97,6 +101,7 @@ const DEFAULT_TOOL_CLASSIFICATION = {
97
101
  skill_update: 'mandatory',
98
102
  request_approval: 'mandatory',
99
103
  execute_approved_action: 'mandatory',
104
+ promote_context: 'mandatory',
100
105
 
101
106
  search_messages: 'cacheable',
102
107
  list_server: 'cacheable',
@@ -126,6 +131,7 @@ const CACHE_INVALIDATION_TOOLS = new Set([
126
131
  'skill_update',
127
132
  'request_approval',
128
133
  'execute_approved_action',
134
+ 'promote_context',
129
135
  ]);
130
136
 
131
137
  const governanceContext = {
@@ -190,6 +196,11 @@ function inferToolForApi(method, apiPath, body) {
190
196
  if (method === 'PATCH' && cleanPath.startsWith('/skills/')) return 'skill_update';
191
197
  if (method === 'POST' && cleanPath === '/actions/request') return 'request_approval';
192
198
  if (method === 'POST' && /^\/actions\/[^/]+\/execute$/.test(cleanPath)) return 'execute_approved_action';
199
+ if (method === 'POST' && cleanPath === '/orchestrate/decision') return 'write_governance_decision';
200
+ if (method === 'POST' && cleanPath === '/orchestrate/correction') return 'write_governance_correction';
201
+ if (method === 'GET' && cleanPath === '/orchestrate/context') return 'get_orchestrate_context';
202
+ if (method === 'POST' && cleanPath === '/orchestrate/complete') return 'complete_orchestrate_trigger';
203
+ if (method === 'POST' && cleanPath === '/context-proposals') return 'promote_context';
193
204
  return null;
194
205
  }
195
206
 
@@ -767,7 +778,7 @@ server.tool('write_memory', 'Write or update a memory file (full content replace
767
778
  });
768
779
 
769
780
  // ── list_workspace ────────────────────────────────────────────────────────────
770
- server.tool('list_workspace', 'List all files in the shared workspace (BRIEF.md, KNOWLEDGE.md, artifacts/, notes/)', {}, async () => {
781
+ server.tool('list_workspace', 'List all files in the shared workspace (typically artifacts/ and notes/). Use promote_context — not a workspace file — to share workspace-level knowledge with future agents.', {}, async () => {
771
782
  if (!currentWorkspaceId) return { content: [{ type: 'text', text: 'No workspace context.' }] };
772
783
  const data = await api('GET', `/workspace-memory?workspaceId=${encodeURIComponent(currentWorkspaceId)}`);
773
784
  const files = data.files ?? [];
@@ -776,7 +787,7 @@ server.tool('list_workspace', 'List all files in the shared workspace (BRIEF.md,
776
787
  });
777
788
 
778
789
  // ── read_workspace ────────────────────────────────────────────────────────────
779
- server.tool('read_workspace', 'Read a file from the shared workspace (e.g. "BRIEF.md", "KNOWLEDGE.md", "artifacts/report.html")', {
790
+ server.tool('read_workspace', 'Read a file from the shared workspace (e.g. "artifacts/report.html", "notes/work-log.md"). The active workspace context (Goal State, decisions, knowledge) is already injected into your system prompt — do not use this tool to recover it.', {
780
791
  path: z.string().describe('File path relative to workspace root'),
781
792
  }, async ({ path }) => {
782
793
  if (!currentWorkspaceId) return { content: [{ type: 'text', text: 'No workspace context.' }] };
@@ -799,7 +810,7 @@ server.tool('read_workspace', 'Read a file from the shared workspace (e.g. "BRIE
799
810
  });
800
811
 
801
812
  // ── write_workspace ───────────────────────────────────────────────────────────
802
- server.tool('write_workspace', 'Write a file to the shared workspace. Use this to save ALL deliverables: code, HTML, scripts, reports, data files, images — everything goes under artifacts/. Also use for KNOWLEDGE.md and shared notes. For binary files (images/PNG/JPG), encode as base64 data URL: read the file with fs.readFileSync, then format as "data:image/png;base64," + buf.toString("base64"). The server will decode and serve them correctly.', {
813
+ server.tool('write_workspace', 'Write a file to the shared workspace. Use this to save ALL task deliverables: code, HTML, scripts, reports, data files, images — everything goes under artifacts/. Do NOT use this to share workspace-level knowledge across agents — call promote_context instead so the candidate is reviewed and injected into every future agent\'s context. For binary files (images/PNG/JPG), encode as base64 data URL: read the file with fs.readFileSync, then format as "data:image/png;base64," + buf.toString("base64"). The server will decode and serve them correctly.', {
803
814
  path: z.string().describe('File path relative to workspace root, e.g. "artifacts/result.html" or "artifacts/cover.png"'),
804
815
  content: z.string().describe('File content. For images: base64 data URL "data:image/png;base64,<base64data>"'),
805
816
  }, async ({ path, content }) => {
@@ -962,6 +973,155 @@ server.tool('execute_approved_action',
962
973
  }
963
974
  );
964
975
 
976
+ // ── promote_context ───────────────────────────────────────────────────────────
977
+ server.tool('promote_context',
978
+ 'Submit a workspace-level knowledge candidate for human review. Use this after finishing a task when you discover a stable fact, convention, or learning that future agents in this workspace should know. The candidate appears in the workspace owner\'s "My Context → Pending Proposals" panel; once a human (or the workspace Orchestrator) confirms it, it is auto-injected into every future agent\'s system prompt under "## Workspace context". This is the only sanctioned path for sharing workspace-level knowledge — do NOT write a "knowledge index" file via write_workspace.',
979
+ {
980
+ workspace_id: z.string().optional().describe('Target workspace id. Defaults to your current workspace if omitted.'),
981
+ type: z.enum(['knowledge', 'workspace_norm', 'memory']).optional().describe('Candidate type. Default "knowledge" — use "workspace_norm" for standing rules, "memory" for durable facts.'),
982
+ summary: z.string().describe('One-line title that reviewers will see in the Pending Proposals list.'),
983
+ content: z.string().describe('Full candidate text that future agents will read. Be concrete, citable, and self-contained.'),
984
+ source_message_id: z.string().optional().describe('Optional message id that motivated this candidate, for audit trail.'),
985
+ tags: z.array(z.string()).optional().describe('Optional tags for categorization.'),
986
+ reason: z.string().optional().describe('Optional human-readable rationale stored on the lifecycle event.'),
987
+ },
988
+ async ({ workspace_id, type, summary, content, source_message_id, tags, reason }) => {
989
+ const targetWorkspaceId = (workspace_id ?? currentWorkspaceId ?? WORKSPACE_ID ?? '').trim();
990
+ if (!targetWorkspaceId) {
991
+ return { isError: true, content: [{ type: 'text', text: 'workspace_id is required (no current workspace context).' }] };
992
+ }
993
+ if (!summary?.trim()) {
994
+ return { isError: true, content: [{ type: 'text', text: 'summary is required.' }] };
995
+ }
996
+ if (!content?.trim()) {
997
+ return { isError: true, content: [{ type: 'text', text: 'content is required.' }] };
998
+ }
999
+ try {
1000
+ const data = await api('POST', '/context-proposals', {
1001
+ workspaceId: targetWorkspaceId,
1002
+ type: type ?? 'knowledge',
1003
+ summary,
1004
+ content,
1005
+ sourceMessageId: source_message_id,
1006
+ tags,
1007
+ reason,
1008
+ });
1009
+ const proposal = data?.proposal ?? {};
1010
+ return {
1011
+ content: [{
1012
+ type: 'text',
1013
+ text:
1014
+ `Knowledge candidate submitted.\n` +
1015
+ `proposal_id=${proposal.id ?? 'unknown'} workspace=${proposal.workspaceId ?? targetWorkspaceId} status=${proposal.status ?? 'candidate'}\n` +
1016
+ `It is now visible in the workspace owner's "My Context → Pending Proposals" panel; once confirmed, it will be injected into every future agent's "## Workspace context".`,
1017
+ }],
1018
+ };
1019
+ } catch (err) {
1020
+ return { isError: true, content: [{ type: 'text', text: `Error: ${err.message}` }] };
1021
+ }
1022
+ }
1023
+ );
1024
+
1025
+ // ── write_governance_decision ─────────────────────────────────────────────────
1026
+ server.tool('write_governance_decision',
1027
+ 'Orchestrator-only: write a `decision` context_item (authority=orchestrator) into the workspace Goal State. Use this when adjudicating multi-agent conflicts, codifying long-term routing rules, or recording any decision that should be visible to all workers in this workspace.',
1028
+ {
1029
+ workspace_id: z.string().describe('Target workspace id; you must be the registered orchestrator for it.'),
1030
+ content: z.string().describe('Decision text. Be concrete, auditable, and reusable; avoid hedging language.'),
1031
+ title: z.string().optional().describe('Short title (optional).'),
1032
+ summary: z.string().optional().describe('One-line summary (optional).'),
1033
+ source_risk: z.enum(['trusted', 'normal', 'untrusted', 'hostile']).optional().describe('Source risk classification (default normal).'),
1034
+ tags: z.array(z.string()).optional().describe('Optional tags.'),
1035
+ item_id: z.string().optional().describe('Override the generated context_item id (otherwise a uuid is allocated).'),
1036
+ reason: z.string().optional().describe('Human-readable rationale stored on the lifecycle event.'),
1037
+ },
1038
+ async ({ workspace_id, content, title, summary, source_risk, tags, item_id, reason }) => {
1039
+ try {
1040
+ const data = await api('POST', '/orchestrate/decision', {
1041
+ workspaceId: workspace_id,
1042
+ content,
1043
+ title,
1044
+ summary,
1045
+ sourceRisk: source_risk,
1046
+ tags,
1047
+ itemId: item_id,
1048
+ reason,
1049
+ });
1050
+ return { content: [{ type: 'text', text: `Decision written. context_item_id=${data?.item?.id} version=${data?.item?.version} status=${data?.item?.derivedStatus}` }] };
1051
+ } catch (err) {
1052
+ return { isError: true, content: [{ type: 'text', text: `Error: ${err.message}` }] };
1053
+ }
1054
+ }
1055
+ );
1056
+
1057
+ // ── write_governance_correction ───────────────────────────────────────────────
1058
+ server.tool('write_governance_correction',
1059
+ 'Orchestrator-only: write a `correction` context_item (authority=orchestrator) into the workspace Goal State. Use this when correcting worker drift, blocker timeouts, or other governance-level course corrections that must propagate to all workers.',
1060
+ {
1061
+ workspace_id: z.string().describe('Target workspace id; you must be the registered orchestrator for it.'),
1062
+ content: z.string().describe('Correction text. Be concrete and actionable; record the deviation observed and the corrective direction.'),
1063
+ title: z.string().optional().describe('Short title (optional).'),
1064
+ summary: z.string().optional().describe('One-line summary (optional).'),
1065
+ source_risk: z.enum(['trusted', 'normal', 'untrusted', 'hostile']).optional().describe('Source risk classification (default normal).'),
1066
+ tags: z.array(z.string()).optional().describe('Optional tags.'),
1067
+ item_id: z.string().optional().describe('Override the generated context_item id (otherwise a uuid is allocated).'),
1068
+ reason: z.string().optional().describe('Human-readable rationale stored on the lifecycle event.'),
1069
+ },
1070
+ async ({ workspace_id, content, title, summary, source_risk, tags, item_id, reason }) => {
1071
+ try {
1072
+ const data = await api('POST', '/orchestrate/correction', {
1073
+ workspaceId: workspace_id,
1074
+ content,
1075
+ title,
1076
+ summary,
1077
+ sourceRisk: source_risk,
1078
+ tags,
1079
+ itemId: item_id,
1080
+ reason,
1081
+ });
1082
+ return { content: [{ type: 'text', text: `Correction written. context_item_id=${data?.item?.id} version=${data?.item?.version} status=${data?.item?.derivedStatus}` }] };
1083
+ } catch (err) {
1084
+ return { isError: true, content: [{ type: 'text', text: `Error: ${err.message}` }] };
1085
+ }
1086
+ }
1087
+ );
1088
+
1089
+ // ── get_orchestrate_context ───────────────────────────────────────────────────
1090
+ server.tool('get_orchestrate_context',
1091
+ 'Orchestrator-only: fetch the active orchestrator trigger payload bound to this agent (the `processing` row from orchestrator_trigger_queue). Returns null when no trigger is currently bound. Use this at the start of every wake-up turn to recover trigger context, then call complete_orchestrate_trigger when the action is finished.',
1092
+ {
1093
+ workspace_id: z.string().optional().describe('Optional workspace filter; defaults to any workspace this agent is the orchestrator for.'),
1094
+ },
1095
+ async ({ workspace_id }) => {
1096
+ try {
1097
+ const path = workspace_id
1098
+ ? `/orchestrate/context?workspaceId=${encodeURIComponent(workspace_id)}`
1099
+ : `/orchestrate/context`;
1100
+ const data = await api('GET', path);
1101
+ if (!data?.trigger) return { content: [{ type: 'text', text: 'No active orchestrator trigger bound to this agent.' }] };
1102
+ return { content: [{ type: 'text', text: JSON.stringify(data.trigger, null, 2) }] };
1103
+ } catch (err) {
1104
+ return { isError: true, content: [{ type: 'text', text: `Error: ${err.message}` }] };
1105
+ }
1106
+ }
1107
+ );
1108
+
1109
+ // ── complete_orchestrate_trigger ──────────────────────────────────────────────
1110
+ server.tool('complete_orchestrate_trigger',
1111
+ 'Orchestrator-only: mark a processing orchestrator trigger as completed (status processing→completed) and reset its circuit breaker. Call this once you have written all governance actions for the current trigger.',
1112
+ {
1113
+ queue_id: z.number().describe('orchestrator_trigger_queue.id of the trigger to complete (returned by get_orchestrate_context as queueId).'),
1114
+ },
1115
+ async ({ queue_id }) => {
1116
+ try {
1117
+ const data = await api('POST', '/orchestrate/complete', { queueId: queue_id });
1118
+ return { content: [{ type: 'text', text: `Trigger completed. queueId=${data?.queueId} triggerId=${data?.triggerId} status=${data?.status}` }] };
1119
+ } catch (err) {
1120
+ return { isError: true, content: [{ type: 'text', text: `Error: ${err.message}` }] };
1121
+ }
1122
+ }
1123
+ );
1124
+
965
1125
  // ── start ─────────────────────────────────────────────────────────────────────
966
1126
  const transport = new StdioServerTransport();
967
1127
  await server.connect(transport);
@@ -1,5 +1,10 @@
1
1
  const t = (name) => `mcp__chat__${name}`;
2
2
 
3
+ // Marker that `injectWorkspaceContext` rewrites into the agent's runtime
4
+ // workspace context block (Goal State + active decisions + knowledge). Kept
5
+ // stable so server-built and daemon-built system prompts can both target it.
6
+ export const WORKSPACE_CONTEXT_PLACEHOLDER = '__WORKSPACE_CONTEXT_BLOCK__';
7
+
3
8
  const BASE_PROMPT = (displayName, name, description, agentId, feishuBotName) => `\
4
9
  You are "${displayName || name}", an AI agent in lightcone — a collaborative platform for human-AI collaboration.
5
10
  ${feishuBotName ? `You are also known as "${feishuBotName}" on Feishu — messages mentioning @${feishuBotName} are directed at you.\n` : ''}\
@@ -8,6 +13,8 @@ ${feishuBotName ? `You are also known as "${feishuBotName}" on Feishu — messag
8
13
 
9
14
  Your workspace and MEMORY.md persist across turns, so you can recover context when resumed. You will be started, put to sleep when idle, and woken up again when someone sends you a message. Think of yourself as a colleague who is always available, accumulates knowledge over time, and develops expertise through interactions.
10
15
 
16
+ ${WORKSPACE_CONTEXT_PLACEHOLDER}
17
+
11
18
  ## Communication — MCP tools ONLY
12
19
 
13
20
  You have MCP tools from the "chat" server. Use ONLY these for communication:
@@ -195,14 +202,14 @@ Your current working directory. Contains:
195
202
 
196
203
  ### Workspace shared workspace (shared with all agents in this workspace)
197
204
  Located one level up from your personal workspace. Contains:
198
- - \`BRIEF.md\` — **read this on every startup**. Set by humans. Defines workspace mission, conventions, and background.
199
- - \`KNOWLEDGE.md\` — shared knowledge index. Use \`${t("write_workspace")}\` to record workspace-level learnings here.
200
205
  - \`notes/\` — shared research notes and decisions.
201
206
  - \`artifacts/\` — **ALL deliverables go here without exception**: code, scripts, HTML pages, data files, reports, images — everything you produce for a task. Use \`${t("write_workspace")}({ path: "artifacts/filename.ext", content: "..." })\` for text files and \`${t("write_workspace_file")}({ file_path: "tmp/local-file.png", path: "artifacts/file.png" })\` for local binary files. Never create deliverable files anywhere else.
202
207
 
208
+ The active workspace context (Goal State, constraints, decisions, knowledge) is already injected into your system prompt above under "## Workspace context" — you do not need to read or write any standing workspace document to recover it.
209
+
203
210
  **Write rule:**
204
211
  - Personal learnings → \`${t("write_memory")}\`
205
- - Workspace-level knowledge → \`${t("write_workspace")}({ path: "KNOWLEDGE.md", ... })\`
212
+ - Workspace-level knowledge worth sharing across all future agents → \`${t("promote_context")}\` (submits a knowledge candidate; see below). Do **not** dump shared knowledge into ad-hoc files inside the workspace shared workspace.
206
213
  - **Any file you produce for a task** → \`${t("write_workspace")}({ path: "artifacts/your-file.ext", ... })\` or \`${t("write_workspace_file")}({ file_path, path: "artifacts/your-file.ext" })\`
207
214
 
208
215
  Temporary local files belong under \`tmp/\` in your personal workspace. If you need to show an image in chat, first save the durable copy to \`artifacts/\`, then optionally call \`${t("upload_image")}\` for a temporary public preview URL.
@@ -217,15 +224,18 @@ Example: writing a web page → \`${t("write_workspace")}({ path: "artifacts/job
217
224
  - \`${t("list_memory")}()\` — list your personal memory files
218
225
 
219
226
  **Workspace memory** (shared filesystem — visible to all agents in the workspace):
220
- - \`${t("read_workspace")}({ path })\` — read a workspace file (e.g. \`"BRIEF.md"\`, \`"KNOWLEDGE.md"\`)
221
- - \`${t("write_workspace")}({ path, content })\` — write a workspace file
227
+ - \`${t("read_workspace")}({ path })\` — read a workspace file (e.g. \`"artifacts/report.html"\`, \`"notes/work-log.md"\`)
228
+ - \`${t("write_workspace")}({ path, content })\` — write a workspace file (use this for task deliverables under \`artifacts/\`, never for shared "knowledge index" files)
222
229
  - \`${t("write_workspace_file")}({ file_path, path })\` — write a local file from your workspace to a workspace artifact without putting base64 in context
223
230
  - \`${t("list_workspace")}()\` — list all files in the workspace
224
231
 
232
+ **Workspace knowledge governance:**
233
+ - \`${t("promote_context")}({ workspace_id, type, summary, content })\` — submit a workspace-level knowledge candidate. The candidate appears in the workspace owner's "My Context → Pending Proposals" panel; once a human (or the workspace Orchestrator) confirms it, it becomes an active context_item and is auto-injected into every future agent's "## Workspace context" section.
234
+
225
235
  ### Startup sequence (CRITICAL)
226
236
 
227
237
  1. Call \`${t("read_memory")}({ path: "MEMORY.md" })\` to load your personal memory index.
228
- 2. Call \`${t("read_workspace")}({ path: "BRIEF.md" })\` to read the workspace brief. If empty, proceed normally.
238
+ 2. The active workspace context (Goal State, constraints, decisions, knowledge) is already injected above under "## Workspace context" read it first; do not fetch any standing workspace document to recover it.
229
239
  3. Then check messages and handle work.
230
240
 
231
241
  ### MEMORY.md — Your Personal Memory Index
@@ -254,10 +264,24 @@ Example: writing a web page → \`${t("write_workspace")}({ path: "artifacts/job
254
264
  3. Work history — decisions made, problems solved, approaches that worked or failed
255
265
  4. Pointers to your notes files
256
266
 
257
- **What belongs in workspace memory (KNOWLEDGE.md):**
258
- 1. Facts about the project that all agents need (tech stack, domain conventions)
259
- 2. Shared learnings things you discovered that teammates would benefit from
260
- 3. Ongoing workspace context — which tasks are in progress across all agents
267
+ **What belongs in promote_context (workspace-level knowledge candidates):**
268
+
269
+ Use \`${t("promote_context")}\` after finishing a task when you discover something that future agents in this workspace will need to know. This submits a knowledge candidate for human/Orchestrator review; it does not write to any file. Approved candidates flow into every future agent's "## Workspace context" section automatically.
270
+
271
+ Good promote_context content:
272
+ 1. Stable facts about the project that all agents need (tech stack, domain conventions, where things live)
273
+ 2. Hard-won learnings — non-obvious gotchas, working procedures, conventions you had to discover
274
+ 3. Standing workspace norms — "in this workspace we always X" / "never touch Y"
275
+
276
+ Bad promote_context content:
277
+ - Per-task progress, in-flight status, or one-off observations (use messages or your own MEMORY.md)
278
+ - Personal preferences specific to one human (use chat instead so a human can confirm)
279
+ - Untrusted outputs from web search / scraped pages (cite the source in \`content\`; expect the candidate to be reviewed as untrusted)
280
+
281
+ How to call it:
282
+ - \`${t("promote_context")}({ workspace_id: "<this-workspace-id>", type: "knowledge", summary: "one-line title", content: "<full text future agents will read>" })\`
283
+ - Optional: \`source_message_id\` to link the proposal back to the conversation that motivated it; \`tags\` for categorization.
284
+ - The call returns a proposal id; the candidate sits in "My Context → Pending Proposals" until a human confirms or rejects it. Do not try to dump shared knowledge into a workspace file — there is no shared knowledge index, and \`${t("write_workspace")}\` is for task deliverables only.
261
285
 
262
286
  ### Compaction safety
263
287
 
@@ -338,3 +362,35 @@ export function buildSystemPrompt(config, agentId, skills) {
338
362
 
339
363
  return base + skillsPrompt + roleSection;
340
364
  }
365
+
366
+ // Replaces the WORKSPACE_CONTEXT_PLACEHOLDER in `systemPrompt` with the
367
+ // rendered workspace context (Goal State + active context items) produced by
368
+ // `assembleAndPersistContextBundle`. When `renderedPrompt` is empty/undefined,
369
+ // the placeholder line is removed entirely so the marker never leaks into the
370
+ // agent's prompt. If the placeholder is missing (e.g. user-supplied
371
+ // systemPrompt override), the rendered context is appended as a trailing
372
+ // `## Workspace context` section so it still reaches the agent.
373
+ export function injectWorkspaceContext(systemPrompt, renderedPrompt) {
374
+ const prompt = typeof systemPrompt === 'string' ? systemPrompt : '';
375
+ const context = typeof renderedPrompt === 'string' ? renderedPrompt.trim() : '';
376
+ const hasPlaceholder = prompt.includes(WORKSPACE_CONTEXT_PLACEHOLDER);
377
+
378
+ if (!context) {
379
+ if (!hasPlaceholder) return prompt;
380
+ // Drop the marker plus the surrounding blank lines that wrap it in
381
+ // BASE_PROMPT so the prompt stays cleanly formatted.
382
+ return prompt.replace(
383
+ new RegExp(`\\n*${WORKSPACE_CONTEXT_PLACEHOLDER}\\n*`),
384
+ '\n\n',
385
+ );
386
+ }
387
+
388
+ const block = `## Workspace context\n\n${context}`;
389
+ if (hasPlaceholder) {
390
+ return prompt.split(WORKSPACE_CONTEXT_PLACEHOLDER).join(block);
391
+ }
392
+ // Fall-through: placeholder was stripped by an upstream override. Append
393
+ // the context block so the agent still receives it.
394
+ const sep = prompt.endsWith('\n') ? '\n' : '\n\n';
395
+ return `${prompt}${sep}${block}\n`;
396
+ }