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.
Files changed (97) hide show
  1. package/docs/openapi.json +39 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-BO9eaLce.js +4 -0
  4. package/public/assets/MessageBubble-BO9eaLce.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-DPgW_Ohe.js → PlanGraphPanel-BK2GoOql.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DPgW_Ohe.js.map → PlanGraphPanel-BK2GoOql.js.map} +1 -1
  7. package/public/assets/{activity-CRebaL8O.js → activity-FPNsylmj.js} +2 -2
  8. package/public/assets/{activity-CRebaL8O.js.map → activity-FPNsylmj.js.map} +1 -1
  9. package/public/assets/agent-profiles-lEZqu7Gm.js +2 -0
  10. package/public/assets/agent-profiles-lEZqu7Gm.js.map +1 -0
  11. package/public/assets/{agents-7WtV1Pf3.js → agents-DwXYbjeZ.js} +2 -2
  12. package/public/assets/{agents-7WtV1Pf3.js.map → agents-DwXYbjeZ.js.map} +1 -1
  13. package/public/assets/{analytics-DpnJh9UQ.js → analytics-BiYHZABE.js} +2 -2
  14. package/public/assets/{analytics-DpnJh9UQ.js.map → analytics-BiYHZABE.js.map} +1 -1
  15. package/public/assets/{automation-CjQNCXlO.js → automation-deDG3zs5.js} +2 -2
  16. package/public/assets/{automation-CjQNCXlO.js.map → automation-deDG3zs5.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-BcObk0ca.js → branch-state-badge-ClWlvQks.js} +2 -2
  18. package/public/assets/{branch-state-badge-BcObk0ca.js.map → branch-state-badge-ClWlvQks.js.map} +1 -1
  19. package/public/assets/{channels-B2bftzgH.js → channels-ln-_oQlI.js} +2 -2
  20. package/public/assets/{channels-B2bftzgH.js.map → channels-ln-_oQlI.js.map} +1 -1
  21. package/public/assets/chat-DH8vR-mf.js +2 -0
  22. package/public/assets/chat-DH8vR-mf.js.map +1 -0
  23. package/public/assets/{connectors-CJOJirYT.js → connectors-CmxpixdH.js} +2 -2
  24. package/public/assets/{connectors-CJOJirYT.js.map → connectors-CmxpixdH.js.map} +1 -1
  25. package/public/assets/{formatted-body-impl-CGACmUqI.js → formatted-body-impl-l_m1i28m.js} +2 -2
  26. package/public/assets/{formatted-body-impl-CGACmUqI.js.map → formatted-body-impl-l_m1i28m.js.map} +1 -1
  27. package/public/assets/index-BETon8ID.css +2 -0
  28. package/public/assets/{index-CwhhpGgX.js → index-D1GX2b-m.js} +9 -9
  29. package/public/assets/index-D1GX2b-m.js.map +1 -0
  30. package/public/assets/{insights-DqXjTYai.js → insights-DpNVkiVq.js} +2 -2
  31. package/public/assets/{insights-DqXjTYai.js.map → insights-DpNVkiVq.js.map} +1 -1
  32. package/public/assets/{integrations-DEgLatDv.js → integrations-DscgKx2I.js} +2 -2
  33. package/public/assets/{integrations-DEgLatDv.js.map → integrations-DscgKx2I.js.map} +1 -1
  34. package/public/assets/{maintenance-CcFJlNxt.js → maintenance-BhAxK-Fd.js} +2 -2
  35. package/public/assets/{maintenance-CcFJlNxt.js.map → maintenance-BhAxK-Fd.js.map} +1 -1
  36. package/public/assets/{managed-agents-CHR4Hx1O.js → managed-agents-Bg4aG1a9.js} +2 -2
  37. package/public/assets/{managed-agents-CHR4Hx1O.js.map → managed-agents-Bg4aG1a9.js.map} +1 -1
  38. package/public/assets/{markdown-preview-impl-BHaUcnfn.js → markdown-preview-impl-BJHjAo1H.js} +2 -2
  39. package/public/assets/{markdown-preview-impl-BHaUcnfn.js.map → markdown-preview-impl-BJHjAo1H.js.map} +1 -1
  40. package/public/assets/{memory-Uk0VfqnC.js → memory-WwrerNC0.js} +2 -2
  41. package/public/assets/{memory-Uk0VfqnC.js.map → memory-WwrerNC0.js.map} +1 -1
  42. package/public/assets/{messages-D_juCBfr.js → messages-BY6mboBf.js} +2 -2
  43. package/public/assets/{messages-D_juCBfr.js.map → messages-BY6mboBf.js.map} +1 -1
  44. package/public/assets/{orchestrators-8IrmgeHV.js → orchestrators-ClNqQHnw.js} +2 -2
  45. package/public/assets/{orchestrators-8IrmgeHV.js.map → orchestrators-ClNqQHnw.js.map} +1 -1
  46. package/public/assets/{overview-D_DM0QHJ.js → overview-Bk91s8bA.js} +2 -2
  47. package/public/assets/{overview-D_DM0QHJ.js.map → overview-Bk91s8bA.js.map} +1 -1
  48. package/public/assets/{pairs-jciDuPLn.js → pairs-IxkuPIfN.js} +2 -2
  49. package/public/assets/{pairs-jciDuPLn.js.map → pairs-IxkuPIfN.js.map} +1 -1
  50. package/public/assets/{projects-C-QHyuMD.js → projects-D7WJtj7Y.js} +2 -2
  51. package/public/assets/{projects-C-QHyuMD.js.map → projects-D7WJtj7Y.js.map} +1 -1
  52. package/public/assets/{registry-D1EIvc8U.js → registry-DUzVG_Oi.js} +2 -2
  53. package/public/assets/{registry-D1EIvc8U.js.map → registry-DUzVG_Oi.js.map} +1 -1
  54. package/public/assets/{security-C0_Df9yI.js → security-D9ru8DHd.js} +2 -2
  55. package/public/assets/{security-C0_Df9yI.js.map → security-D9ru8DHd.js.map} +1 -1
  56. package/public/assets/{select-CXFXPZ7Z.js → select-Bu-gipoC.js} +2 -2
  57. package/public/assets/{select-CXFXPZ7Z.js.map → select-Bu-gipoC.js.map} +1 -1
  58. package/public/assets/{settings-DGPpdFoq.js → settings-CoOySnqS.js} +2 -2
  59. package/public/assets/{settings-DGPpdFoq.js.map → settings-CoOySnqS.js.map} +1 -1
  60. package/public/assets/{store-d-IbqTaD.js → store-DGM2s9L1.js} +2 -2
  61. package/public/assets/{store-d-IbqTaD.js.map → store-DGM2s9L1.js.map} +1 -1
  62. package/public/assets/{tasks-B_epVa5b.js → tasks-C94dSpvI.js} +2 -2
  63. package/public/assets/{tasks-B_epVa5b.js.map → tasks-C94dSpvI.js.map} +1 -1
  64. package/public/assets/{teams-D2hATpCD.js → teams-Cq7HWQe9.js} +2 -2
  65. package/public/assets/{teams-D2hATpCD.js.map → teams-Cq7HWQe9.js.map} +1 -1
  66. package/public/assets/{terminal-viewer-impl-CefeetWa.js → terminal-viewer-impl-BQr-VK32.js} +2 -2
  67. package/public/assets/{terminal-viewer-impl-CefeetWa.js.map → terminal-viewer-impl-BQr-VK32.js.map} +1 -1
  68. package/public/assets/types-Tv67voFu.js.map +1 -1
  69. package/public/assets/{user-avatar-BOGZ21O8.js → user-avatar-aCd8Fb5v.js} +2 -2
  70. package/public/assets/{user-avatar-BOGZ21O8.js.map → user-avatar-aCd8Fb5v.js.map} +1 -1
  71. package/public/assets/{work-queue-C9wBOivX.js → work-queue-BuyQhedx.js} +2 -2
  72. package/public/assets/{work-queue-C9wBOivX.js.map → work-queue-BuyQhedx.js.map} +1 -1
  73. package/public/assets/{workspaces-DKFmnOF8.js → workspaces-DXTrd2or.js} +2 -2
  74. package/public/assets/{workspaces-DKFmnOF8.js.map → workspaces-DXTrd2or.js.map} +1 -1
  75. package/public/index.html +3 -3
  76. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  77. package/src/config-store.ts +72 -6
  78. package/src/db/migrations.ts +9 -0
  79. package/src/db/project-entries.ts +63 -9
  80. package/src/db/projects.ts +76 -4
  81. package/src/db/schema.ts +8 -0
  82. package/src/mcp/index.ts +4 -3
  83. package/src/mcp-memory-tools.ts +239 -0
  84. package/src/project-instructions.ts +48 -10
  85. package/src/routes/agent-profiles.ts +36 -8
  86. package/src/routes/index.ts +2 -1
  87. package/src/runtime-tokens.ts +3 -0
  88. package/src/security.ts +1 -1
  89. package/src/services/spawn-agent.ts +12 -2
  90. package/public/assets/MessageBubble-CkPnAwxJ.js +0 -3
  91. package/public/assets/MessageBubble-CkPnAwxJ.js.map +0 -1
  92. package/public/assets/agent-profiles-BFpEQHKo.js +0 -2
  93. package/public/assets/agent-profiles-BFpEQHKo.js.map +0 -1
  94. package/public/assets/chat-Dcwua_5V.js +0 -2
  95. package/public/assets/chat-Dcwua_5V.js.map +0 -1
  96. package/public/assets/index-CKPsJqiW.css +0 -2
  97. package/public/assets/index-CwhhpGgX.js.map +0 -1
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { getDb, ValidationError } from "./connection.ts";
3
3
  import { listProjectEntries } from "./project-entries.ts";
