agent-relay-server 0.95.1 → 0.97.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.
Files changed (157) hide show
  1. package/docs/openapi.json +39 -1
  2. package/package.json +3 -3
  3. package/public/assets/MessageBubble-CIDyX8f7.js +4 -0
  4. package/public/assets/MessageBubble-CIDyX8f7.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-DPgW_Ohe.js → PlanGraphPanel-BmWB81w_.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DPgW_Ohe.js.map → PlanGraphPanel-BmWB81w_.js.map} +1 -1
  7. package/public/assets/{activity-CRebaL8O.js → activity-QQikHZe4.js} +2 -2
  8. package/public/assets/{activity-CRebaL8O.js.map → activity-QQikHZe4.js.map} +1 -1
  9. package/public/assets/agent-profiles-WvAN5cR_.js +2 -0
  10. package/public/assets/agent-profiles-WvAN5cR_.js.map +1 -0
  11. package/public/assets/agents-BsFfumLn.js +2 -0
  12. package/public/assets/{agents-7WtV1Pf3.js.map → agents-BsFfumLn.js.map} +1 -1
  13. package/public/assets/{analytics-DpnJh9UQ.js → analytics-CUJbpoA7.js} +2 -2
  14. package/public/assets/{analytics-DpnJh9UQ.js.map → analytics-CUJbpoA7.js.map} +1 -1
  15. package/public/assets/{automation-CjQNCXlO.js → automation-BTbpiINE.js} +2 -2
  16. package/public/assets/{automation-CjQNCXlO.js.map → automation-BTbpiINE.js.map} +1 -1
  17. package/public/assets/{badge-BbfOskRI.js → badge-BLar6FjP.js} +2 -2
  18. package/public/assets/{badge-BbfOskRI.js.map → badge-BLar6FjP.js.map} +1 -1
  19. package/public/assets/branch-state-badge-CNko2_BL.js +2 -0
  20. package/public/assets/{branch-state-badge-BcObk0ca.js.map → branch-state-badge-CNko2_BL.js.map} +1 -1
  21. package/public/assets/{button-DWiw--lT.js → button-BrIB69qd.js} +2 -2
  22. package/public/assets/{button-DWiw--lT.js.map → button-BrIB69qd.js.map} +1 -1
  23. package/public/assets/card-lhnFnBoz.js +2 -0
  24. package/public/assets/{card-BeqvuYTT.js.map → card-lhnFnBoz.js.map} +1 -1
  25. package/public/assets/{channels-B2bftzgH.js → channels-Tg8l4n5J.js} +2 -2
  26. package/public/assets/{channels-B2bftzgH.js.map → channels-Tg8l4n5J.js.map} +1 -1
  27. package/public/assets/chat-BlFUmD3r.js +2 -0
  28. package/public/assets/chat-BlFUmD3r.js.map +1 -0
  29. package/public/assets/{connectors-CJOJirYT.js → connectors-B5wbjyUd.js} +2 -2
  30. package/public/assets/{connectors-CJOJirYT.js.map → connectors-B5wbjyUd.js.map} +1 -1
  31. package/public/assets/copy-button-Cd-LbSbg.js +2 -0
  32. package/public/assets/{copy-button-CIXy8ks6.js.map → copy-button-Cd-LbSbg.js.map} +1 -1
  33. package/public/assets/dist-30weaXto.js +2 -0
  34. package/public/assets/{dist-LFuRypZd.js.map → dist-30weaXto.js.map} +1 -1
  35. package/public/assets/{dist-CtuS_3Jl.js → dist-B5VvlkhI.js} +2 -2
  36. package/public/assets/{dist-CtuS_3Jl.js.map → dist-B5VvlkhI.js.map} +1 -1
  37. package/public/assets/dist-BEzpMqLy.js +2 -0
  38. package/public/assets/{dist-Q6OOS84U.js.map → dist-BEzpMqLy.js.map} +1 -1
  39. package/public/assets/{dist-CbxR2pzD.js → dist-BjOBkHHY.js} +2 -2
  40. package/public/assets/{dist-CbxR2pzD.js.map → dist-BjOBkHHY.js.map} +1 -1
  41. package/public/assets/{dist-zTw85jD8.js → dist-BnbGIUmi.js} +2 -2
  42. package/public/assets/{dist-zTw85jD8.js.map → dist-BnbGIUmi.js.map} +1 -1
  43. package/public/assets/{dist-BufB2Xy_.js → dist-Bp10H3oR.js} +2 -2
  44. package/public/assets/{dist-BufB2Xy_.js.map → dist-Bp10H3oR.js.map} +1 -1
  45. package/public/assets/{dist-Du_bcq-z.js → dist-CQF3Jiqg.js} +2 -2
  46. package/public/assets/{dist-Du_bcq-z.js.map → dist-CQF3Jiqg.js.map} +1 -1
  47. package/public/assets/dist-Cgy0FmP-.js +2 -0
  48. package/public/assets/{dist-CXkmW1Od.js.map → dist-Cgy0FmP-.js.map} +1 -1
  49. package/public/assets/{dist-KxiSP9h9.js → dist-DAl28ejX.js} +2 -2
  50. package/public/assets/{dist-KxiSP9h9.js.map → dist-DAl28ejX.js.map} +1 -1
  51. package/public/assets/{dist-eG_93I3z.js → dist-DDUGmmoi.js} +2 -2
  52. package/public/assets/{dist-eG_93I3z.js.map → dist-DDUGmmoi.js.map} +1 -1
  53. package/public/assets/{dist-BQXmO19g.js → dist-DZ97OosY.js} +2 -2
  54. package/public/assets/{dist-BQXmO19g.js.map → dist-DZ97OosY.js.map} +1 -1
  55. package/public/assets/{es2015-ibwCE_JR.js → es2015-B8Z9p8oQ.js} +2 -2
  56. package/public/assets/{es2015-ibwCE_JR.js.map → es2015-B8Z9p8oQ.js.map} +1 -1
  57. package/public/assets/{formatted-body-impl-CGACmUqI.js → formatted-body-impl-Dbjjdcu9.js} +2 -2
  58. package/public/assets/{formatted-body-impl-CGACmUqI.js.map → formatted-body-impl-Dbjjdcu9.js.map} +1 -1
  59. package/public/assets/index-CPWbr4sq.css +2 -0
  60. package/public/assets/index-CPXz74W5.js +23 -0
  61. package/public/assets/index-CPXz74W5.js.map +1 -0
  62. package/public/assets/{input-DUdtS3nG.js → input-B_fUHtLD.js} +2 -2
  63. package/public/assets/{input-DUdtS3nG.js.map → input-B_fUHtLD.js.map} +1 -1
  64. package/public/assets/{insights-DqXjTYai.js → insights-D1Oq25BL.js} +2 -2
  65. package/public/assets/{insights-DqXjTYai.js.map → insights-D1Oq25BL.js.map} +1 -1
  66. package/public/assets/{integrations-DEgLatDv.js → integrations-sYKtupOq.js} +2 -2
  67. package/public/assets/{integrations-DEgLatDv.js.map → integrations-sYKtupOq.js.map} +1 -1
  68. package/public/assets/{lib-Gp69YB9e.js → lib-ng6WZYjE.js} +2 -2
  69. package/public/assets/{lib-Gp69YB9e.js.map → lib-ng6WZYjE.js.map} +1 -1
  70. package/public/assets/{lucide-react-uFIMC7W7.js → lucide-react-BYZuAhQs.js} +2 -2
  71. package/public/assets/{lucide-react-uFIMC7W7.js.map → lucide-react-BYZuAhQs.js.map} +1 -1
  72. package/public/assets/maintenance-CgXJwFTo.js +2 -0
  73. package/public/assets/{maintenance-CcFJlNxt.js.map → maintenance-CgXJwFTo.js.map} +1 -1
  74. package/public/assets/{managed-agents-CHR4Hx1O.js → managed-agents-CbuFHwWE.js} +4 -4
  75. package/public/assets/{managed-agents-CHR4Hx1O.js.map → managed-agents-CbuFHwWE.js.map} +1 -1
  76. package/public/assets/markdown-preview-impl-B79bSsKK.js +2 -0
  77. package/public/assets/{markdown-preview-impl-BHaUcnfn.js.map → markdown-preview-impl-B79bSsKK.js.map} +1 -1
  78. package/public/assets/memory-Box_rMj_.js +2 -0
  79. package/public/assets/{memory-Uk0VfqnC.js.map → memory-Box_rMj_.js.map} +1 -1
  80. package/public/assets/{messages-D_juCBfr.js → messages-BXIHBaN2.js} +2 -2
  81. package/public/assets/{messages-D_juCBfr.js.map → messages-BXIHBaN2.js.map} +1 -1
  82. package/public/assets/{orchestrators-8IrmgeHV.js → orchestrators-MNj_mp3U.js} +2 -2
  83. package/public/assets/{orchestrators-8IrmgeHV.js.map → orchestrators-MNj_mp3U.js.map} +1 -1
  84. package/public/assets/{overview-D_DM0QHJ.js → overview-KfwJgCUa.js} +2 -2
  85. package/public/assets/{overview-D_DM0QHJ.js.map → overview-KfwJgCUa.js.map} +1 -1
  86. package/public/assets/{pairs-jciDuPLn.js → pairs-DfrGWlgL.js} +2 -2
  87. package/public/assets/{pairs-jciDuPLn.js.map → pairs-DfrGWlgL.js.map} +1 -1
  88. package/public/assets/{projects-C-QHyuMD.js → projects-BPI8z55X.js} +2 -2
  89. package/public/assets/{projects-C-QHyuMD.js.map → projects-BPI8z55X.js.map} +1 -1
  90. package/public/assets/{react-dom-DF2cuNaf.js → react-dom-Dbhs84ug.js} +2 -2
  91. package/public/assets/{react-dom-DF2cuNaf.js.map → react-dom-Dbhs84ug.js.map} +1 -1
  92. package/public/assets/{registry-D1EIvc8U.js → registry-CiBC97sL.js} +2 -2
  93. package/public/assets/{registry-D1EIvc8U.js.map → registry-CiBC97sL.js.map} +1 -1
  94. package/public/assets/{security-C0_Df9yI.js → security-BbU_vIdb.js} +2 -2
  95. package/public/assets/{security-C0_Df9yI.js.map → security-BbU_vIdb.js.map} +1 -1
  96. package/public/assets/{select-CXFXPZ7Z.js → select-CeQwmVTm.js} +2 -2
  97. package/public/assets/{select-CXFXPZ7Z.js.map → select-CeQwmVTm.js.map} +1 -1
  98. package/public/assets/settings-DBsL3jb7.js +6 -0
  99. package/public/assets/{settings-DGPpdFoq.js.map → settings-DBsL3jb7.js.map} +1 -1
  100. package/public/assets/{store-d-IbqTaD.js → store-pKbf_WYd.js} +5 -5
  101. package/public/assets/{store-d-IbqTaD.js.map → store-pKbf_WYd.js.map} +1 -1
  102. package/public/assets/{tasks-B_epVa5b.js → tasks-za0RxIQw.js} +2 -2
  103. package/public/assets/{tasks-B_epVa5b.js.map → tasks-za0RxIQw.js.map} +1 -1
  104. package/public/assets/{teams-D2hATpCD.js → teams-ByAqwLG9.js} +2 -2
  105. package/public/assets/{teams-D2hATpCD.js.map → teams-ByAqwLG9.js.map} +1 -1
  106. package/public/assets/{terminal-viewer-impl-CefeetWa.js → terminal-viewer-impl-CkoWD5D_.js} +3 -3
  107. package/public/assets/{terminal-viewer-impl-CefeetWa.js.map → terminal-viewer-impl-CkoWD5D_.js.map} +1 -1
  108. package/public/assets/types-Tv67voFu.js.map +1 -1
  109. package/public/assets/{use-keyboard-viewport-CeLM-PeW.js → use-keyboard-viewport-D8_NYDkh.js} +2 -2
  110. package/public/assets/{use-keyboard-viewport-CeLM-PeW.js.map → use-keyboard-viewport-D8_NYDkh.js.map} +1 -1
  111. package/public/assets/user-avatar-Q_N6iHEn.js +2 -0
  112. package/public/assets/{user-avatar-BOGZ21O8.js.map → user-avatar-Q_N6iHEn.js.map} +1 -1
  113. package/public/assets/{work-queue-C9wBOivX.js → work-queue-DfRo1B2P.js} +2 -2
  114. package/public/assets/{work-queue-C9wBOivX.js.map → work-queue-DfRo1B2P.js.map} +1 -1
  115. package/public/assets/workspaces-BolccWZy.js +3 -0
  116. package/public/assets/{workspaces-DKFmnOF8.js.map → workspaces-BolccWZy.js.map} +1 -1
  117. package/public/index.html +21 -21
  118. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  119. package/src/agent-busy-health.ts +22 -20
  120. package/src/config-store.ts +72 -6
  121. package/src/db/migrations.ts +9 -0
  122. package/src/db/project-entries.ts +63 -9
  123. package/src/db/projects.ts +76 -4
  124. package/src/db/schema.ts +8 -0
  125. package/src/mcp/index.ts +4 -3
  126. package/src/mcp/tools-spawn.ts +13 -2
  127. package/src/mcp-memory-tools.ts +239 -0
  128. package/src/mcp-team-template-tools.ts +8 -1
  129. package/src/project-instructions.ts +48 -10
  130. package/src/routes/agent-profiles.ts +36 -8
  131. package/src/routes/agents-spawn.ts +4 -2
  132. package/src/routes/index.ts +2 -1
  133. package/src/runtime-tokens.ts +3 -0
  134. package/src/security.ts +1 -1
  135. package/src/services/spawn-agent.ts +57 -2
  136. package/public/assets/MessageBubble-CkPnAwxJ.js +0 -3
  137. package/public/assets/MessageBubble-CkPnAwxJ.js.map +0 -1
  138. package/public/assets/agent-profiles-BFpEQHKo.js +0 -2
  139. package/public/assets/agent-profiles-BFpEQHKo.js.map +0 -1
  140. package/public/assets/agents-7WtV1Pf3.js +0 -2
  141. package/public/assets/branch-state-badge-BcObk0ca.js +0 -2
  142. package/public/assets/card-BeqvuYTT.js +0 -2
  143. package/public/assets/chat-Dcwua_5V.js +0 -2
  144. package/public/assets/chat-Dcwua_5V.js.map +0 -1
  145. package/public/assets/copy-button-CIXy8ks6.js +0 -2
  146. package/public/assets/dist-CXkmW1Od.js +0 -2
  147. package/public/assets/dist-LFuRypZd.js +0 -2
  148. package/public/assets/dist-Q6OOS84U.js +0 -2
  149. package/public/assets/index-CKPsJqiW.css +0 -2
  150. package/public/assets/index-CwhhpGgX.js +0 -23
  151. package/public/assets/index-CwhhpGgX.js.map +0 -1
  152. package/public/assets/maintenance-CcFJlNxt.js +0 -2
  153. package/public/assets/markdown-preview-impl-BHaUcnfn.js +0 -2
  154. package/public/assets/memory-Uk0VfqnC.js +0 -2
  155. package/public/assets/settings-DGPpdFoq.js +0 -6
  156. package/public/assets/user-avatar-BOGZ21O8.js +0 -2
  157. package/public/assets/workspaces-DKFmnOF8.js +0 -3
