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.
- package/docs/openapi.json +39 -1
- package/package.json +3 -3
- package/public/assets/MessageBubble-CIDyX8f7.js +4 -0
- package/public/assets/MessageBubble-CIDyX8f7.js.map +1 -0
- package/public/assets/{PlanGraphPanel-DPgW_Ohe.js → PlanGraphPanel-BmWB81w_.js} +2 -2
- package/public/assets/{PlanGraphPanel-DPgW_Ohe.js.map → PlanGraphPanel-BmWB81w_.js.map} +1 -1
- package/public/assets/{activity-CRebaL8O.js → activity-QQikHZe4.js} +2 -2
- package/public/assets/{activity-CRebaL8O.js.map → activity-QQikHZe4.js.map} +1 -1
- package/public/assets/agent-profiles-WvAN5cR_.js +2 -0
- package/public/assets/agent-profiles-WvAN5cR_.js.map +1 -0
- package/public/assets/agents-BsFfumLn.js +2 -0
- package/public/assets/{agents-7WtV1Pf3.js.map → agents-BsFfumLn.js.map} +1 -1
- package/public/assets/{analytics-DpnJh9UQ.js → analytics-CUJbpoA7.js} +2 -2
- package/public/assets/{analytics-DpnJh9UQ.js.map → analytics-CUJbpoA7.js.map} +1 -1
- package/public/assets/{automation-CjQNCXlO.js → automation-BTbpiINE.js} +2 -2
- package/public/assets/{automation-CjQNCXlO.js.map → automation-BTbpiINE.js.map} +1 -1
- package/public/assets/{badge-BbfOskRI.js → badge-BLar6FjP.js} +2 -2
- package/public/assets/{badge-BbfOskRI.js.map → badge-BLar6FjP.js.map} +1 -1
- package/public/assets/branch-state-badge-CNko2_BL.js +2 -0
- package/public/assets/{branch-state-badge-BcObk0ca.js.map → branch-state-badge-CNko2_BL.js.map} +1 -1
- package/public/assets/{button-DWiw--lT.js → button-BrIB69qd.js} +2 -2
- package/public/assets/{button-DWiw--lT.js.map → button-BrIB69qd.js.map} +1 -1
- package/public/assets/card-lhnFnBoz.js +2 -0
- package/public/assets/{card-BeqvuYTT.js.map → card-lhnFnBoz.js.map} +1 -1
- package/public/assets/{channels-B2bftzgH.js → channels-Tg8l4n5J.js} +2 -2
- package/public/assets/{channels-B2bftzgH.js.map → channels-Tg8l4n5J.js.map} +1 -1
- package/public/assets/chat-BlFUmD3r.js +2 -0
- package/public/assets/chat-BlFUmD3r.js.map +1 -0
- package/public/assets/{connectors-CJOJirYT.js → connectors-B5wbjyUd.js} +2 -2
- package/public/assets/{connectors-CJOJirYT.js.map → connectors-B5wbjyUd.js.map} +1 -1
- package/public/assets/copy-button-Cd-LbSbg.js +2 -0
- package/public/assets/{copy-button-CIXy8ks6.js.map → copy-button-Cd-LbSbg.js.map} +1 -1
- package/public/assets/dist-30weaXto.js +2 -0
- package/public/assets/{dist-LFuRypZd.js.map → dist-30weaXto.js.map} +1 -1
- package/public/assets/{dist-CtuS_3Jl.js → dist-B5VvlkhI.js} +2 -2
- package/public/assets/{dist-CtuS_3Jl.js.map → dist-B5VvlkhI.js.map} +1 -1
- package/public/assets/dist-BEzpMqLy.js +2 -0
- package/public/assets/{dist-Q6OOS84U.js.map → dist-BEzpMqLy.js.map} +1 -1
- package/public/assets/{dist-CbxR2pzD.js → dist-BjOBkHHY.js} +2 -2
- package/public/assets/{dist-CbxR2pzD.js.map → dist-BjOBkHHY.js.map} +1 -1
- package/public/assets/{dist-zTw85jD8.js → dist-BnbGIUmi.js} +2 -2
- package/public/assets/{dist-zTw85jD8.js.map → dist-BnbGIUmi.js.map} +1 -1
- package/public/assets/{dist-BufB2Xy_.js → dist-Bp10H3oR.js} +2 -2
- package/public/assets/{dist-BufB2Xy_.js.map → dist-Bp10H3oR.js.map} +1 -1
- package/public/assets/{dist-Du_bcq-z.js → dist-CQF3Jiqg.js} +2 -2
- package/public/assets/{dist-Du_bcq-z.js.map → dist-CQF3Jiqg.js.map} +1 -1
- package/public/assets/dist-Cgy0FmP-.js +2 -0
- package/public/assets/{dist-CXkmW1Od.js.map → dist-Cgy0FmP-.js.map} +1 -1
- package/public/assets/{dist-KxiSP9h9.js → dist-DAl28ejX.js} +2 -2
- package/public/assets/{dist-KxiSP9h9.js.map → dist-DAl28ejX.js.map} +1 -1
- package/public/assets/{dist-eG_93I3z.js → dist-DDUGmmoi.js} +2 -2
- package/public/assets/{dist-eG_93I3z.js.map → dist-DDUGmmoi.js.map} +1 -1
- package/public/assets/{dist-BQXmO19g.js → dist-DZ97OosY.js} +2 -2
- package/public/assets/{dist-BQXmO19g.js.map → dist-DZ97OosY.js.map} +1 -1
- package/public/assets/{es2015-ibwCE_JR.js → es2015-B8Z9p8oQ.js} +2 -2
- package/public/assets/{es2015-ibwCE_JR.js.map → es2015-B8Z9p8oQ.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CGACmUqI.js → formatted-body-impl-Dbjjdcu9.js} +2 -2
- package/public/assets/{formatted-body-impl-CGACmUqI.js.map → formatted-body-impl-Dbjjdcu9.js.map} +1 -1
- package/public/assets/index-CPWbr4sq.css +2 -0
- package/public/assets/index-CPXz74W5.js +23 -0
- package/public/assets/index-CPXz74W5.js.map +1 -0
- package/public/assets/{input-DUdtS3nG.js → input-B_fUHtLD.js} +2 -2
- package/public/assets/{input-DUdtS3nG.js.map → input-B_fUHtLD.js.map} +1 -1
- package/public/assets/{insights-DqXjTYai.js → insights-D1Oq25BL.js} +2 -2
- package/public/assets/{insights-DqXjTYai.js.map → insights-D1Oq25BL.js.map} +1 -1
- package/public/assets/{integrations-DEgLatDv.js → integrations-sYKtupOq.js} +2 -2
- package/public/assets/{integrations-DEgLatDv.js.map → integrations-sYKtupOq.js.map} +1 -1
- package/public/assets/{lib-Gp69YB9e.js → lib-ng6WZYjE.js} +2 -2
- package/public/assets/{lib-Gp69YB9e.js.map → lib-ng6WZYjE.js.map} +1 -1
- package/public/assets/{lucide-react-uFIMC7W7.js → lucide-react-BYZuAhQs.js} +2 -2
- package/public/assets/{lucide-react-uFIMC7W7.js.map → lucide-react-BYZuAhQs.js.map} +1 -1
- package/public/assets/maintenance-CgXJwFTo.js +2 -0
- package/public/assets/{maintenance-CcFJlNxt.js.map → maintenance-CgXJwFTo.js.map} +1 -1
- package/public/assets/{managed-agents-CHR4Hx1O.js → managed-agents-CbuFHwWE.js} +4 -4
- package/public/assets/{managed-agents-CHR4Hx1O.js.map → managed-agents-CbuFHwWE.js.map} +1 -1
- package/public/assets/markdown-preview-impl-B79bSsKK.js +2 -0
- package/public/assets/{markdown-preview-impl-BHaUcnfn.js.map → markdown-preview-impl-B79bSsKK.js.map} +1 -1
- package/public/assets/memory-Box_rMj_.js +2 -0
- package/public/assets/{memory-Uk0VfqnC.js.map → memory-Box_rMj_.js.map} +1 -1
- package/public/assets/{messages-D_juCBfr.js → messages-BXIHBaN2.js} +2 -2
- package/public/assets/{messages-D_juCBfr.js.map → messages-BXIHBaN2.js.map} +1 -1
- package/public/assets/{orchestrators-8IrmgeHV.js → orchestrators-MNj_mp3U.js} +2 -2
- package/public/assets/{orchestrators-8IrmgeHV.js.map → orchestrators-MNj_mp3U.js.map} +1 -1
- package/public/assets/{overview-D_DM0QHJ.js → overview-KfwJgCUa.js} +2 -2
- package/public/assets/{overview-D_DM0QHJ.js.map → overview-KfwJgCUa.js.map} +1 -1
- package/public/assets/{pairs-jciDuPLn.js → pairs-DfrGWlgL.js} +2 -2
- package/public/assets/{pairs-jciDuPLn.js.map → pairs-DfrGWlgL.js.map} +1 -1
- package/public/assets/{projects-C-QHyuMD.js → projects-BPI8z55X.js} +2 -2
- package/public/assets/{projects-C-QHyuMD.js.map → projects-BPI8z55X.js.map} +1 -1
- package/public/assets/{react-dom-DF2cuNaf.js → react-dom-Dbhs84ug.js} +2 -2
- package/public/assets/{react-dom-DF2cuNaf.js.map → react-dom-Dbhs84ug.js.map} +1 -1
- package/public/assets/{registry-D1EIvc8U.js → registry-CiBC97sL.js} +2 -2
- package/public/assets/{registry-D1EIvc8U.js.map → registry-CiBC97sL.js.map} +1 -1
- package/public/assets/{security-C0_Df9yI.js → security-BbU_vIdb.js} +2 -2
- package/public/assets/{security-C0_Df9yI.js.map → security-BbU_vIdb.js.map} +1 -1
- package/public/assets/{select-CXFXPZ7Z.js → select-CeQwmVTm.js} +2 -2
- package/public/assets/{select-CXFXPZ7Z.js.map → select-CeQwmVTm.js.map} +1 -1
- package/public/assets/settings-DBsL3jb7.js +6 -0
- package/public/assets/{settings-DGPpdFoq.js.map → settings-DBsL3jb7.js.map} +1 -1
- package/public/assets/{store-d-IbqTaD.js → store-pKbf_WYd.js} +5 -5
- package/public/assets/{store-d-IbqTaD.js.map → store-pKbf_WYd.js.map} +1 -1
- package/public/assets/{tasks-B_epVa5b.js → tasks-za0RxIQw.js} +2 -2
- package/public/assets/{tasks-B_epVa5b.js.map → tasks-za0RxIQw.js.map} +1 -1
- package/public/assets/{teams-D2hATpCD.js → teams-ByAqwLG9.js} +2 -2
- package/public/assets/{teams-D2hATpCD.js.map → teams-ByAqwLG9.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CefeetWa.js → terminal-viewer-impl-CkoWD5D_.js} +3 -3
- package/public/assets/{terminal-viewer-impl-CefeetWa.js.map → terminal-viewer-impl-CkoWD5D_.js.map} +1 -1
- package/public/assets/types-Tv67voFu.js.map +1 -1
- package/public/assets/{use-keyboard-viewport-CeLM-PeW.js → use-keyboard-viewport-D8_NYDkh.js} +2 -2
- package/public/assets/{use-keyboard-viewport-CeLM-PeW.js.map → use-keyboard-viewport-D8_NYDkh.js.map} +1 -1
- package/public/assets/user-avatar-Q_N6iHEn.js +2 -0
- package/public/assets/{user-avatar-BOGZ21O8.js.map → user-avatar-Q_N6iHEn.js.map} +1 -1
- package/public/assets/{work-queue-C9wBOivX.js → work-queue-DfRo1B2P.js} +2 -2
- package/public/assets/{work-queue-C9wBOivX.js.map → work-queue-DfRo1B2P.js.map} +1 -1
- package/public/assets/workspaces-BolccWZy.js +3 -0
- package/public/assets/{workspaces-DKFmnOF8.js.map → workspaces-BolccWZy.js.map} +1 -1
- package/public/index.html +21 -21
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/agent-busy-health.ts +22 -20
- package/src/config-store.ts +72 -6
- package/src/db/migrations.ts +9 -0
- package/src/db/project-entries.ts +63 -9
- package/src/db/projects.ts +76 -4
- package/src/db/schema.ts +8 -0
- package/src/mcp/index.ts +4 -3
- package/src/mcp/tools-spawn.ts +13 -2
- package/src/mcp-memory-tools.ts +239 -0
- package/src/mcp-team-template-tools.ts +8 -1
- package/src/project-instructions.ts +48 -10
- package/src/routes/agent-profiles.ts +36 -8
- package/src/routes/agents-spawn.ts +4 -2
- package/src/routes/index.ts +2 -1
- package/src/runtime-tokens.ts +3 -0
- package/src/security.ts +1 -1
- package/src/services/spawn-agent.ts +57 -2
- package/public/assets/MessageBubble-CkPnAwxJ.js +0 -3
- package/public/assets/MessageBubble-CkPnAwxJ.js.map +0 -1
- package/public/assets/agent-profiles-BFpEQHKo.js +0 -2
- package/public/assets/agent-profiles-BFpEQHKo.js.map +0 -1
- package/public/assets/agents-7WtV1Pf3.js +0 -2
- package/public/assets/branch-state-badge-BcObk0ca.js +0 -2
- package/public/assets/card-BeqvuYTT.js +0 -2
- package/public/assets/chat-Dcwua_5V.js +0 -2
- package/public/assets/chat-Dcwua_5V.js.map +0 -1
- package/public/assets/copy-button-CIXy8ks6.js +0 -2
- package/public/assets/dist-CXkmW1Od.js +0 -2
- package/public/assets/dist-LFuRypZd.js +0 -2
- package/public/assets/dist-Q6OOS84U.js +0 -2
- package/public/assets/index-CKPsJqiW.css +0 -2
- package/public/assets/index-CwhhpGgX.js +0 -23
- package/public/assets/index-CwhhpGgX.js.map +0 -1
- package/public/assets/maintenance-CcFJlNxt.js +0 -2
- package/public/assets/markdown-preview-impl-BHaUcnfn.js +0 -2
- package/public/assets/memory-Uk0VfqnC.js +0 -2
- package/public/assets/settings-DGPpdFoq.js +0 -6
- package/public/assets/user-avatar-BOGZ21O8.js +0 -2
- 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 (
|
|
95
|
-
const previous = getProjectEntry(
|
|
96
|
-
if (!previous) throw new ValidationError(`project entry ${
|
|
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,
|
|
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 (?, ?, ?, ?, ?,
|
|
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
|
-
|
|
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
|
-
|
|
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 ")}
|
package/src/db/projects.ts
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
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}`);
|
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|