agent-relay-server 0.95.1 → 0.96.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 +2 -2
- package/public/assets/MessageBubble-BO9eaLce.js +4 -0
- package/public/assets/MessageBubble-BO9eaLce.js.map +1 -0
- package/public/assets/{PlanGraphPanel-DPgW_Ohe.js → PlanGraphPanel-BK2GoOql.js} +2 -2
- package/public/assets/{PlanGraphPanel-DPgW_Ohe.js.map → PlanGraphPanel-BK2GoOql.js.map} +1 -1
- package/public/assets/{activity-CRebaL8O.js → activity-FPNsylmj.js} +2 -2
- package/public/assets/{activity-CRebaL8O.js.map → activity-FPNsylmj.js.map} +1 -1
- package/public/assets/agent-profiles-lEZqu7Gm.js +2 -0
- package/public/assets/agent-profiles-lEZqu7Gm.js.map +1 -0
- package/public/assets/{agents-7WtV1Pf3.js → agents-DwXYbjeZ.js} +2 -2
- package/public/assets/{agents-7WtV1Pf3.js.map → agents-DwXYbjeZ.js.map} +1 -1
- package/public/assets/{analytics-DpnJh9UQ.js → analytics-BiYHZABE.js} +2 -2
- package/public/assets/{analytics-DpnJh9UQ.js.map → analytics-BiYHZABE.js.map} +1 -1
- package/public/assets/{automation-CjQNCXlO.js → automation-deDG3zs5.js} +2 -2
- package/public/assets/{automation-CjQNCXlO.js.map → automation-deDG3zs5.js.map} +1 -1
- package/public/assets/{branch-state-badge-BcObk0ca.js → branch-state-badge-ClWlvQks.js} +2 -2
- package/public/assets/{branch-state-badge-BcObk0ca.js.map → branch-state-badge-ClWlvQks.js.map} +1 -1
- package/public/assets/{channels-B2bftzgH.js → channels-ln-_oQlI.js} +2 -2
- package/public/assets/{channels-B2bftzgH.js.map → channels-ln-_oQlI.js.map} +1 -1
- package/public/assets/chat-DH8vR-mf.js +2 -0
- package/public/assets/chat-DH8vR-mf.js.map +1 -0
- package/public/assets/{connectors-CJOJirYT.js → connectors-CmxpixdH.js} +2 -2
- package/public/assets/{connectors-CJOJirYT.js.map → connectors-CmxpixdH.js.map} +1 -1
- package/public/assets/{formatted-body-impl-CGACmUqI.js → formatted-body-impl-l_m1i28m.js} +2 -2
- package/public/assets/{formatted-body-impl-CGACmUqI.js.map → formatted-body-impl-l_m1i28m.js.map} +1 -1
- package/public/assets/index-BETon8ID.css +2 -0
- package/public/assets/{index-CwhhpGgX.js → index-D1GX2b-m.js} +9 -9
- package/public/assets/index-D1GX2b-m.js.map +1 -0
- package/public/assets/{insights-DqXjTYai.js → insights-DpNVkiVq.js} +2 -2
- package/public/assets/{insights-DqXjTYai.js.map → insights-DpNVkiVq.js.map} +1 -1
- package/public/assets/{integrations-DEgLatDv.js → integrations-DscgKx2I.js} +2 -2
- package/public/assets/{integrations-DEgLatDv.js.map → integrations-DscgKx2I.js.map} +1 -1
- package/public/assets/{maintenance-CcFJlNxt.js → maintenance-BhAxK-Fd.js} +2 -2
- package/public/assets/{maintenance-CcFJlNxt.js.map → maintenance-BhAxK-Fd.js.map} +1 -1
- package/public/assets/{managed-agents-CHR4Hx1O.js → managed-agents-Bg4aG1a9.js} +2 -2
- package/public/assets/{managed-agents-CHR4Hx1O.js.map → managed-agents-Bg4aG1a9.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BHaUcnfn.js → markdown-preview-impl-BJHjAo1H.js} +2 -2
- package/public/assets/{markdown-preview-impl-BHaUcnfn.js.map → markdown-preview-impl-BJHjAo1H.js.map} +1 -1
- package/public/assets/{memory-Uk0VfqnC.js → memory-WwrerNC0.js} +2 -2
- package/public/assets/{memory-Uk0VfqnC.js.map → memory-WwrerNC0.js.map} +1 -1
- package/public/assets/{messages-D_juCBfr.js → messages-BY6mboBf.js} +2 -2
- package/public/assets/{messages-D_juCBfr.js.map → messages-BY6mboBf.js.map} +1 -1
- package/public/assets/{orchestrators-8IrmgeHV.js → orchestrators-ClNqQHnw.js} +2 -2
- package/public/assets/{orchestrators-8IrmgeHV.js.map → orchestrators-ClNqQHnw.js.map} +1 -1
- package/public/assets/{overview-D_DM0QHJ.js → overview-Bk91s8bA.js} +2 -2
- package/public/assets/{overview-D_DM0QHJ.js.map → overview-Bk91s8bA.js.map} +1 -1
- package/public/assets/{pairs-jciDuPLn.js → pairs-IxkuPIfN.js} +2 -2
- package/public/assets/{pairs-jciDuPLn.js.map → pairs-IxkuPIfN.js.map} +1 -1
- package/public/assets/{projects-C-QHyuMD.js → projects-D7WJtj7Y.js} +2 -2
- package/public/assets/{projects-C-QHyuMD.js.map → projects-D7WJtj7Y.js.map} +1 -1
- package/public/assets/{registry-D1EIvc8U.js → registry-DUzVG_Oi.js} +2 -2
- package/public/assets/{registry-D1EIvc8U.js.map → registry-DUzVG_Oi.js.map} +1 -1
- package/public/assets/{security-C0_Df9yI.js → security-D9ru8DHd.js} +2 -2
- package/public/assets/{security-C0_Df9yI.js.map → security-D9ru8DHd.js.map} +1 -1
- package/public/assets/{select-CXFXPZ7Z.js → select-Bu-gipoC.js} +2 -2
- package/public/assets/{select-CXFXPZ7Z.js.map → select-Bu-gipoC.js.map} +1 -1
- package/public/assets/{settings-DGPpdFoq.js → settings-CoOySnqS.js} +2 -2
- package/public/assets/{settings-DGPpdFoq.js.map → settings-CoOySnqS.js.map} +1 -1
- package/public/assets/{store-d-IbqTaD.js → store-DGM2s9L1.js} +2 -2
- package/public/assets/{store-d-IbqTaD.js.map → store-DGM2s9L1.js.map} +1 -1
- package/public/assets/{tasks-B_epVa5b.js → tasks-C94dSpvI.js} +2 -2
- package/public/assets/{tasks-B_epVa5b.js.map → tasks-C94dSpvI.js.map} +1 -1
- package/public/assets/{teams-D2hATpCD.js → teams-Cq7HWQe9.js} +2 -2
- package/public/assets/{teams-D2hATpCD.js.map → teams-Cq7HWQe9.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-CefeetWa.js → terminal-viewer-impl-BQr-VK32.js} +2 -2
- package/public/assets/{terminal-viewer-impl-CefeetWa.js.map → terminal-viewer-impl-BQr-VK32.js.map} +1 -1
- package/public/assets/types-Tv67voFu.js.map +1 -1
- package/public/assets/{user-avatar-BOGZ21O8.js → user-avatar-aCd8Fb5v.js} +2 -2
- package/public/assets/{user-avatar-BOGZ21O8.js.map → user-avatar-aCd8Fb5v.js.map} +1 -1
- package/public/assets/{work-queue-C9wBOivX.js → work-queue-BuyQhedx.js} +2 -2
- package/public/assets/{work-queue-C9wBOivX.js.map → work-queue-BuyQhedx.js.map} +1 -1
- package/public/assets/{workspaces-DKFmnOF8.js → workspaces-DXTrd2or.js} +2 -2
- package/public/assets/{workspaces-DKFmnOF8.js.map → workspaces-DXTrd2or.js.map} +1 -1
- package/public/index.html +3 -3
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- 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-memory-tools.ts +239 -0
- package/src/project-instructions.ts +48 -10
- package/src/routes/agent-profiles.ts +36 -8
- 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 +12 -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/chat-Dcwua_5V.js +0 -2
- package/public/assets/chat-Dcwua_5V.js.map +0 -1
- package/public/assets/index-CKPsJqiW.css +0 -2
- package/public/assets/index-CwhhpGgX.js.map +0 -1
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}`);
|
|
@@ -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
|
+
}
|
|
@@ -44,6 +44,10 @@ interface ComposeProjectInstructionsOptions {
|
|
|
44
44
|
/** Hard byte cap on the rendered block (UTF-8). Default 8192. The block is truncated
|
|
45
45
|
* deterministically (lowest-priority entries dropped first) to stay within it. */
|
|
46
46
|
maxBytes?: number;
|
|
47
|
+
/** Header label prefix (#559). Default "Project knowledge"; the scoped stores pass
|
|
48
|
+
* "Profile memory" / "Operator memory" so an agent can tell role/operator memory from
|
|
49
|
+
* repo knowledge. The anchor title/slug is still appended. */
|
|
50
|
+
headerLabel?: string;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
interface SpawnInstructionInjectionEvent {
|
|
@@ -140,7 +144,7 @@ export function composeProjectInstructions(
|
|
|
140
144
|
|
|
141
145
|
// Greedy size-bounded render. Entries are contiguous by kind (primary sort key), so a
|
|
142
146
|
// per-kind `## heading` is emitted once when the first entry of that kind is included.
|
|
143
|
-
const header = `# Project knowledge: ${project.title?.trim() || project.slug}`;
|
|
147
|
+
const header = `# ${opts.headerLabel ?? "Project knowledge"}: ${project.title?.trim() || project.slug}`;
|
|
144
148
|
const budget = maxBytes - byteLen(`\n\n${TRUNCATION_NOTICE}`);
|
|
145
149
|
const lines: string[] = [header];
|
|
146
150
|
let bytes = byteLen(header);
|
|
@@ -181,18 +185,37 @@ export function composeProjectInstructions(
|
|
|
181
185
|
* Returns the joined block, or the caller's own systemPromptAppend untouched when no
|
|
182
186
|
* profile/project material applies — so a profile-less spawn stays byte-identical.
|
|
183
187
|
*
|
|
184
|
-
* ── #559
|
|
185
|
-
* Scoped {profile|user} memory
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* prompt.
|
|
190
|
-
*
|
|
191
|
-
* a refactor.
|
|
188
|
+
* ── #559 scoped memory ─────────────────────────────────────────────────────────────────
|
|
189
|
+
* Scoped {profile|user} memory (#559) composes in beside the project preamble: `profileAnchor`
|
|
190
|
+
* (role memory) and `userAnchor` (operator memory) each render via the same
|
|
191
|
+
* `composeProjectInstructions` renderer (scope-agnostic — a profile/user store is just a
|
|
192
|
+
* `projects` row of a different scope) and slot into `appendBlocks` before the profile system
|
|
193
|
+
* prompt. Unlike the project preamble they are UNGATED — relay-native, curated, small — so they
|
|
194
|
+
* inject whenever the resolved anchor holds active (promoted) entries.
|
|
192
195
|
*/
|
|
196
|
+
/** Kinds rendered into a scoped {profile|user} memory preamble (#559). Adds `memory` to the
|
|
197
|
+
* project default (rule/fact) because `memory` is the /remember capture kind — a promoted role
|
|
198
|
+
* principle or operator preference must reach the spawn. `decision` stays log-only. */
|
|
199
|
+
const SCOPED_MEMORY_KINDS: readonly ProjectEntryKind[] = ["rule", "fact", "memory"];
|
|
200
|
+
|
|
201
|
+
/** Render a {profile|user}-scoped store's promoted (active) entries into a preamble block, or "".
|
|
202
|
+
* Unlike the project preamble this is UNGATED by a policy: scoped memory is relay-native, curated
|
|
203
|
+
* (proposed→promoted), and small, so it always injects when present (#559). */
|
|
204
|
+
function composeScopedKnowledge(
|
|
205
|
+
anchor: Pick<Project, "id" | "slug" | "title">,
|
|
206
|
+
headerLabel: string,
|
|
207
|
+
): string {
|
|
208
|
+
return composeProjectInstructions(anchor, { includeKinds: SCOPED_MEMORY_KINDS, headerLabel });
|
|
209
|
+
}
|
|
210
|
+
|
|
193
211
|
export function composeSpawnInstructionsWithEvents(input: {
|
|
194
212
|
callerProject?: Pick<Project, "id" | "slug" | "title">;
|
|
195
213
|
projectInstructions?: ProjectInstructionPolicy;
|
|
214
|
+
/** #559 — the caller's resolved profile-scoped store (role memory), injected when it has
|
|
215
|
+
* active entries. Inheritance via the contains graph is handled inside the composer. */
|
|
216
|
+
profileAnchor?: Pick<Project, "id" | "slug" | "title">;
|
|
217
|
+
/** #559 — the caller's resolved user/operator-scoped store (operator memory). */
|
|
218
|
+
userAnchor?: Pick<Project, "id" | "slug" | "title">;
|
|
196
219
|
profile?: Pick<AgentProfile, "instructions" | "maxSpawnedAgents">;
|
|
197
220
|
spawnedBy?: string;
|
|
198
221
|
callerSystemPromptAppend?: string;
|
|
@@ -217,7 +240,22 @@ export function composeSpawnInstructionsWithEvents(input: {
|
|
|
217
240
|
});
|
|
218
241
|
}
|
|
219
242
|
}
|
|
220
|
-
// [#559
|
|
243
|
+
// [#559] profile/user-scoped knowledge preamble — beside the project preamble, before the
|
|
244
|
+
// profile system prompt. Ungated: injects whenever the resolved anchor holds active entries.
|
|
245
|
+
for (const scope of [
|
|
246
|
+
{ anchor: input.profileAnchor, label: "Profile memory", source: "profile-memory" as const, category: "knowledge" as const },
|
|
247
|
+
{ anchor: input.userAnchor, label: "Operator memory", source: "user-memory" as const, category: "knowledge" as const },
|
|
248
|
+
]) {
|
|
249
|
+
if (!scope.anchor) continue;
|
|
250
|
+
const block = composeScopedKnowledge(scope.anchor, scope.label);
|
|
251
|
+
if (!block.trim()) continue;
|
|
252
|
+
appendBlocks.push(block);
|
|
253
|
+
relayInjectionEvents.push({
|
|
254
|
+
category: scope.category,
|
|
255
|
+
summary: `injected ${scope.label.toLowerCase()}: ${scope.anchor.title?.trim() || scope.anchor.slug}`,
|
|
256
|
+
detail: { source: scope.source, anchor: scope.anchor, content: block },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
221
259
|
if (input.spawnedBy) {
|
|
222
260
|
appendBlocks.push(SPAWNED_CHILD_REPORTUP_INSTRUCTION);
|
|
223
261
|
relayInjectionEvents.push({
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: agent-profiles.
|
|
2
|
-
import { ValidationError } from "../db";
|
|
3
|
-
import { cleanString } from "../validation";
|
|
2
|
+
import { ValidationError, withResolvedProvisioning } from "../db";
|
|
3
|
+
import { cleanString, optionalEnum } from "../validation";
|
|
4
4
|
import { deleteAgentProfile, getAgentProfile, listAgentProfiles, setAgentProfile } from "../config-store";
|
|
5
5
|
import { emitConfigChanged } from "../sse";
|
|
6
|
-
import { error, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
|
|
6
|
+
import { error, isRootCredentialRequest, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
|
|
7
|
+
import { getComponentAuth, hasComponentScope } from "../security";
|
|
7
8
|
import { isRecord } from "agent-relay-sdk";
|
|
8
|
-
import { type AgentProfile } from "../types";
|
|
9
|
+
import { SPAWN_PROVIDERS, type AgentProfile, type SpawnProvider } from "../types";
|
|
9
10
|
|
|
10
11
|
export const getAgentProfilesRoute: Handler = () => json(listAgentProfiles());
|
|
11
12
|
|
|
@@ -15,6 +16,29 @@ export const getAgentProfileRoute: Handler = (_req, params) => {
|
|
|
15
16
|
return profile ? json(profile) : error("agent profile not found", 404);
|
|
16
17
|
};
|
|
17
18
|
|
|
19
|
+
function isAdminProfileRequest(req: Request): boolean {
|
|
20
|
+
if (isRootCredentialRequest(req)) return true;
|
|
21
|
+
const component = getComponentAuth(req);
|
|
22
|
+
return component ? hasComponentScope(component, "system:admin") : false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const getResolvedAgentProfileRoute: Handler = (req, params) => {
|
|
26
|
+
try {
|
|
27
|
+
if (!isAdminProfileRequest(req)) return error("system admin scope required", 403);
|
|
28
|
+
const name = normalizeConfigPathParam(params.name, "name");
|
|
29
|
+
const entry = getAgentProfile(name);
|
|
30
|
+
if (!entry) return error("agent profile not found", 404);
|
|
31
|
+
const requestedProvider = new URL(req.url).searchParams.get("provider") ?? undefined;
|
|
32
|
+
const profileProvider = entry.value.provider === "claude" || entry.value.provider === "codex" ? entry.value.provider : undefined;
|
|
33
|
+
const provider = optionalEnum(requestedProvider, "provider", SPAWN_PROVIDERS, profileProvider ?? "codex") as SpawnProvider;
|
|
34
|
+
const profile = withResolvedProvisioning(entry.value, provider);
|
|
35
|
+
return profile ? json({ provider, profile }) : error("agent profile not found", 404);
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (e instanceof ValidationError) return error(e.message, 400);
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
18
42
|
export const putAgentProfileRoute: Handler = async (req, params) => {
|
|
19
43
|
const parsed = await parseBody<unknown>(req);
|
|
20
44
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
@@ -22,9 +46,12 @@ export const putAgentProfileRoute: Handler = async (req, params) => {
|
|
|
22
46
|
if (!isRecord(parsed.body)) throw new ValidationError("agent profile body required");
|
|
23
47
|
const name = normalizeConfigPathParam(params.name, "name");
|
|
24
48
|
const updatedBy = cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 });
|
|
25
|
-
const
|
|
49
|
+
const existing = getAgentProfile(name);
|
|
50
|
+
const profile = existing?.value.builtIn && isAdminProfileRequest(req)
|
|
51
|
+
? setAgentProfile({ ...parsed.body, name } as AgentProfile, updatedBy, { overrideBuiltIn: true })
|
|
52
|
+
: setAgentProfile({ ...parsed.body, name } as AgentProfile, updatedBy);
|
|
26
53
|
emitConfigChanged(profile.namespace, profile.key, profile.version);
|
|
27
|
-
return json(profile, profile.version === 1 ? 201 : 200);
|
|
54
|
+
return json(profile, existing ? 200 : profile.version === 1 ? 201 : 200);
|
|
28
55
|
} catch (e) {
|
|
29
56
|
if (e instanceof ValidationError) return error(e.message, 400);
|
|
30
57
|
throw e;
|
|
@@ -35,9 +62,10 @@ export const deleteAgentProfileRoute: Handler = (req, params) => {
|
|
|
35
62
|
try {
|
|
36
63
|
const name = normalizeConfigPathParam(params.name, "name");
|
|
37
64
|
const existing = getAgentProfile(name);
|
|
38
|
-
if (!existing
|
|
65
|
+
if (!existing) return error("agent profile not found", 404);
|
|
39
66
|
const updatedBy = cleanString(new URL(req.url).searchParams.get("updatedBy") ?? undefined, "updatedBy", { max: 200 });
|
|
40
|
-
|
|
67
|
+
if (existing.value.builtIn && !isAdminProfileRequest(req)) return error("built-in agent profiles are managed by Relay", 400);
|
|
68
|
+
deleteAgentProfile(name, updatedBy, { resetBuiltInOverride: existing.value.builtIn });
|
|
41
69
|
emitConfigChanged(existing.namespace, existing.key, existing.version + 1);
|
|
42
70
|
return json({ ok: true });
|
|
43
71
|
} catch (e) {
|
package/src/routes/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
|
|
2
2
|
import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentCrashReportRoute, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
|
|
3
|
-
import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, putAgentProfileRoute } from "./agent-profiles";
|
|
3
|
+
import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, getResolvedAgentProfileRoute, putAgentProfileRoute } from "./agent-profiles";
|
|
4
4
|
import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
|
|
5
5
|
import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
|
|
6
6
|
import { deleteArtifactById, getArtifactById, getArtifactContent, getArtifacts, headArtifactContent, postArtifact } from "./artifacts";
|
|
@@ -231,6 +231,7 @@ const routes: Route[] = [
|
|
|
231
231
|
route("PATCH", "/api/commands/:id", patchCommand),
|
|
232
232
|
route("DELETE", "/api/commands/:id", deleteCommandById),
|
|
233
233
|
route("GET", "/api/agent-profiles", getAgentProfilesRoute),
|
|
234
|
+
route("GET", "/api/agent-profiles/:name/resolved", getResolvedAgentProfileRoute),
|
|
234
235
|
route("GET", "/api/agent-profiles/:name", getAgentProfileRoute),
|
|
235
236
|
route("PUT", "/api/agent-profiles/:name", putAgentProfileRoute),
|
|
236
237
|
route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -132,6 +132,9 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
132
132
|
...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
|
|
133
133
|
...(input.teamId ? { teamId: input.teamId } : {}),
|
|
134
134
|
...(input.projectId ? { projectId: input.projectId } : {}),
|
|
135
|
+
// #559 — stamp the resolved profile NAME so the scoped-memory tools resolve the
|
|
136
|
+
// caller's OWN profile store (self-scoped writes). Identity-only, like projectId.
|
|
137
|
+
...(input.profile ? { profile: input.profile } : {}),
|
|
135
138
|
...(toolGroups ? { toolGroups } : {}),
|
|
136
139
|
},
|
|
137
140
|
createdBy: input.createdBy ?? "runtime",
|
package/src/security.ts
CHANGED
|
@@ -545,7 +545,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
|
|
|
545
545
|
for (const [key, item] of Object.entries(record)) {
|
|
546
546
|
if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
|
|
547
547
|
if (typeof item !== "boolean") return false;
|
|
548
|
-
} else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId" || key === "tenantId") {
|
|
548
|
+
} else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId" || key === "profile" || key === "tenantId") {
|
|
549
549
|
if (typeof item !== "string") return false;
|
|
550
550
|
} else if (key === "maxSpawnedAgents") {
|
|
551
551
|
if (typeof item !== "number") return false;
|