@@ -9,7 +9,7 @@ import { PROJECT_ENTRY_KINDS, PROJECT_ENTRY_SOURCES, type BlackboardEntryKind, t
9
9
  // a parallel copy (not folded with blackboard) because the two scopes have different
10
10
  // lifetimes and column sets; the duplication is intentional and ratchet-noted.
11
11
 
12
- const PROJECT_ENTRY_STATUSES = ["active", "superseded", "retracted"] as const;
12
+ const PROJECT_ENTRY_STATUSES = ["proposed", "active", "superseded", "retracted"] as const;
13
13
 
14
14
  interface ProjectEntryRow {
15
15
  id: string;
@@ -37,6 +37,30 @@ interface WriteProjectEntryInput {
37
37
  supersedes?: string;
38
38
  source?: ProjectEntrySource;
39
39
  originRef?: string;
40
+ /** Initial status. Default `active` (#403 behavior). The scoped-memory capture path (#559)
41
+ * passes `proposed` so a write lands in the curation tier and is NOT injected until promoted. */
42
+ status?: ProjectEntryStatus;
43
+ /** #559 — when true, auto-supersede the latest active|proposed entry of the same kind whose
44
+ * (normalized) title matches, in the same project. Implements "a new principle supersedes the
45
+ * prior one" (curated memory doesn't just stack). Ignored when `supersedes` is given explicitly. */
46
+ supersedePriorByTitle?: boolean;
47
+ }
48
+
49
+ function normalizeTitle(title: string): string {
50
+ return title.trim().toLowerCase().replace(/\s+/g, " ");
51
+ }
52
+
53
+ /** Latest active|proposed entry of `kind` in `projectId` whose title matches (normalized).
54
+ * The supersede anchor for the auto-supersede capture path. */
55
+ function latestEntryByTitle(projectId: string, kind: ProjectEntryKind, title: string): ProjectEntry | null {
56
+ const rows = getDb().query(`
57
+ SELECT * FROM project_entries
58
+ WHERE project_id = ? AND kind = ? AND status IN ('active', 'proposed')
59
+ ORDER BY updated_at DESC, created_at DESC, id DESC
60
+ `).all(projectId, kind) as ProjectEntryRow[];
61
+ const target = normalizeTitle(title);
62
+ const match = rows.find((row) => normalizeTitle(row.title) === target);
63
+ return match ? rowToEntry(match) : null;
40
64
  }
41
65
 
42
66
  function projectEntryId(): string {
@@ -88,27 +112,36 @@ export function writeProjectEntry(input: WriteProjectEntryInput): ProjectEntry {
88
112
  assertKind(input.kind);
89
113
  const source = input.source ?? "manual";
90
114
  assertSource(source);
115
+ const status = input.status ?? "active";
116
+ assertStatus(status);
117
+ // Resolve the supersede target: an explicit id wins; otherwise the auto-by-title anchor
118
+ // (#559) so a re-write of the same principle supersedes rather than stacks.
119
+ let supersedes = input.supersedes;
120
+ if (!supersedes && input.supersedePriorByTitle) {
121
+ supersedes = latestEntryByTitle(input.projectId, input.kind, input.title)?.id;
122
+ }
91
123
  const id = projectEntryId();
92
124
  const now = Date.now();
93
125
  getDb().transaction(() => {
94
- if (input.supersedes) {
95
- const previous = getProjectEntry(input.supersedes);
96
- if (!previous) throw new ValidationError(`project entry ${input.supersedes} not found`);
126
+ if (supersedes) {
127
+ const previous = getProjectEntry(supersedes);
128
+ if (!previous) throw new ValidationError(`project entry ${supersedes} not found`);
97
129
  if (previous.projectId !== input.projectId) throw new ValidationError("superseded entry belongs to another project");
98
130
  if (previous.status === "retracted") throw new ValidationError("cannot supersede a retracted entry");
99
131
  getDb().query("UPDATE project_entries SET status = 'superseded', updated_at = ? WHERE id = ?")
100
- .run(now, input.supersedes);
132
+ .run(now, supersedes);
101
133
  }
102
134
  getDb().query(`
103
135
  INSERT INTO project_entries (id, project_id, kind, title, body, status, supersedes, tags, author_id, source, origin_ref, created_at, updated_at)
104
- VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
105
137
  `).run(
106
138
  id,
107
139
  input.projectId,
108
140
  input.kind,
109
141
  input.title,
110
142
  input.body ?? null,
111
- input.supersedes ?? null,
143
+ status,
144
+ supersedes ?? null,
112
145
  input.tags?.length ? JSON.stringify([...new Set(input.tags)]) : null,
113
146
  input.authorId,
114
147
  source,
@@ -120,6 +153,20 @@ export function writeProjectEntry(input: WriteProjectEntryInput): ProjectEntry {
120
153
  return getProjectEntry(id)!;
121
154
  }
122
155
 
156
+ /** Promote a `proposed` entry to `active` (#559) — the curation gate that makes a captured
157
+ * memory injectable. Idempotent on an already-active entry; rejects superseded/retracted. */
158
+ export function promoteProjectEntry(id: string): ProjectEntry | null {
159
+ const existing = getProjectEntry(id);
160
+ if (!existing) return null;
161
+ if (existing.status === "active") return existing;
162
+ if (existing.status !== "proposed") {
163
+ throw new ValidationError(`cannot promote a ${existing.status} entry`);
164
+ }
165
+ getDb().query("UPDATE project_entries SET status = 'active', updated_at = ? WHERE id = ?")
166
+ .run(Date.now(), id);
167
+ return getProjectEntry(id);
168
+ }
169
+
123
170
  export function getProjectEntry(id: string): ProjectEntry | null {
124
171
  const row = getDb().query("SELECT * FROM project_entries WHERE id = ?").get(id) as ProjectEntryRow | undefined;
125
172
  return row ? rowToEntry(row) : null;
@@ -139,16 +186,23 @@ export function getProjectEntryChain(id: string): ProjectEntry[] {
139
186
 
140
187
  export function listProjectEntries(
141
188
  projectId: string,
142
- opts: { kind?: ProjectEntryKind; includeInactive?: boolean } = {},
189
+ opts: { kind?: ProjectEntryKind; includeInactive?: boolean; status?: ProjectEntryStatus } = {},
143
190
  ): ProjectEntry[] {
144
191
  if (opts.kind) assertKind(opts.kind);
192
+ if (opts.status) assertStatus(opts.status);
145
193
  const conditions = ["project_id = ?"];
146
194
  const params: string[] = [projectId];
147
195
  if (opts.kind) {
148
196
  conditions.push("kind = ?");
149
197
  params.push(opts.kind);
150
198
  }
151
- if (!opts.includeInactive) conditions.push("status = 'active'");
199
+ // An explicit `status` filter wins; otherwise the default keeps #403 behavior (active only).
200
+ if (opts.status) {
201
+ conditions.push("status = ?");
202
+ params.push(opts.status);
203
+ } else if (!opts.includeInactive) {
204
+ conditions.push("status = 'active'");
205
+ }
152
206
  const rows = getDb().query(`
153
207
  SELECT * FROM project_entries
154
208
  WHERE ${conditions.join(" AND ")}
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { getDb, ValidationError } from "./connection.ts";
3
3
  import { listProjectEntries } from "./project-entries.ts";
4
4
  import { isPathWithinBase } from "../utils";
5
- import { PROJECT_EDGE_KINDS, type Project, type ProjectEdge, type ProjectEdgeKind, type ProjectEntryKind, type ProjectRoot, type ResolvedProjectEntry } from "../types";
5
+ import { PROJECT_EDGE_KINDS, PROJECT_SCOPES, type Project, type ProjectEdge, type ProjectEdgeKind, type ProjectEntryKind, type ProjectRoot, type ProjectScope, type ResolvedProjectEntry } from "../types";
6
6
 
7
7
  // Projects entity CRUD + roots + cwd→project resolution (#405). Templated on
8
8
  // src/db/teams.ts; the durable analog of a team. project_entries CRUD lives in
@@ -15,6 +15,8 @@ interface ProjectRow {
15
15
  description: string | null;
16
16
  tenant_id: string | null;
17
17
  issue_repo: string | null;
18
+ scope: ProjectScope;
19
+ anchor_key: string | null;
18
20
  status: Project["status"];
19
21
  created_at: number;
20
22
  updated_at: number;
@@ -38,6 +40,8 @@ function rowToProject(row: ProjectRow): Project {
38
40
  ...(row.description ? { description: row.description } : {}),
39
41
  ...(row.tenant_id ? { tenantId: row.tenant_id } : {}),
40
42
  ...(row.issue_repo ? { issueRepo: row.issue_repo } : {}),
43
+ scope: row.scope ?? "project",
44
+ ...(row.anchor_key ? { anchorKey: row.anchor_key } : {}),
41
45
  status: row.status,
42
46
  createdAt: row.created_at,
43
47
  updatedAt: row.updated_at,
@@ -63,11 +67,22 @@ export function resolveProject(ref: string): Project | null {
63
67
  return getProject(ref) ?? getProjectBySlug(ref);
64
68
  }
65
69
 
66
- export function listProjects(opts: { includeArchived?: boolean } = {}): Project[] {
67
- const where = opts.includeArchived ? "" : "WHERE status = 'active' ";
70
+ /** List projects. Defaults to the `project` scope only so #403 callers (the project list)
71
+ * never see profile/user anchors; pass an explicit `scope` to list a scoped store, or
72
+ * `scope: "any"` for every scope. */
73
+ export function listProjects(opts: { includeArchived?: boolean; scope?: ProjectScope | "any" } = {}): Project[] {
74
+ const conditions: string[] = [];
75
+ const params: string[] = [];
76
+ if (!opts.includeArchived) conditions.push("status = 'active'");
77
+ const scope = opts.scope ?? "project";
78
+ if (scope !== "any") {
79
+ conditions.push("scope = ?");
80
+ params.push(scope);
81
+ }
82
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")} ` : "";
68
83
  const rows = getDb()
69
84
  .query(`SELECT * FROM projects ${where}ORDER BY created_at ASC, id ASC`)
70
- .all() as ProjectRow[];
85
+ .all(...params) as ProjectRow[];
71
86
  return rows.map(rowToProject);
72
87
  }
73
88
 
@@ -131,6 +146,63 @@ export function ensureProject(input: EnsureProjectInput): Project {
131
146
  return getProject(id)!;
132
147
  }
133
148
 
149
+ // ── Scoped anchors (#559) ───────────────────────────────────────────────────
150
+ // A profile/user knowledge store is a `projects` row with scope!='project' and an
151
+ // anchor_key (the profile name / operator id). The slug carries the scope prefix so it
152
+ // stays globally unique alongside project slugs (`profile:coordinator`, `user:edin`).
153
+ // These are the get-or-create entry points the spawn path and the scoped-memory tools
154
+ // use; entries/edges/cascade then operate on the returned project id, scope-agnostic.
155
+
156
+ const SCOPED_SLUG_PREFIX: Record<Exclude<ProjectScope, "project">, string> = {
157
+ profile: "profile:",
158
+ user: "user:",
159
+ };
160
+
161
+ /** Find a scoped anchor by (scope, anchor_key) — the profile name or operator id. Null
162
+ * when none exists yet (the caller decides whether to create it). */
163
+ export function resolveScopedAnchor(scope: Exclude<ProjectScope, "project">, anchorKey: string): Project | null {
164
+ const key = anchorKey.trim();
165
+ if (!key) return null;
166
+ const row = getDb()
167
+ .query("SELECT * FROM projects WHERE scope = ? AND anchor_key = ? LIMIT 1")
168
+ .get(scope, key) as ProjectRow | undefined;
169
+ return row ? rowToProject(row) : null;
170
+ }
171
+
172
+ /** Get-or-create a scoped anchor (profile/user store). Idempotent on (scope, anchor_key);
173
+ * the create-on-first-write analog of ensureProject for the non-repo scopes. */
174
+ export function ensureScopedAnchor(
175
+ scope: Exclude<ProjectScope, "project">,
176
+ anchorKey: string,
177
+ opts: { title?: string } = {},
178
+ ): Project {
179
+ if (!PROJECT_SCOPES.includes(scope)) throw new ValidationError(`scope must be one of: ${PROJECT_SCOPES.join(", ")}`);
180
+ const key = anchorKey.trim();
181
+ if (!key) throw new ValidationError("anchorKey required");
182
+ const existing = resolveScopedAnchor(scope, key);
183
+ if (existing) return existing;
184
+ const id = projectId();
185
+ const now = Date.now();
186
+ const slug = `${SCOPED_SLUG_PREFIX[scope]}${key}`;
187
+ getDb().query(`
188
+ INSERT INTO projects (id, slug, title, description, tenant_id, issue_repo, scope, anchor_key, status, created_at, updated_at)
189
+ VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?, 'active', ?, ?)
190
+ ON CONFLICT(slug) DO NOTHING
191
+ `).run(id, slug, opts.title ?? null, scope, key, now, now);
192
+ // Re-resolve to absorb a concurrent insert that won the slug race.
193
+ return resolveScopedAnchor(scope, key) ?? getProjectBySlug(slug)!;
194
+ }
195
+
196
+ /** The profile-scoped knowledge store for a profile NAME (role memory). */
197
+ export function ensureProfileAnchor(profileName: string): Project {
198
+ return ensureScopedAnchor("profile", profileName, { title: `Profile: ${profileName.trim()}` });
199
+ }
200
+
201
+ /** The user/operator-scoped knowledge store for an operator identity (cross-project memory). */
202
+ export function ensureUserAnchor(operatorId: string): Project {
203
+ return ensureScopedAnchor("user", operatorId, { title: `Operator: ${operatorId.trim()}` });
204
+ }
205
+
134
206
  /** Set (or clear, with null) a project's `issueRepo` (#533). With `onlyIfNull`, a value is
135
207
  * written only when none is set yet — the mechanism for the git-remote AUTO-DERIVE to fill
136
208
  * a fresh project without ever clobbering an explicit override or a prior derivation. */
package/src/db/schema.ts CHANGED
@@ -239,6 +239,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
239
239
  -- Projects (#403/#405) — the durable knowledge scope. projects/project_roots/project_entries
240
240
  -- re-scope the teams/blackboard_entries pair above (team_id → project_id); project_entries
241
241
  -- adds source/origin_ref so a promoted or ingested entry tracks where it came from.
242
+ -- #559 — scope generalizes the anchor from repo-root to {project|profile|user}.
243
+ -- A profile/user store is a projects row with scope!='project' and anchor_key = the
244
+ -- profile name / operator id. Existing rows default scope='project' (zero regression).
242
245
  CREATE TABLE IF NOT EXISTS projects (
243
246
  id TEXT PRIMARY KEY,
244
247
  slug TEXT NOT NULL UNIQUE,
@@ -246,10 +249,15 @@ export function initDb(path: string = "agent-relay.db"): Database {
246
249
  description TEXT,
247
250
  tenant_id TEXT,
248
251
  issue_repo TEXT,
252
+ scope TEXT NOT NULL DEFAULT 'project',
253
+ anchor_key TEXT,
249
254
  status TEXT NOT NULL DEFAULT 'active',
250
255
  created_at INTEGER NOT NULL,
251
256
  updated_at INTEGER NOT NULL
252
257
  );
258
+ -- idx_projects_scope_anchor is created in applyMigrations() AFTER the scope/anchor_key ALTER —
259
+ -- never inline here (#483/#559: an inline index over a not-yet-migrated column crashes initDb
260
+ -- on an existing DB whose projects table predates the scope columns).
253
261
  CREATE TABLE IF NOT EXISTS project_roots (
254
262
  project_id TEXT NOT NULL,
255
263
  root_path TEXT NOT NULL,
package/src/mcp/index.ts CHANGED
@@ -7,7 +7,7 @@ import { createActivityEvent, ValidationError } from "../db";
7
7
  import { PLAN_TOOLS, relayPlanTool } from "../mcp-plan-tools";
8
8
  import { TEAM_TOOLS, relayTeamTool } from "../mcp-team-tools";
9
9
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "../mcp-blackboard-tools";
10
- import { PROJECT_TOOLS, relayProjectTool } from "../mcp-project-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "../mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "../mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "../mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "../mcp-issue-lifecycle-tools";
10
+ import { PROJECT_TOOLS, relayProjectTool } from "../mcp-project-tools"; import { MEMORY_TOOLS, relayMemoryTool } from "../mcp-memory-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "../mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "../mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "../mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "../mcp-issue-lifecycle-tools";
11
11
  import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "../mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "../mcp-merge-tools";
12
12
  import { NTFY_TOOLS, relayNotifyNtfy } from "../mcp-ntfy-tools";
13
13
  import { AGENT_ACTION_TOOL, relayAgentAction } from "../mcp-agent-action"; import { providerCatalogToolInputSchema } from "../mcp-provider-catalog";
@@ -35,6 +35,7 @@ const TOOLS: ToolDefinition[] = [
35
35
  ...PLAN_TOOLS, ...ISSUE_REF_TOOLS, ...ISSUE_ASSOCIATION_TOOLS, ...ISSUE_ACTIVITY_TOOLS, ...ISSUE_LIFECYCLE_TOOLS,
36
36
  ...BLACKBOARD_TOOLS,
37
37
  ...PROJECT_TOOLS,
38
+ ...MEMORY_TOOLS,
38
39
  ...NOTIFICATION_SUBSCRIPTION_TOOLS,
39
40
  ...NTFY_TOOLS,
40
41
  ];
@@ -44,7 +45,7 @@ const TOOLS: ToolDefinition[] = [
44
45
  // top of scope-gating — scope enforcement at tools/call stays authoritative.
45
46
  export const RELAY_TOOL_GROUPS: Record<string, readonly string[]> = {
46
47
  core: ["relay_whoami", "relay_send_message", "relay_reply", "relay_get_message", "relay_get_thread", "relay_find_agents"],
47
- memory: ["relay_recall", "relay_compact_and_resume"],
48
+ memory: ["relay_recall", "relay_compact_and_resume", "relay_memory_write", "relay_memory_promote", "relay_memory_retract", "relay_memory_read", "relay_memory_search"],
48
49
  artifacts: ["relay_upload_artifact", "relay_attach_artifact"],
49
50
  workspace: ["relay_workspace_status", "relay_workspace_ready", "relay_workspace_deps", "relay_workspace_list", "relay_workspace_claim", "relay_workspace_release", "relay_workspace_land", "relay_merge_pause", "relay_merge_pauses", "relay_merge_renew", "relay_merge_resume"],
50
51
  "spawn-lifecycle": ["relay_spawn_agent", "relay_spawn_targets", "relay_my_agents", "relay_shutdown_agent", "relay_agent_action", "relay_agent_status", "relay_agent_crash_report", "relay_schedule_timer", "relay_list_timers", "relay_cancel_timer"],
@@ -166,7 +167,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
166
167
  else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
167
168
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
168
169
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
169
- else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
170
+ else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_memory_")) result = relayMemoryTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
170
171
  else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions" || name === "relay_why_notified" || name === "relay_notification_preview") result = relayNotificationSubscriptionTool(auth, name, args);
171
172
  else if (name === "relay_notify_ntfy") result = await relayNotifyNtfy(args);
172
173
  else throw new ValidationError(`unknown tool: ${name}`);
@@ -1,4 +1,4 @@
1
- import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
1
+ import { spawnAgent, summarizeSpawnCommandForCaller, type SpawnAgentInput } from "../services/spawn-agent";
2
2
  import { authContextFromMcp } from "../services/auth-context";
3
3
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../services/shutdown-agent";
4
4
  import { buildSpawnTargets } from "../spawn-targets";
@@ -162,7 +162,18 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
162
162
  resumeWorkspace: parseResumeWorkspace(args.resumeWorkspace),
163
163
  };
164
164
  const result = await spawnAgent(input, authContextFromMcp(auth));
165
- return { ...result };
165
+ // #734 — return the lean shape callers need (spawnRequestId + agentId for idempotency/shutdown)
166
+ // plus a sanitized command summary. The full resolved provisioning already went to the
167
+ // orchestrator on the dispatched command; echoing it here blows the MCP result token cap.
168
+ return {
169
+ ok: result.ok,
170
+ spawnRequestId: result.spawnRequestId,
171
+ orchestratorId: result.orchestratorId,
172
+ provider: result.provider,
173
+ agentId: result.agentId,
174
+ registered: result.registered,
175
+ command: summarizeSpawnCommandForCaller(result.command),
176
+ };
166
177
  }
167
178
 
168
179
  export function relaySpawnTargets(auth: McpAuthContext): Record<string, unknown> {
@@ -0,0 +1,239 @@
1
+ import {
2
+ ensureProfileAnchor,
3
+ ensureUserAnchor,
4
+ getProjectEntry,
5
+ listProjectEntries,
6
+ promoteProjectEntry,
7
+ resolveAgentOwnership,
8
+ resolveProjectEntries,
9
+ resolveScopedAnchor,
10
+ retractProjectEntry,
11
+ searchProjectEntries,
12
+ ValidationError,
13
+ writeProjectEntry,
14
+ } from "./db";
15
+ import { DEFAULT_USER_ID } from "agent-relay-sdk";
16
+ import { McpNotFoundError } from "./mcp-errors";
17
+ import { authContextFromMcp, type AuthContext } from "./services/auth-context";
18
+ import { cleanString, cleanStringArray, optionalEnum } from "./validation";
19
+ import { PROJECT_ENTRY_KINDS, type Project, type ProjectEntryKind } from "./types";
20
+
21
+ // Scoped-memory capture tools (#559) — the relay-owned, vanilla-safe replacement for host
22
+ // claude-mneme's /remember. They write/read knowledge in the {profile|user} scopes that
23
+ // src/db/projects.ts now anchors. The store underneath IS project_entries (same CRUD, same
24
+ // supersede chain, same spawn-time injection), so these tools are a thin self-scoped front:
25
+ //
26
+ // • SELF-SCOPED WRITES — a write/promote/retract targets the caller's OWN profile store
27
+ // (resolved from the token's `constraints.profile`, never a name the caller passes) or the
28
+ // caller's OWN operator store (resolved from agent ownership). One role can't poison another.
29
+ // • CURATED — a capture lands `status:'proposed'` (NOT injected); `relay_memory_promote` flips
30
+ // it to `active` so it renders into the next spawn's preamble. `promote:true` on write is the
31
+ // confident shortcut. A re-write of the same title supersedes the prior (no stacking).
32
+ // • READS are not a poison risk, so `relay_memory_read`/`search` accept an optional `profile`
33
+ // name to inspect another role's store; writes never do.
34
+
35
+ const MEMORY_SCOPES = ["profile", "user"] as const;
36
+ type MemoryScope = (typeof MEMORY_SCOPES)[number];
37
+
38
+ const MEMORY_ENTRY_STATUSES = ["proposed", "active", "superseded", "retracted"] as const;
39
+
40
+ export const MEMORY_TOOLS = [
41
+ {
42
+ name: "relay_memory_write",
43
+ description:
44
+ "Capture a scoped memory (the relay-owned /remember). Writes to the caller's OWN store: `scope:'profile'` = its role memory (resolved from the token's profile), `scope:'user'` = operator memory. Lands as `proposed` (NOT injected) until promoted — pass `promote:true` to activate immediately. A write whose title matches an existing entry of the same kind supersedes it (curated, not stacked). Default kind `memory`.",
45
+ requiredScopes: ["project:write"],
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ scope: { type: "string", enum: MEMORY_SCOPES, description: "Which of the caller's own stores: profile (role) or user (operator). Default profile." },
50
+ kind: { type: "string", enum: PROJECT_ENTRY_KINDS, description: "Entry kind; default `memory`. rule/fact/memory are injected at spawn." },
51
+ title: { type: "string" },
52
+ body: { type: "string" },
53
+ tags: { type: "array", items: { type: "string" } },
54
+ promote: { type: "boolean", description: "Write straight to `active` (injectable) instead of the default `proposed` tier." },
55
+ },
56
+ required: ["title"],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ {
61
+ name: "relay_memory_promote",
62
+ description: "Promote a `proposed` memory in the caller's own store to `active` so it is injected at the next spawn. Self-scoped: the entry must belong to the caller's profile or user store.",
63
+ requiredScopes: ["project:write"],
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: { id: { type: "string", description: "The pe_… entry id to promote." } },
67
+ required: ["id"],
68
+ additionalProperties: false,
69
+ },
70
+ },
71
+ {
72
+ name: "relay_memory_retract",
73
+ description: "Retract a memory in the caller's own store without deleting its ledger row. Self-scoped.",
74
+ requiredScopes: ["project:write"],
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: { id: { type: "string" } },
78
+ required: ["id"],
79
+ additionalProperties: false,
80
+ },
81
+ },
82
+ {
83
+ name: "relay_memory_read",
84
+ description: "Read scoped memory entries. Defaults to the caller's own `profile` store; pass `scope:'user'` for operator memory, or an explicit `profile` name to inspect another role's store (read-only). Includes entries inherited via the `contains` graph (each tagged `inherited:true`) unless `includeInherited:false`. `status` filters the tier (e.g. `proposed` to review pending captures); default active only.",
85
+ requiredScopes: ["project:read"],
86
+ inputSchema: {
87
+ type: "object",
88
+ properties: {
89
+ scope: { type: "string", enum: MEMORY_SCOPES },
90
+ profile: { type: "string", description: "Inspect a specific profile's store by name (read-only). Overrides `scope`." },
91
+ kind: { type: "string", enum: PROJECT_ENTRY_KINDS },
92
+ status: { type: "string", enum: MEMORY_ENTRY_STATUSES, description: "Filter by tier; default active only." },
93
+ includeInherited: { type: "boolean" },
94
+ },
95
+ additionalProperties: false,
96
+ },
97
+ },
98
+ {
99
+ name: "relay_memory_search",
100
+ description: "Search scoped memory by title, body, or tags. Defaults to the caller's own `profile` store; `scope:'user'` or an explicit `profile` name select another store (read-only).",
101
+ requiredScopes: ["project:read"],
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {
105
+ scope: { type: "string", enum: MEMORY_SCOPES },
106
+ profile: { type: "string" },
107
+ query: { type: "string" },
108
+ kind: { type: "string", enum: PROJECT_ENTRY_KINDS },
109
+ },
110
+ required: ["query"],
111
+ additionalProperties: false,
112
+ },
113
+ },
114
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
115
+
116
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
117
+
118
+ function cleanScope(value: unknown): MemoryScope {
119
+ return (optionalEnum(value, "scope", MEMORY_SCOPES) as MemoryScope | undefined) ?? "profile";
120
+ }
121
+
122
+ function cleanKind(value: unknown): ProjectEntryKind | undefined {
123
+ return optionalEnum(value, "kind", PROJECT_ENTRY_KINDS) as ProjectEntryKind | undefined;
124
+ }
125
+
126
+ /** The caller's profile NAME, from the signed token constraints. Self-scoped writes resolve the
127
+ * store from THIS — never a name the caller passes — so an agent can only write its own role's
128
+ * memory. Throws when the token carries no profile (e.g. a human/admin token). */
129
+ function callerProfileName(ctx: AuthContext): string {
130
+ const profile = ctx.constraints?.profile;
131
+ if (!profile) throw new ValidationError("no profile on this token: scoped profile memory is writable only by a spawned agent with a resolved profile");
132
+ return profile;
133
+ }
134
+
135
+ /** The caller's operator identity for the `user` scope — the owner of the calling agent, falling
136
+ * back to the default (single-tenant) operator. */
137
+ function callerOperatorId(ctx: AuthContext): string {
138
+ if (ctx.callerAgentId) return resolveAgentOwnership(ctx.callerAgentId).ownerUserId;
139
+ if (ctx.actor.kind === "user" && ctx.actor.id && ctx.actor.id !== "anonymous") return ctx.actor.id;
140
+ return DEFAULT_USER_ID;
141
+ }
142
+
143
+ /** Resolve the caller's OWN store for a write/promote/retract — get-or-create. */
144
+ function ownAnchor(ctx: AuthContext, scope: MemoryScope): Project {
145
+ return scope === "profile" ? ensureProfileAnchor(callerProfileName(ctx)) : ensureUserAnchor(callerOperatorId(ctx));
146
+ }
147
+
148
+ /** Resolve the store for a READ — own store by default, or an explicitly-named profile (read-only
149
+ * transparency). Returns null when a named profile has no store yet. */
150
+ function readAnchor(ctx: AuthContext, scope: MemoryScope, explicitProfile: string | undefined): Project | null {
151
+ if (explicitProfile) return resolveScopedAnchor("profile", explicitProfile);
152
+ if (scope === "profile") return resolveScopedAnchor("profile", callerProfileName(ctx));
153
+ return resolveScopedAnchor("user", callerOperatorId(ctx));
154
+ }
155
+
156
+ function authorId(ctx: AuthContext): string {
157
+ return ctx.callerAgentId ?? ctx.actor.id;
158
+ }
159
+
160
+ /** Confirm an entry belongs to the caller's own store before mutating it (self-scoped guard). */
161
+ function ownEntryProjectId(ctx: AuthContext, entryId: string): string {
162
+ const entry = getProjectEntry(entryId);
163
+ if (!entry) throw new McpNotFoundError("memory entry not found");
164
+ const profileStore = resolveScopedAnchor("profile", callerProfileNameOrNull(ctx) ?? "");
165
+ const userStore = resolveScopedAnchor("user", callerOperatorId(ctx));
166
+ if (entry.projectId === profileStore?.id || entry.projectId === userStore?.id) return entry.projectId;
167
+ throw new McpNotFoundError("memory entry not found");
168
+ }
169
+
170
+ function callerProfileNameOrNull(ctx: AuthContext): string | null {
171
+ return ctx.constraints?.profile ?? null;
172
+ }
173
+
174
+ export function relayMemoryTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
175
+ const ctx = authContextFromMcp(auth);
176
+
177
+ if (name === "relay_memory_write") {
178
+ const scope = cleanScope(args.scope);
179
+ const anchor = ownAnchor(ctx, scope);
180
+ const kind = cleanKind(args.kind) ?? "memory";
181
+ return writeProjectEntry({
182
+ projectId: anchor.id,
183
+ authorId: authorId(ctx),
184
+ kind,
185
+ title: cleanString(args.title, "title", { required: true, max: 240 })!,
186
+ body: cleanString(args.body, "body", { max: 16_000 }),
187
+ tags: cleanStringArray(args.tags, "tags", { itemMax: 80, maxItems: 32 }),
188
+ status: args.promote === true ? "active" : "proposed",
189
+ supersedePriorByTitle: true,
190
+ }) as unknown as Record<string, unknown>;
191
+ }
192
+
193
+ if (name === "relay_memory_promote") {
194
+ const id = cleanString(args.id, "id", { required: true, max: 120 })!;
195
+ ownEntryProjectId(ctx, id);
196
+ const entry = promoteProjectEntry(id);
197
+ if (!entry) throw new McpNotFoundError("memory entry not found");
198
+ return entry as unknown as Record<string, unknown>;
199
+ }
200
+
201
+ if (name === "relay_memory_retract") {
202
+ const id = cleanString(args.id, "id", { required: true, max: 120 })!;
203
+ ownEntryProjectId(ctx, id);
204
+ const entry = retractProjectEntry(id);
205
+ if (!entry) throw new McpNotFoundError("memory entry not found");
206
+ return entry as unknown as Record<string, unknown>;
207
+ }
208
+
209
+ if (name === "relay_memory_read") {
210
+ const scope = cleanScope(args.scope);
211
+ const explicitProfile = cleanString(args.profile, "profile", { max: 200 });
212
+ const anchor = readAnchor(ctx, scope, explicitProfile);
213
+ if (!anchor) return { entries: [] };
214
+ const status = optionalEnum(args.status, "status", MEMORY_ENTRY_STATUSES) as (typeof MEMORY_ENTRY_STATUSES)[number] | undefined;
215
+ const kind = cleanKind(args.kind);
216
+ // A status filter (e.g. reviewing the `proposed` tier) reads the OWN store only — proposed
217
+ // captures never cascade. The default active read uses the contains-graph cascade so a profile
218
+ // inherits its umbrella's promoted principles (each tagged `inherited:true`).
219
+ if (status) {
220
+ return { entries: listProjectEntries(anchor.id, { ...(kind ? { kind } : {}), status }) };
221
+ }
222
+ return {
223
+ entries: resolveProjectEntries(anchor.id, {
224
+ ...(kind ? { kind } : {}),
225
+ includeInherited: args.includeInherited !== false,
226
+ }),
227
+ };
228
+ }
229
+
230
+ if (name === "relay_memory_search") {
231
+ const scope = cleanScope(args.scope);
232
+ const explicitProfile = cleanString(args.profile, "profile", { max: 200 });
233
+ const anchor = readAnchor(ctx, scope, explicitProfile);
234
+ if (!anchor) return { entries: [] };
235
+ return { entries: searchProjectEntries(anchor.id, cleanString(args.query, "query", { required: true, max: 240 })!, { kind: cleanKind(args.kind) }) };
236
+ }
237
+
238
+ throw new ValidationError(`unknown tool: ${name}`);
239
+ }
@@ -2,6 +2,7 @@ import { APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES } from "a
2
2
  import { McpAuthError } from "./mcp-errors";
3
3
  import { authContextFromMcp } from "./services/auth-context";
4
4
  import { deleteTeamTemplate, getTeamTemplate, instantiateTeamTemplate, listTeamTemplates, saveTeamTemplate } from "./services/team-templates";
5
+ import { summarizeSpawnCommandForCaller } from "./services/spawn-agent";
5
6
  import { ValidationError } from "./db";
6
7
 
7
8
  const COMPOSITION_ENTRY_SCHEMA = {
@@ -123,7 +124,13 @@ export async function relayTeamTemplateTool(auth: McpAuthLike, name: string, arg
123
124
  }
124
125
  if (name === "relay_team_spawn_from_template") {
125
126
  requireScope(auth, "teams:write");
126
- return await instantiateTeamTemplate({ ...args, slug: slugArg(args) }, authContextFromMcp(auth)) as unknown as Record<string, unknown>;
127
+ const instantiated = await instantiateTeamTemplate({ ...args, slug: slugArg(args) }, authContextFromMcp(auth));
128
+ // #734 — each spawned result echoes its full resolved agentProfile; strip the heavy inlined
129
+ // provisioning file contents (multiplied by N spawned agents here) before returning to the caller.
130
+ return {
131
+ ...instantiated,
132
+ spawned: instantiated.spawned.map((s) => ({ ...s, command: summarizeSpawnCommandForCaller(s.command) })),
133
+ } as unknown as Record<string, unknown>;
127
134
  }
128
135
  throw new ValidationError(`unknown tool: ${name}`);
129
136
  }