4
4
  import { isPathWithinBase } from "../utils";
5
- import { PROJECT_EDGE_KINDS, type Project, type ProjectEdge, type ProjectEdgeKind, type ProjectEntryKind, type ProjectRoot, type ResolvedProjectEntry } from "../types";
5
+ import { PROJECT_EDGE_KINDS, PROJECT_SCOPES, type Project, type ProjectEdge, type ProjectEdgeKind, type ProjectEntryKind, type ProjectRoot, type ProjectScope, type ResolvedProjectEntry } from "../types";
6
6
 
7
7
  // Projects entity CRUD + roots + cwd→project resolution (#405). Templated on
8
8
  // src/db/teams.ts; the durable analog of a team. project_entries CRUD lives in
@@ -15,6 +15,8 @@ interface ProjectRow {
15
15
  description: string | null;
16
16
  tenant_id: string | null;
17
17
  issue_repo: string | null;
18
+ scope: ProjectScope;
19
+ anchor_key: string | null;
18
20
  status: Project["status"];
19
21
  created_at: number;
20
22
  updated_at: number;
@@ -38,6 +40,8 @@ function rowToProject(row: ProjectRow): Project {
38
40
  ...(row.description ? { description: row.description } : {}),
39
41
  ...(row.tenant_id ? { tenantId: row.tenant_id } : {}),
40
42
  ...(row.issue_repo ? { issueRepo: row.issue_repo } : {}),
43
+ scope: row.scope ?? "project",
44
+ ...(row.anchor_key ? { anchorKey: row.anchor_key } : {}),
41
45
  status: row.status,
42
46
  createdAt: row.created_at,
43
47
  updatedAt: row.updated_at,
@@ -63,11 +67,22 @@ export function resolveProject(ref: string): Project | null {
63
67
  return getProject(ref) ?? getProjectBySlug(ref);
64
68
  }
65
69
 
66
- export function listProjects(opts: { includeArchived?: boolean } = {}): Project[] {
67
- const where = opts.includeArchived ? "" : "WHERE status = 'active' ";
70
+ /** List projects. Defaults to the `project` scope only so #403 callers (the project list)
71
+ * never see profile/user anchors; pass an explicit `scope` to list a scoped store, or
72
+ * `scope: "any"` for every scope. */
73
+ export function listProjects(opts: { includeArchived?: boolean; scope?: ProjectScope | "any" } = {}): Project[] {
74
+ const conditions: string[] = [];
75
+ const params: string[] = [];
76
+ if (!opts.includeArchived) conditions.push("status = 'active'");
77
+ const scope = opts.scope ?? "project";
78
+ if (scope !== "any") {
79
+ conditions.push("scope = ?");
80
+ params.push(scope);
81
+ }
82
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")} ` : "";
68
83
  const rows = getDb()
69
84
  .query(`SELECT * FROM projects ${where}ORDER BY created_at ASC, id ASC`)
70
- .all() as ProjectRow[];
85
+ .all(...params) as ProjectRow[];
71
86
  return rows.map(rowToProject);
72
87
  }
73
88
 
@@ -131,6 +146,63 @@ export function ensureProject(input: EnsureProjectInput): Project {
131
146
  return getProject(id)!;
132
147
  }
133
148
 
149
+ // ── Scoped anchors (#559) ───────────────────────────────────────────────────
150
+ // A profile/user knowledge store is a `projects` row with scope!='project' and an
151
+ // anchor_key (the profile name / operator id). The slug carries the scope prefix so it
152
+ // stays globally unique alongside project slugs (`profile:coordinator`, `user:edin`).
153
+ // These are the get-or-create entry points the spawn path and the scoped-memory tools
154
+ // use; entries/edges/cascade then operate on the returned project id, scope-agnostic.
155
+
156
+ const SCOPED_SLUG_PREFIX: Record<Exclude<ProjectScope, "project">, string> = {
157
+ profile: "profile:",
158
+ user: "user:",
159
+ };
160
+
161
+ /** Find a scoped anchor by (scope, anchor_key) — the profile name or operator id. Null
162
+ * when none exists yet (the caller decides whether to create it). */
163
+ export function resolveScopedAnchor(scope: Exclude<ProjectScope, "project">, anchorKey: string): Project | null {
164
+ const key = anchorKey.trim();
165
+ if (!key) return null;
166
+ const row = getDb()
167
+ .query("SELECT * FROM projects WHERE scope = ? AND anchor_key = ? LIMIT 1")
168
+ .get(scope, key) as ProjectRow | undefined;
169
+ return row ? rowToProject(row) : null;
170
+ }
171
+
172
+ /** Get-or-create a scoped anchor (profile/user store). Idempotent on (scope, anchor_key);
173
+ * the create-on-first-write analog of ensureProject for the non-repo scopes. */
174
+ export function ensureScopedAnchor(
175
+ scope: Exclude<ProjectScope, "project">,
176
+ anchorKey: string,
177
+ opts: { title?: string } = {},
178
+ ): Project {
179
+ if (!PROJECT_SCOPES.includes(scope)) throw new ValidationError(`scope must be one of: ${PROJECT_SCOPES.join(", ")}`);
180
+ const key = anchorKey.trim();
181
+ if (!key) throw new ValidationError("anchorKey required");
182
+ const existing = resolveScopedAnchor(scope, key);
183
+ if (existing) return existing;
184
+ const id = projectId();
185
+ const now = Date.now();
186
+ const slug = `${SCOPED_SLUG_PREFIX[scope]}${key}`;
187
+ getDb().query(`
188
+ INSERT INTO projects (id, slug, title, description, tenant_id, issue_repo, scope, anchor_key, status, created_at, updated_at)
189
+ VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?, 'active', ?, ?)
190
+ ON CONFLICT(slug) DO NOTHING
191
+ `).run(id, slug, opts.title ?? null, scope, key, now, now);
192
+ // Re-resolve to absorb a concurrent insert that won the slug race.
193
+ return resolveScopedAnchor(scope, key) ?? getProjectBySlug(slug)!;
194
+ }
195
+
196
+ /** The profile-scoped knowledge store for a profile NAME (role memory). */
197
+ export function ensureProfileAnchor(profileName: string): Project {
198
+ return ensureScopedAnchor("profile", profileName, { title: `Profile: ${profileName.trim()}` });
199
+ }
200
+
201
+ /** The user/operator-scoped knowledge store for an operator identity (cross-project memory). */
202
+ export function ensureUserAnchor(operatorId: string): Project {
203
+ return ensureScopedAnchor("user", operatorId, { title: `Operator: ${operatorId.trim()}` });
204
+ }
205
+
134
206
  /** Set (or clear, with null) a project's `issueRepo` (#533). With `onlyIfNull`, a value is
135
207
  * written only when none is set yet — the mechanism for the git-remote AUTO-DERIVE to fill
136
208
  * a fresh project without ever clobbering an explicit override or a prior derivation. */
package/src/db/schema.ts CHANGED
@@ -239,6 +239,9 @@ export function initDb(path: string = "agent-relay.db"): Database {
239
239
  -- Projects (#403/#405) — the durable knowledge scope. projects/project_roots/project_entries
240
240
  -- re-scope the teams/blackboard_entries pair above (team_id → project_id); project_entries
241
241
  -- adds source/origin_ref so a promoted or ingested entry tracks where it came from.
242
+ -- #559 — scope generalizes the anchor from repo-root to {project|profile|user}.
243
+ -- A profile/user store is a projects row with scope!='project' and anchor_key = the
244
+ -- profile name / operator id. Existing rows default scope='project' (zero regression).
242
245
  CREATE TABLE IF NOT EXISTS projects (
243
246
  id TEXT PRIMARY KEY,
244
247
  slug TEXT NOT NULL UNIQUE,
@@ -246,10 +249,15 @@ export function initDb(path: string = "agent-relay.db"): Database {
246
249
  description TEXT,
247
250
  tenant_id TEXT,
248
251
  issue_repo TEXT,
252
+ scope TEXT NOT NULL DEFAULT 'project',
253
+ anchor_key TEXT,
249
254
  status TEXT NOT NULL DEFAULT 'active',
250
255
  created_at INTEGER NOT NULL,
251
256
  updated_at INTEGER NOT NULL
252
257
  );
258
+ -- idx_projects_scope_anchor is created in applyMigrations() AFTER the scope/anchor_key ALTER —
259
+ -- never inline here (#483/#559: an inline index over a not-yet-migrated column crashes initDb
260
+ -- on an existing DB whose projects table predates the scope columns).
253
261
  CREATE TABLE IF NOT EXISTS project_roots (
254
262
  project_id TEXT NOT NULL,
255
263
  root_path TEXT NOT NULL,
package/src/mcp/index.ts CHANGED
@@ -7,7 +7,7 @@ import { createActivityEvent, ValidationError } from "../db";
7
7
  import { PLAN_TOOLS, relayPlanTool } from "../mcp-plan-tools";
8
8
  import { TEAM_TOOLS, relayTeamTool } from "../mcp-team-tools";
9
9
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "../mcp-blackboard-tools";
10
- import { PROJECT_TOOLS, relayProjectTool } from "../mcp-project-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "../mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "../mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "../mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "../mcp-issue-lifecycle-tools";
10
+ import { PROJECT_TOOLS, relayProjectTool } from "../mcp-project-tools"; import { MEMORY_TOOLS, relayMemoryTool } from "../mcp-memory-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "../mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "../mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "../mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "../mcp-issue-lifecycle-tools";
11
11
  import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "../mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "../mcp-merge-tools";
12
12
  import { NTFY_TOOLS, relayNotifyNtfy } from "../mcp-ntfy-tools";
13
13
  import { AGENT_ACTION_TOOL, relayAgentAction } from "../mcp-agent-action"; import { providerCatalogToolInputSchema } from "../mcp-provider-catalog";
@@ -35,6 +35,7 @@ const TOOLS: ToolDefinition[] = [
35
35
  ...PLAN_TOOLS, ...ISSUE_REF_TOOLS, ...ISSUE_ASSOCIATION_TOOLS, ...ISSUE_ACTIVITY_TOOLS, ...ISSUE_LIFECYCLE_TOOLS,
36
36
  ...BLACKBOARD_TOOLS,
37
37
  ...PROJECT_TOOLS,
38
+ ...MEMORY_TOOLS,
38
39
  ...NOTIFICATION_SUBSCRIPTION_TOOLS,
39
40
  ...NTFY_TOOLS,
40
41
  ];
@@ -44,7 +45,7 @@ const TOOLS: ToolDefinition[] = [
44
45
  // top of scope-gating — scope enforcement at tools/call stays authoritative.
45
46
  export const RELAY_TOOL_GROUPS: Record<string, readonly string[]> = {
46
47
  core: ["relay_whoami", "relay_send_message", "relay_reply", "relay_get_message", "relay_get_thread", "relay_find_agents"],
47
- memory: ["relay_recall", "relay_compact_and_resume"],
48
+ memory: ["relay_recall", "relay_compact_and_resume", "relay_memory_write", "relay_memory_promote", "relay_memory_retract", "relay_memory_read", "relay_memory_search"],
48
49
  artifacts: ["relay_upload_artifact", "relay_attach_artifact"],
49
50
  workspace: ["relay_workspace_status", "relay_workspace_ready", "relay_workspace_deps", "relay_workspace_list", "relay_workspace_claim", "relay_workspace_release", "relay_workspace_land", "relay_merge_pause", "relay_merge_pauses", "relay_merge_renew", "relay_merge_resume"],
50
51
  "spawn-lifecycle": ["relay_spawn_agent", "relay_spawn_targets", "relay_my_agents", "relay_shutdown_agent", "relay_agent_action", "relay_agent_status", "relay_agent_crash_report", "relay_schedule_timer", "relay_list_timers", "relay_cancel_timer"],
@@ -166,7 +167,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
166
167
  else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
167
168
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
168
169
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
169
- else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
170
+ else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_memory_")) result = relayMemoryTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
170
171
  else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions" || name === "relay_why_notified" || name === "relay_notification_preview") result = relayNotificationSubscriptionTool(auth, name, args);
171
172
  else if (name === "relay_notify_ntfy") result = await relayNotifyNtfy(args);
172
173
  else throw new ValidationError(`unknown tool: ${name}`);
@@ -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 SEAM ──────────────────────────────────────────────────────────────────────────
185
- * Scoped {profile|user} memory does not exist yet (#559 generalizes Projects' anchor to
186
- * project|profile|user). When it lands, a profile/user-scoped preamble composes in RIGHT
187
- * HERE, beside the project preamble same `composeProjectInstructions`-shaped renderer over
188
- * the {profile|user} scope union, slotted into `appendBlocks` before the profile system
189
- * prompt. The runner reports the contributing scopes via AssembledInstructions.scopedKnowledge.
190
- * Until #559 lands, the only knowledge scope is the project preamble below — a thin add, not
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 seam] profile/user-scoped knowledge preamble composes in here.
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 profile = setAgentProfile({ ...parsed.body, name } as AgentProfile, updatedBy);
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 || existing.value.builtIn) return error(existing ? "built-in agent profiles are managed by Relay" : "agent profile not found", existing ? 400 : 404);
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
- deleteAgentProfile(name, updatedBy);
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) {
@@ -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),
@@ -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;