negotium 0.1.37 → 0.1.39

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 (64) hide show
  1. package/README.md +19 -0
  2. package/dist/agent-helpers.js +15637 -5674
  3. package/dist/agent-helpers.js.map +85 -37
  4. package/dist/browser-runtime.js +1600 -0
  5. package/dist/browser-runtime.js.map +28 -0
  6. package/dist/hosted-agent.js +8 -8
  7. package/dist/hosted-agent.js.map +6 -5
  8. package/dist/main.js +3790 -3113
  9. package/dist/main.js.map +33 -29
  10. package/dist/mcp-factories.js +15141 -301
  11. package/dist/mcp-factories.js.map +100 -7
  12. package/dist/prompts.js +117 -49
  13. package/dist/prompts.js.map +3 -3
  14. package/dist/query-runtime.js +310 -1
  15. package/dist/query-runtime.js.map +10 -5
  16. package/dist/runtime/src/agents/archiver.ts +407 -296
  17. package/dist/runtime/src/agents/mcp-tools/ask-user.ts +440 -274
  18. package/dist/runtime/src/agents/mcp-tools/self-config.ts +139 -65
  19. package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +1037 -816
  20. package/dist/runtime/src/agents/public-helpers.ts +82 -0
  21. package/dist/runtime/src/agents/self-config-core.ts +574 -278
  22. package/dist/runtime/src/agents/topic-cleanup.ts +167 -129
  23. package/dist/runtime/src/index.ts +4 -1
  24. package/dist/runtime/src/mcp/factories/index.ts +9 -0
  25. package/dist/runtime/src/mcp/session-comm/topic-catalog.ts +132 -0
  26. package/dist/runtime/src/mcp/session-comm/topics.ts +23 -57
  27. package/dist/runtime/src/platform/mcp-config.ts +2 -4
  28. package/dist/runtime/src/platform/playwright/browser-processes.ts +3 -2
  29. package/dist/runtime/src/platform/playwright/capability.ts +5 -0
  30. package/dist/runtime/src/platform/playwright/manager.ts +380 -66
  31. package/dist/runtime/src/platform/playwright/public-runtime.ts +45 -0
  32. package/dist/runtime/src/platform/playwright/transport-probe.ts +89 -0
  33. package/dist/runtime/src/prompts/builders.ts +192 -51
  34. package/dist/runtime/src/query/active-rooms.ts +2 -0
  35. package/dist/runtime/src/query/public-runtime.ts +8 -0
  36. package/dist/runtime/src/query/state.ts +69 -33
  37. package/dist/runtime/src/query/types.ts +1 -0
  38. package/dist/runtime/src/runtime/playwright-turn-abort.ts +59 -0
  39. package/dist/runtime/src/runtime/turn-event-stream.ts +23 -6
  40. package/dist/runtime/src/runtime/turn-runner.ts +6 -1
  41. package/dist/runtime/src/version.ts +1 -1
  42. package/dist/types/packages/core/src/agents/api-topic-agent-switch.d.ts +38 -0
  43. package/dist/types/packages/core/src/agents/archiver.d.ts +52 -16
  44. package/dist/types/packages/core/src/agents/mcp-tools/ask-user.d.ts +75 -4
  45. package/dist/types/packages/core/src/agents/mcp-tools/self-config.d.ts +17 -0
  46. package/dist/types/packages/core/src/agents/mcp-tools/spawn-subagent.d.ts +112 -25
  47. package/dist/types/packages/core/src/agents/public-helpers.d.ts +7 -0
  48. package/dist/types/packages/core/src/agents/self-config-core.d.ts +185 -0
  49. package/dist/types/packages/core/src/agents/topic-cleanup.d.ts +30 -24
  50. package/dist/types/packages/core/src/mcp/factories/index.d.ts +1 -0
  51. package/dist/types/packages/core/src/mcp/session-comm/topic-catalog.d.ts +46 -0
  52. package/dist/types/packages/core/src/platform/mcp-config.d.ts +2 -1
  53. package/dist/types/packages/core/src/platform/playwright/capability.d.ts +1 -0
  54. package/dist/types/packages/core/src/platform/playwright/manager.d.ts +65 -5
  55. package/dist/types/packages/core/src/platform/playwright/public-runtime.d.ts +4 -0
  56. package/dist/types/packages/core/src/platform/playwright/transport-probe.d.ts +13 -0
  57. package/dist/types/packages/core/src/prompts/builders.d.ts +29 -3
  58. package/dist/types/packages/core/src/query/active-rooms.d.ts +2 -0
  59. package/dist/types/packages/core/src/query/public-runtime.d.ts +1 -0
  60. package/dist/types/packages/core/src/query/state.d.ts +13 -0
  61. package/dist/types/packages/core/src/query/types.d.ts +2 -1
  62. package/dist/types/packages/core/src/runtime/playwright-turn-abort.d.ts +12 -0
  63. package/dist/types/packages/core/src/version.d.ts +1 -1
  64. package/package.json +6 -1
@@ -12,20 +12,15 @@ import {
12
12
  getPendingSelfSchedule,
13
13
  updatePendingSelfSchedule,
14
14
  } from "#storage/self-schedules";
15
- import {
16
- createDerivedTopic,
17
- TopicForkCompactionError,
18
- TopicTitleConflictError,
19
- } from "#topics/derive";
20
15
  import { topicMarkdownLink } from "#topics/links";
21
16
  import type { AgentKind, EffortLevel } from "#types";
22
- import type { TopicDto } from "#types/api";
23
17
 
24
18
  export const SELF_CONFIG_MCP_KEY = "topic-config";
25
-
26
- export type SelfConfigField = "agent" | "model" | "effort";
27
19
  export const SELF_SCHEDULE_MAX_DELAY_SECONDS = 86_400;
28
20
  export const SELF_SCHEDULE_MAX_MESSAGE_LENGTH = 10_000;
21
+ export const SELF_CONFIG_DERIVED_TOPIC_LIMIT = 5;
22
+
23
+ export type SelfConfigField = "agent" | "model" | "effort";
29
24
 
30
25
  export interface SelfConfigContext {
31
26
  topicId: string;
@@ -42,42 +37,186 @@ export interface SelfConfigResult {
42
37
  isError?: boolean;
43
38
  }
44
39
 
45
- function ok(text: string): SelfConfigResult {
46
- return { text };
40
+ export interface SelfConfigTopic {
41
+ id: string;
42
+ title: string;
43
+ agent?: AgentKind;
44
+ defaultModel: string;
45
+ defaultEffort: EffortLevel;
46
+ participants: readonly { userId: string }[];
47
47
  }
48
48
 
49
- function err(text: string): SelfConfigResult {
50
- return { text, isError: true };
49
+ export interface SelfConfigTopicConfig {
50
+ model?: string;
51
+ effort?: EffortLevel;
52
+ mcp?: string[];
53
+ agentLocked?: boolean;
54
+ modelLocked?: boolean;
55
+ effortLocked?: boolean;
51
56
  }
52
57
 
53
- function requireAccessibleTopic(ctx: SelfConfigContext): TopicDto | SelfConfigResult {
54
- if (!ctx.topicId || !ctx.userId) return err("Error: missing topicId/userId context.");
55
- const topic = getTopic(ctx.topicId);
56
- if (!topic) return err(`Error: topic '${ctx.topicId}' not found.`);
57
- if (!topic.participants.some((p) => p.userId === ctx.userId)) {
58
- return err("Error: user is not a member of this topic.");
59
- }
60
- return topic;
58
+ export interface SelfConfigAgentPolicy {
59
+ defaultEffort?: EffortLevel;
60
+ validEfforts: readonly EffortLevel[];
61
+ validateModel(model: string): boolean;
62
+ validateEffort(effort: EffortLevel): boolean;
63
+ resolveModel(model: string | undefined): string;
64
+ }
65
+
66
+ export interface SelfConfigTopicStore {
67
+ getTopic(topicId: string): SelfConfigTopic | null;
68
+ getConfig(topicId: string): SelfConfigTopicConfig | undefined;
69
+ setConfig(topicId: string, config: SelfConfigTopicConfig): void;
70
+ }
71
+
72
+ export interface SelfConfigModelPolicy {
73
+ forAgent(agent: AgentKind): SelfConfigAgentPolicy;
74
+ owner(model: string): AgentKind | undefined;
75
+ checkAuth(
76
+ agent: AgentKind,
77
+ model: string,
78
+ userId: string,
79
+ ): { ok: true } | { ok: false; error: string };
61
80
  }
62
81
 
63
- function requireTopic(ctx: SelfConfigContext): TopicDto | SelfConfigResult {
64
- const topic = requireAccessibleTopic(ctx);
65
- if (isResult(topic)) return topic;
66
- if (!topic.agent) return err("Error: this topic has no AI agent invited.");
67
- return topic;
82
+ export interface SelfConfigRuntimeBoundary {
83
+ resolveWorkspaceDir(topicId: string): string;
84
+ switchAgent(options: SelfConfigAgentSwitchOptions): SelfConfigAgentSwitchResult;
85
+ configChanged(topicId: string, field: SelfConfigField): void;
68
86
  }
69
87
 
70
- function isResult(value: TopicDto | SelfConfigResult): value is SelfConfigResult {
71
- return "text" in value;
88
+ export type SelfConfigAgentSwitchResult =
89
+ | {
90
+ ok: true;
91
+ outcome:
92
+ | { kind: "fresh"; agent: AgentKind; reason: "no-history" | "bridge-failed" }
93
+ | {
94
+ kind: "bridged";
95
+ agent: AgentKind;
96
+ bridgedSessionId: string;
97
+ rolloutPath: string;
98
+ };
99
+ }
100
+ | { ok: false; error: string };
101
+
102
+ export interface SelfConfigAgentSwitchOptions {
103
+ topicId: string;
104
+ topicTitle: string;
105
+ userId: string;
106
+ fromAgent?: AgentKind;
107
+ agent: AgentKind;
108
+ cwd: string;
109
+ config: SelfConfigTopicConfig;
110
+ defaultModel?: string;
111
+ defaultEffort?: EffortLevel;
112
+ reason: string;
113
+ }
114
+
115
+ export interface SelfConfigDerivedTopics {
116
+ create(
117
+ sourceTopicId: string,
118
+ userId: string,
119
+ copyHistory: boolean,
120
+ options: { name?: string },
121
+ ): Promise<SelfConfigTopic | null>;
122
+ link(topicId: string): string;
123
+ isTitleConflict(error: unknown): boolean;
124
+ isForkCompactionError(error: unknown): boolean;
125
+ }
126
+
127
+ export interface SelfConfigSchedules {
128
+ create(input: {
129
+ topicId: string;
130
+ userId: string;
131
+ message: string;
132
+ deliverAt: number;
133
+ now?: number;
134
+ }): SelfConfigCreateScheduleResult;
135
+ getPending(topicId: string): SelfConfigSchedule | null;
136
+ update(input: {
137
+ topicId: string;
138
+ scheduleId: string;
139
+ message?: string;
140
+ deliverAt?: number;
141
+ now?: number;
142
+ }): SelfConfigSchedule | null;
143
+ cancel(topicId: string, scheduleId: string): boolean;
144
+ }
145
+
146
+ export interface SelfConfigSchedule {
147
+ id: string;
148
+ topicId: string;
149
+ userId: string;
150
+ message: string;
151
+ deliverAt: number;
152
+ }
153
+
154
+ export type SelfConfigCreateScheduleResult =
155
+ | { ok: true; schedule: SelfConfigSchedule }
156
+ | { ok: false; existing: SelfConfigSchedule };
157
+
158
+ /** Product-level limits and copy that are shared by policy and MCP wrappers. */
159
+ export interface SelfConfigProductConfig {
160
+ readonly mcpKey: string;
161
+ readonly scheduleMaxDelaySeconds: number;
162
+ readonly scheduleMaxMessageLength: number;
163
+ readonly derivedTopicLimit: number;
164
+ readonly toolDescriptions: Readonly<Record<string, string>>;
165
+ }
166
+
167
+ export interface SelfConfigHost {
168
+ topics: SelfConfigTopicStore;
169
+ models: SelfConfigModelPolicy;
170
+ runtime: SelfConfigRuntimeBoundary;
171
+ derivedTopics?: SelfConfigDerivedTopics;
172
+ schedules?: SelfConfigSchedules;
173
+ }
174
+
175
+ export interface SelfConfigCore {
176
+ readonly host: SelfConfigHost;
177
+ readonly product: SelfConfigProductConfig;
178
+ setModel(ctx: SelfConfigContext, model: string): SelfConfigResult;
179
+ getModel(ctx: SelfConfigContext): SelfConfigResult;
180
+ setAgent(ctx: SelfConfigContext, agent: AgentKind): SelfConfigResult;
181
+ getAgent(ctx: SelfConfigContext): SelfConfigResult;
182
+ setEffort(ctx: SelfConfigContext, effort: EffortLevel): SelfConfigResult;
183
+ getEffort(ctx: SelfConfigContext): SelfConfigResult;
184
+ scheduleContinue(
185
+ ctx: SelfConfigContext,
186
+ delaySeconds: number,
187
+ message: string,
188
+ nowMs?: number,
189
+ ): SelfConfigResult;
190
+ getSchedule(ctx: SelfConfigContext, nowMs?: number): SelfConfigResult;
191
+ updateSchedule(
192
+ ctx: SelfConfigContext,
193
+ scheduleId: string,
194
+ updates: { delaySeconds?: number; message?: string },
195
+ nowMs?: number,
196
+ ): SelfConfigResult;
197
+ cancelSchedule(ctx: SelfConfigContext, scheduleId: string): SelfConfigResult;
198
+ spawnTopic(ctx: SelfConfigContext, name?: string): Promise<SelfConfigResult>;
199
+ forkTopic(ctx: SelfConfigContext, name?: string): Promise<SelfConfigResult>;
200
+ }
201
+
202
+ export const DEFAULT_SELF_CONFIG_PRODUCT: SelfConfigProductConfig = {
203
+ mcpKey: SELF_CONFIG_MCP_KEY,
204
+ scheduleMaxDelaySeconds: SELF_SCHEDULE_MAX_DELAY_SECONDS,
205
+ scheduleMaxMessageLength: SELF_SCHEDULE_MAX_MESSAGE_LENGTH,
206
+ derivedTopicLimit: SELF_CONFIG_DERIVED_TOPIC_LIMIT,
207
+ toolDescriptions: {},
208
+ };
209
+
210
+ function ok(text: string): SelfConfigResult {
211
+ return { text };
72
212
  }
73
213
 
74
- function currentAgent(topic: TopicDto): AgentKind {
75
- return (topic.agent ?? "maestro") as AgentKind;
214
+ function err(text: string): SelfConfigResult {
215
+ return { text, isError: true };
76
216
  }
77
217
 
78
- function effectiveDefaultModel(topic: TopicDto, agent: AgentKind): string {
79
- const registry = getRegistry(agent);
80
- return resolveModelForAgent(agent, topic.defaultModel, registry);
218
+ function errorMessage(error: unknown): string {
219
+ return error instanceof Error ? error.message : String(error);
81
220
  }
82
221
 
83
222
  function escapeRegExp(value: string): string {
@@ -111,8 +250,7 @@ function agentAliases(agent: AgentKind): string[] {
111
250
  function hasExplicitAgentSwitchRequest(prompt: string | undefined, agent: AgentKind): boolean {
112
251
  if (!prompt?.trim()) return false;
113
252
  const text = prompt.toLowerCase().replace(/\s+/g, " ").trim();
114
- const aliases = agentAliases(agent).map(escapeRegExp);
115
- const target = `(?:${aliases.join("|")})`;
253
+ const target = `(?:${agentAliases(agent).map(escapeRegExp).join("|")})`;
116
254
  const switchVerb =
117
255
  "(?:바꿔|바꿔줘|변경|변경해|전환|전환해|설정|설정해|써줘|사용|가|switch|change|set|use)";
118
256
  const switchSubject = "(?:agent|runtime|model|에이전트|런타임|모델)";
@@ -126,205 +264,435 @@ function hasExplicitAgentSwitchRequest(prompt: string | undefined, agent: AgentK
126
264
  ].some((pattern) => pattern.test(text));
127
265
  }
128
266
 
129
- export function setSelfConfigModel(ctx: SelfConfigContext, model: string): SelfConfigResult {
130
- const topic = requireTopic(ctx);
131
- if (isResult(topic)) return topic;
132
-
133
- const cfg = getApiTopicConfig(topic.id) ?? {};
134
- if (cfg.modelLocked) return err("Model for this topic is locked by the user. Cannot override.");
135
-
136
- const agent = currentAgent(topic);
137
- const registry = getRegistry(agent);
138
- const owner = modelOwner(model);
139
- if ((owner && owner !== agent) || !registry.validateModel(model)) {
140
- return err(
141
- `'${model}' is not a valid model for agent '${agent}'. If it belongs to another agent, call set_agent first.`,
267
+ export function createSelfConfigCore(
268
+ host: SelfConfigHost,
269
+ productOverrides: Partial<SelfConfigProductConfig> = {},
270
+ ): SelfConfigCore {
271
+ const product: SelfConfigProductConfig = Object.freeze({
272
+ ...DEFAULT_SELF_CONFIG_PRODUCT,
273
+ ...productOverrides,
274
+ toolDescriptions: Object.freeze({
275
+ ...DEFAULT_SELF_CONFIG_PRODUCT.toolDescriptions,
276
+ ...productOverrides.toolDescriptions,
277
+ }),
278
+ });
279
+ for (const [name, value] of [
280
+ ["scheduleMaxDelaySeconds", product.scheduleMaxDelaySeconds],
281
+ ["scheduleMaxMessageLength", product.scheduleMaxMessageLength],
282
+ ["derivedTopicLimit", product.derivedTopicLimit],
283
+ ] as const) {
284
+ if (!Number.isInteger(value) || value < 1) {
285
+ throw new Error(`self-config product ${name} must be a positive integer`);
286
+ }
287
+ }
288
+
289
+ function requireAccessibleTopic(ctx: SelfConfigContext): SelfConfigTopic | SelfConfigResult {
290
+ if (!ctx.topicId || !ctx.userId) return err("Error: missing topicId/userId context.");
291
+ const topic = host.topics.getTopic(ctx.topicId);
292
+ if (!topic) return err(`Error: topic '${ctx.topicId}' not found.`);
293
+ if (!topic.participants.some((participant) => participant.userId === ctx.userId)) {
294
+ return err("Error: user is not a member of this topic.");
295
+ }
296
+ return topic;
297
+ }
298
+
299
+ function requireTopic(ctx: SelfConfigContext): SelfConfigTopic | SelfConfigResult {
300
+ const topic = requireAccessibleTopic(ctx);
301
+ if ("text" in topic) return topic;
302
+ if (!topic.agent) return err("Error: this topic has no AI agent invited.");
303
+ return topic;
304
+ }
305
+
306
+ function currentAgent(topic: SelfConfigTopic): AgentKind {
307
+ return topic.agent ?? "maestro";
308
+ }
309
+
310
+ function setModel(ctx: SelfConfigContext, model: string): SelfConfigResult {
311
+ const topic = requireTopic(ctx);
312
+ if ("text" in topic) return topic;
313
+
314
+ const config = host.topics.getConfig(topic.id) ?? {};
315
+ if (config.modelLocked) {
316
+ return err("Model for this topic is locked by the user. Cannot override.");
317
+ }
318
+
319
+ const agent = currentAgent(topic);
320
+ const policy = host.models.forAgent(agent);
321
+ const owner = host.models.owner(model);
322
+ if ((owner && owner !== agent) || !policy.validateModel(model)) {
323
+ return err(
324
+ `'${model}' is not a valid model for agent '${agent}'. If it belongs to another agent, call set_agent first.`,
325
+ );
326
+ }
327
+ const resolvedModel = policy.resolveModel(model);
328
+ const auth = host.models.checkAuth(agent, resolvedModel, ctx.userId);
329
+ if (!auth.ok) return err(auth.error);
330
+
331
+ host.topics.setConfig(topic.id, { ...config, model: resolvedModel });
332
+ host.runtime.configChanged(topic.id, "model");
333
+ ctx.onConfigChanged?.("model");
334
+ return ok(
335
+ `Model for this topic set to '${resolvedModel}' (agent=${agent}). Applies from the next turn.`,
142
336
  );
143
337
  }
144
- const resolvedModel = resolveModelForAgent(agent, model, registry);
145
- const auth = checkAgentModelAuth(agent, resolvedModel, undefined, ctx.userId);
146
- if (!auth.ok) return err(auth.error);
147
338
 
148
- setApiTopicConfig(topic.id, { ...cfg, model: resolvedModel });
149
- // Keep the provider conversation. Codex and Claude both support changing
150
- // the model for a later turn while resuming the same thread/session.
151
- WsHub.get().broadcastTopicUpdated(topic.id);
152
- ctx.onConfigChanged?.("model");
153
- return ok(
154
- `Model for this topic set to '${resolvedModel}' (agent=${agent}). Applies from the next turn.`,
155
- );
156
- }
339
+ function getModel(ctx: SelfConfigContext): SelfConfigResult {
340
+ const topic = requireTopic(ctx);
341
+ if ("text" in topic) return topic;
342
+
343
+ const config = host.topics.getConfig(topic.id);
344
+ const agent = currentAgent(topic);
345
+ const policy = host.models.forAgent(agent);
346
+ const fallback = policy.resolveModel(topic.defaultModel);
347
+ const resolved = config?.model ? policy.resolveModel(config.model) : fallback;
348
+ const value =
349
+ config?.model && resolved === config.model ? config.model : `default (${fallback})`;
350
+ const lock = config?.modelLocked ? " [locked by user]" : "";
351
+ return ok(`Model (agent=${agent}): ${value}${lock}`);
352
+ }
157
353
 
158
- export function getSelfConfigModel(ctx: SelfConfigContext): SelfConfigResult {
159
- const topic = requireTopic(ctx);
160
- if (isResult(topic)) return topic;
354
+ function setAgent(ctx: SelfConfigContext, agent: AgentKind): SelfConfigResult {
355
+ const topic = requireTopic(ctx);
356
+ if ("text" in topic) return topic;
161
357
 
162
- const cfg = getApiTopicConfig(topic.id);
163
- const agent = currentAgent(topic);
164
- const registry = getRegistry(agent);
165
- const fallback = effectiveDefaultModel(topic, agent);
166
- const resolved = cfg?.model ? resolveModelForAgent(agent, cfg.model, registry) : fallback;
167
- const value = cfg?.model && resolved === cfg.model ? cfg.model : `default (${fallback})`;
168
- const lock = cfg?.modelLocked ? " [locked by user]" : "";
169
- return ok(`Model (agent=${agent}): ${value}${lock}`);
170
- }
358
+ const config = host.topics.getConfig(topic.id) ?? {};
359
+ const existing = currentAgent(topic);
360
+ if (existing === agent) return ok(`Agent is already '${agent}'. No change.`);
361
+ if (config.agentLocked) {
362
+ return err("Agent for this topic is locked by the user. Cannot override.");
363
+ }
364
+ if (!hasExplicitAgentSwitchRequest(ctx.currentUserPrompt, agent)) {
365
+ return err(
366
+ `Agent switch to '${agent}' requires an explicit request in the current user message.`,
367
+ );
368
+ }
171
369
 
172
- export function setSelfConfigAgent(ctx: SelfConfigContext, agent: AgentKind): SelfConfigResult {
173
- const topic = requireTopic(ctx);
174
- if (isResult(topic)) return topic;
175
-
176
- const cfg = getApiTopicConfig(topic.id) ?? {};
177
- // Agent is a topic property; the same path is used by UI and MCP.
178
-
179
- const existing = currentAgent(topic);
180
- if (existing === agent) return ok(`Agent is already '${agent}'. No change.`);
181
- if (cfg.agentLocked) return err("Agent for this topic is locked by the user. Cannot override.");
182
- if (!hasExplicitAgentSwitchRequest(ctx.currentUserPrompt, agent)) {
183
- return err(
184
- `Agent switch to '${agent}' requires an explicit request in the current user message.`,
370
+ const switched = host.runtime.switchAgent({
371
+ topicId: topic.id,
372
+ topicTitle: topic.title,
373
+ userId: ctx.userId,
374
+ fromAgent: existing,
375
+ agent,
376
+ cwd: ctx.cwd ?? host.runtime.resolveWorkspaceDir(topic.id),
377
+ config: { ...config, model: undefined, effort: undefined },
378
+ defaultModel: topic.defaultModel,
379
+ defaultEffort: topic.defaultEffort,
380
+ reason: "self-config-agent-switch",
381
+ });
382
+ if (!switched.ok) return err(switched.error);
383
+
384
+ host.runtime.configChanged(topic.id, "agent");
385
+ ctx.onConfigChanged?.("agent");
386
+ const sessionNote =
387
+ switched.outcome.kind === "bridged"
388
+ ? ` Conversation history was bridged into a ${agent} session (${switched.outcome.bridgedSessionId}).`
389
+ : switched.outcome.reason === "bridge-failed"
390
+ ? " Conversation history bridge failed; the next turn starts fresh."
391
+ : " No prior conversation history was found; the next turn starts fresh.";
392
+ return ok(
393
+ `Agent for this topic set to '${agent}'. Model/effort reset to '${agent}' defaults.${sessionNote}`,
185
394
  );
186
395
  }
187
396
 
188
- // Switching agents invalidates provider-native session ids and per-agent
189
- // model/effort overrides. The helper mirrors Otium's switchTopicAgent:
190
- // write rollout, manifest the synthetic session in the unified log, then
191
- // commit config + durable session.
192
- const switched = switchApiTopicAgent({
193
- topicId: topic.id,
194
- topicTitle: topic.title,
195
- userId: ctx.userId,
196
- fromAgent: existing,
197
- agent,
198
- cwd: ctx.cwd ?? resolveTopicWorkspaceDir(topic.id),
199
- config: { ...cfg, model: undefined, effort: undefined },
200
- defaultModel: topic.defaultModel,
201
- defaultEffort: topic.defaultEffort,
202
- reason: "self-config-agent-switch",
203
- });
204
- if (!switched.ok) return err(switched.error);
397
+ function getAgent(ctx: SelfConfigContext): SelfConfigResult {
398
+ const topic = requireTopic(ctx);
399
+ if ("text" in topic) return topic;
400
+ return ok(`Agent: ${currentAgent(topic)}`);
401
+ }
205
402
 
206
- WsHub.get().broadcastTopicUpdated(topic.id);
207
- ctx.onConfigChanged?.("agent");
208
- const sessionNote =
209
- switched.outcome.kind === "bridged"
210
- ? ` Conversation history was bridged into a ${agent} session (${switched.outcome.bridgedSessionId}).`
211
- : switched.outcome.reason === "bridge-failed"
212
- ? " Conversation history bridge failed; the next turn starts fresh."
213
- : " No prior conversation history was found; the next turn starts fresh.";
214
- return ok(
215
- `Agent for this topic set to '${agent}'. Model/effort reset to '${agent}' defaults.${sessionNote}`,
216
- );
217
- }
403
+ function setEffort(ctx: SelfConfigContext, effort: EffortLevel): SelfConfigResult {
404
+ const topic = requireTopic(ctx);
405
+ if ("text" in topic) return topic;
218
406
 
219
- export function getSelfConfigAgent(ctx: SelfConfigContext): SelfConfigResult {
220
- const topic = requireTopic(ctx);
221
- if (isResult(topic)) return topic;
407
+ const config = host.topics.getConfig(topic.id) ?? {};
408
+ if (config.effortLocked) {
409
+ return err("Effort for this topic is locked by the user. Cannot override.");
410
+ }
411
+ const agent = currentAgent(topic);
412
+ const policy = host.models.forAgent(agent);
413
+ if (!policy.validateEffort(effort)) {
414
+ return err(
415
+ `'${effort}' is not a valid effort for agent '${agent}'. Valid: ${policy.validEfforts.join(", ")}.`,
416
+ );
417
+ }
222
418
 
223
- const lock = "";
224
- return ok(`Agent: ${currentAgent(topic)}${lock}`);
225
- }
419
+ host.topics.setConfig(topic.id, { ...config, effort });
420
+ host.runtime.configChanged(topic.id, "effort");
421
+ ctx.onConfigChanged?.("effort");
422
+ return ok(
423
+ `Effort for this topic set to '${effort}' (agent=${agent}). Applies from the next turn.`,
424
+ );
425
+ }
226
426
 
227
- export function setSelfConfigEffort(ctx: SelfConfigContext, effort: EffortLevel): SelfConfigResult {
228
- const topic = requireTopic(ctx);
229
- if (isResult(topic)) return topic;
427
+ function getEffort(ctx: SelfConfigContext): SelfConfigResult {
428
+ const topic = requireTopic(ctx);
429
+ if ("text" in topic) return topic;
430
+
431
+ const config = host.topics.getConfig(topic.id);
432
+ const agent = currentAgent(topic);
433
+ const policy = host.models.forAgent(agent);
434
+ const fallback = policy.defaultEffort ? `default (${policy.defaultEffort})` : "default (off)";
435
+ const value = config?.effort ?? fallback;
436
+ const lock = config?.effortLocked ? " [locked by user]" : "";
437
+ return ok(`Effort (agent=${agent}): ${value}${lock}`);
438
+ }
439
+
440
+ function unavailable(capability: string): SelfConfigResult {
441
+ return err(`${capability} is not available in this host.`);
442
+ }
443
+
444
+ function scheduleContinue(
445
+ ctx: SelfConfigContext,
446
+ delaySeconds: number,
447
+ message: string,
448
+ nowMs = Date.now(),
449
+ ): SelfConfigResult {
450
+ if (!host.schedules) return unavailable("Self-scheduling");
451
+ const topic = requireTopic(ctx);
452
+ if ("text" in topic) return topic;
453
+ if (
454
+ !Number.isInteger(delaySeconds) ||
455
+ delaySeconds < 1 ||
456
+ delaySeconds > product.scheduleMaxDelaySeconds
457
+ ) {
458
+ return err(`delay_seconds must be an integer from 1 to ${product.scheduleMaxDelaySeconds}.`);
459
+ }
460
+ const cleanMessage = message.trim();
461
+ if (!cleanMessage) return err("message is required.");
462
+ if (cleanMessage.length > product.scheduleMaxMessageLength) {
463
+ return err(`message must be ${product.scheduleMaxMessageLength} characters or fewer.`);
464
+ }
230
465
 
231
- const cfg = getApiTopicConfig(topic.id) ?? {};
232
- if (cfg.effortLocked) return err("Effort for this topic is locked by the user. Cannot override.");
466
+ const deliverAtMs = nowMs + delaySeconds * 1000;
467
+ const created = host.schedules.create({
468
+ topicId: topic.id,
469
+ userId: ctx.userId,
470
+ message: cleanMessage,
471
+ deliverAt: deliverAtMs,
472
+ now: nowMs,
473
+ });
474
+ if (!created.ok) {
475
+ return err(
476
+ `This topic already has a pending self-schedule (${created.existing.id}) for ` +
477
+ `${new Date(created.existing.deliverAt).toISOString()}. ` +
478
+ "Use update_self_schedule or cancel_self_schedule instead of creating another one.",
479
+ );
480
+ }
481
+ return ok(
482
+ `Scheduled this topic to resume in ${delaySeconds} seconds at ${new Date(deliverAtMs).toISOString()}. ` +
483
+ `Schedule ID: ${created.schedule.id}. ` +
484
+ `The continuation is durable across node restarts (delivery granularity is about 5 seconds).`,
485
+ );
486
+ }
233
487
 
234
- const agent = currentAgent(topic);
235
- const registry = getRegistry(agent);
236
- if (!registry.validateEffort(effort)) {
237
- return err(
238
- `'${effort}' is not a valid effort for agent '${agent}'. Valid: ${registry.validEfforts.join(", ")}.`,
488
+ function getSchedule(ctx: SelfConfigContext, nowMs = Date.now()): SelfConfigResult {
489
+ if (!host.schedules) return unavailable("Self-scheduling");
490
+ const topic = requireAccessibleTopic(ctx);
491
+ if ("text" in topic) return topic;
492
+ const schedule = host.schedules.getPending(topic.id);
493
+ if (!schedule) return ok("This topic has no pending self-schedule.");
494
+ const remainingSeconds = Math.max(0, Math.ceil((schedule.deliverAt - nowMs) / 1000));
495
+ return ok(
496
+ [
497
+ `Pending self-schedule: ${schedule.id}`,
498
+ `Deliver at: ${new Date(schedule.deliverAt).toISOString()} (in about ${remainingSeconds} seconds)`,
499
+ `Message: ${schedule.message}`,
500
+ ].join("\n"),
239
501
  );
240
502
  }
241
503
 
242
- setApiTopicConfig(topic.id, { ...cfg, effort });
243
- WsHub.get().broadcastTopicUpdated(topic.id);
244
- ctx.onConfigChanged?.("effort");
245
- return ok(
246
- `Effort for this topic set to '${effort}' (agent=${agent}). Applies from the next turn.`,
247
- );
504
+ function updateSchedule(
505
+ ctx: SelfConfigContext,
506
+ scheduleId: string,
507
+ updates: { delaySeconds?: number; message?: string },
508
+ nowMs = Date.now(),
509
+ ): SelfConfigResult {
510
+ if (!host.schedules) return unavailable("Self-scheduling");
511
+ const topic = requireAccessibleTopic(ctx);
512
+ if ("text" in topic) return topic;
513
+ if (updates.delaySeconds === undefined && updates.message === undefined) {
514
+ return err("Provide delay_seconds, message, or both.");
515
+ }
516
+ if (
517
+ updates.delaySeconds !== undefined &&
518
+ (!Number.isInteger(updates.delaySeconds) ||
519
+ updates.delaySeconds < 1 ||
520
+ updates.delaySeconds > product.scheduleMaxDelaySeconds)
521
+ ) {
522
+ return err(`delay_seconds must be an integer from 1 to ${product.scheduleMaxDelaySeconds}.`);
523
+ }
524
+ let cleanMessage: string | undefined;
525
+ if (updates.message !== undefined) {
526
+ cleanMessage = updates.message.trim();
527
+ if (!cleanMessage) return err("message is required when provided.");
528
+ if (cleanMessage.length > product.scheduleMaxMessageLength) {
529
+ return err(`message must be ${product.scheduleMaxMessageLength} characters or fewer.`);
530
+ }
531
+ }
532
+
533
+ const updated = host.schedules.update({
534
+ topicId: topic.id,
535
+ scheduleId,
536
+ message: cleanMessage,
537
+ deliverAt:
538
+ updates.delaySeconds === undefined ? undefined : nowMs + updates.delaySeconds * 1000,
539
+ now: nowMs,
540
+ });
541
+ if (!updated) {
542
+ return err(
543
+ `Pending self-schedule '${scheduleId}' was not found. It may have been cancelled, replaced, or already started.`,
544
+ );
545
+ }
546
+ return ok(
547
+ `Updated self-schedule ${updated.id}. It will resume at ${new Date(updated.deliverAt).toISOString()}.`,
548
+ );
549
+ }
550
+
551
+ function cancelSchedule(ctx: SelfConfigContext, scheduleId: string): SelfConfigResult {
552
+ if (!host.schedules) return unavailable("Self-scheduling");
553
+ const topic = requireAccessibleTopic(ctx);
554
+ if ("text" in topic) return topic;
555
+ if (!host.schedules.cancel(topic.id, scheduleId)) {
556
+ return err(
557
+ `Pending self-schedule '${scheduleId}' was not found. It may have been cancelled, replaced, or already started.`,
558
+ );
559
+ }
560
+ return ok(`Cancelled self-schedule ${scheduleId}.`);
561
+ }
562
+
563
+ async function deriveTopic(
564
+ ctx: SelfConfigContext,
565
+ copyHistory: boolean,
566
+ name?: string,
567
+ ): Promise<SelfConfigResult> {
568
+ if (!host.derivedTopics) return unavailable("Derived topics");
569
+ const topic = requireTopic(ctx);
570
+ if ("text" in topic) return topic;
571
+
572
+ let derived: SelfConfigTopic | null;
573
+ try {
574
+ derived = await host.derivedTopics.create(topic.id, ctx.userId, copyHistory, { name });
575
+ } catch (error) {
576
+ if (host.derivedTopics.isTitleConflict(error)) {
577
+ return err(`${errorMessage(error)} — pick a different name.`);
578
+ }
579
+ if (copyHistory && host.derivedTopics.isForkCompactionError(error)) {
580
+ return err(errorMessage(error));
581
+ }
582
+ throw error;
583
+ }
584
+ const verb = copyHistory ? "Forked" : "Spawned";
585
+ if (!derived) {
586
+ return err(`Failed to ${verb.toLowerCase()} topic (source not found or permission denied).`);
587
+ }
588
+ const history = copyHistory ? " History copied." : "";
589
+ return ok(
590
+ `${verb} new topic "${derived.title}".${history}\nLink: ${host.derivedTopics.link(derived.id)}`,
591
+ );
592
+ }
593
+
594
+ const core: SelfConfigCore = {
595
+ host,
596
+ product,
597
+ setModel,
598
+ getModel,
599
+ setAgent,
600
+ getAgent,
601
+ setEffort,
602
+ getEffort,
603
+ scheduleContinue,
604
+ getSchedule,
605
+ updateSchedule,
606
+ cancelSchedule,
607
+ spawnTopic: (ctx, name) => deriveTopic(ctx, false, name),
608
+ forkTopic: (ctx, name) => deriveTopic(ctx, true, name),
609
+ };
610
+ return Object.freeze(core);
611
+ }
612
+
613
+ export const defaultSelfConfigHost: SelfConfigHost = {
614
+ topics: {
615
+ getTopic,
616
+ getConfig: getApiTopicConfig,
617
+ setConfig: setApiTopicConfig,
618
+ },
619
+ models: {
620
+ forAgent(agent) {
621
+ const registry = getRegistry(agent);
622
+ return {
623
+ defaultEffort: registry.defaultEffort,
624
+ validEfforts: registry.validEfforts,
625
+ validateModel: (model) => registry.validateModel(model),
626
+ validateEffort: (effort) => registry.validateEffort(effort),
627
+ resolveModel: (model) => resolveModelForAgent(agent, model, registry),
628
+ };
629
+ },
630
+ owner: modelOwner,
631
+ checkAuth: (agent, model, userId) => checkAgentModelAuth(agent, model, undefined, userId),
632
+ },
633
+ runtime: {
634
+ resolveWorkspaceDir: resolveTopicWorkspaceDir,
635
+ switchAgent: switchApiTopicAgent,
636
+ configChanged: (topicId) => WsHub.get().broadcastTopicUpdated(topicId),
637
+ },
638
+ derivedTopics: {
639
+ async create(sourceTopicId, userId, copyHistory, options) {
640
+ const { createDerivedTopic } = await import("#topics/derive");
641
+ return createDerivedTopic(sourceTopicId, userId, copyHistory, options);
642
+ },
643
+ link: topicMarkdownLink,
644
+ isTitleConflict: (error) => error instanceof Error && error.name === "TopicTitleConflictError",
645
+ isForkCompactionError: (error) =>
646
+ error instanceof Error && error.name === "TopicForkCompactionError",
647
+ },
648
+ schedules: {
649
+ create: createPendingSelfSchedule,
650
+ getPending: getPendingSelfSchedule,
651
+ update: updatePendingSelfSchedule,
652
+ cancel: cancelPendingSelfSchedule,
653
+ },
654
+ };
655
+
656
+ export const defaultSelfConfigCore = createSelfConfigCore(defaultSelfConfigHost);
657
+
658
+ export function setSelfConfigModel(ctx: SelfConfigContext, model: string): SelfConfigResult {
659
+ return defaultSelfConfigCore.setModel(ctx, model);
660
+ }
661
+
662
+ export function getSelfConfigModel(ctx: SelfConfigContext): SelfConfigResult {
663
+ return defaultSelfConfigCore.getModel(ctx);
664
+ }
665
+
666
+ export function setSelfConfigAgent(ctx: SelfConfigContext, agent: AgentKind): SelfConfigResult {
667
+ return defaultSelfConfigCore.setAgent(ctx, agent);
668
+ }
669
+
670
+ export function getSelfConfigAgent(ctx: SelfConfigContext): SelfConfigResult {
671
+ return defaultSelfConfigCore.getAgent(ctx);
672
+ }
673
+
674
+ export function setSelfConfigEffort(ctx: SelfConfigContext, effort: EffortLevel): SelfConfigResult {
675
+ return defaultSelfConfigCore.setEffort(ctx, effort);
248
676
  }
249
677
 
250
678
  export function getSelfConfigEffort(ctx: SelfConfigContext): SelfConfigResult {
251
- const topic = requireTopic(ctx);
252
- if (isResult(topic)) return topic;
253
-
254
- const cfg = getApiTopicConfig(topic.id);
255
- const agent = currentAgent(topic);
256
- const registry = getRegistry(agent);
257
- const fallback = registry.defaultEffort ? `default (${registry.defaultEffort})` : "default (off)";
258
- const value = cfg?.effort ?? fallback;
259
- const lock = cfg?.effortLocked ? " [locked by user]" : "";
260
- return ok(`Effort (agent=${agent}): ${value}${lock}`);
261
- }
262
-
263
- /**
264
- * Persist the topic's sole pending one-shot continuation. The inbox worker
265
- * claims due rows only when the room is idle and keeps running claims durable
266
- * until the turn settles.
267
- */
679
+ return defaultSelfConfigCore.getEffort(ctx);
680
+ }
681
+
268
682
  export function scheduleSelfConfigContinue(
269
683
  ctx: SelfConfigContext,
270
684
  delaySeconds: number,
271
685
  message: string,
272
686
  nowMs = Date.now(),
273
687
  ): SelfConfigResult {
274
- const topic = requireTopic(ctx);
275
- if (isResult(topic)) return topic;
276
- if (
277
- !Number.isInteger(delaySeconds) ||
278
- delaySeconds < 1 ||
279
- delaySeconds > SELF_SCHEDULE_MAX_DELAY_SECONDS
280
- ) {
281
- return err(`delay_seconds must be an integer from 1 to ${SELF_SCHEDULE_MAX_DELAY_SECONDS}.`);
282
- }
283
- const cleanMessage = message.trim();
284
- if (!cleanMessage) return err("message is required.");
285
- if (cleanMessage.length > SELF_SCHEDULE_MAX_MESSAGE_LENGTH) {
286
- return err(`message must be ${SELF_SCHEDULE_MAX_MESSAGE_LENGTH} characters or fewer.`);
287
- }
288
-
289
- const deliverAtMs = nowMs + delaySeconds * 1000;
290
- const created = createPendingSelfSchedule({
291
- topicId: topic.id,
292
- userId: ctx.userId,
293
- message: cleanMessage,
294
- deliverAt: deliverAtMs,
295
- now: nowMs,
296
- });
297
- if (!created.ok) {
298
- return err(
299
- `This topic already has a pending self-schedule (${created.existing.id}) for ` +
300
- `${new Date(created.existing.deliverAt).toISOString()}. ` +
301
- "Use update_self_schedule or cancel_self_schedule instead of creating another one.",
302
- );
303
- }
304
- const deliverAt = new Date(deliverAtMs).toISOString();
305
- return ok(
306
- `Scheduled this topic to resume in ${delaySeconds} seconds at ${deliverAt}. ` +
307
- `Schedule ID: ${created.schedule.id}. ` +
308
- `The continuation is durable across node restarts (delivery granularity is about 5 seconds).`,
309
- );
688
+ return defaultSelfConfigCore.scheduleContinue(ctx, delaySeconds, message, nowMs);
310
689
  }
311
690
 
312
691
  export function getSelfConfigSchedule(
313
692
  ctx: SelfConfigContext,
314
693
  nowMs = Date.now(),
315
694
  ): SelfConfigResult {
316
- const topic = requireAccessibleTopic(ctx);
317
- if (isResult(topic)) return topic;
318
- const schedule = getPendingSelfSchedule(topic.id);
319
- if (!schedule) return ok("This topic has no pending self-schedule.");
320
- const remainingSeconds = Math.max(0, Math.ceil((schedule.deliverAt - nowMs) / 1000));
321
- return ok(
322
- [
323
- `Pending self-schedule: ${schedule.id}`,
324
- `Deliver at: ${new Date(schedule.deliverAt).toISOString()} (in about ${remainingSeconds} seconds)`,
325
- `Message: ${schedule.message}`,
326
- ].join("\n"),
327
- );
695
+ return defaultSelfConfigCore.getSchedule(ctx, nowMs);
328
696
  }
329
697
 
330
698
  export function updateSelfConfigSchedule(
@@ -333,98 +701,26 @@ export function updateSelfConfigSchedule(
333
701
  updates: { delaySeconds?: number; message?: string },
334
702
  nowMs = Date.now(),
335
703
  ): SelfConfigResult {
336
- const topic = requireAccessibleTopic(ctx);
337
- if (isResult(topic)) return topic;
338
- if (updates.delaySeconds === undefined && updates.message === undefined) {
339
- return err("Provide delay_seconds, message, or both.");
340
- }
341
- if (
342
- updates.delaySeconds !== undefined &&
343
- (!Number.isInteger(updates.delaySeconds) ||
344
- updates.delaySeconds < 1 ||
345
- updates.delaySeconds > SELF_SCHEDULE_MAX_DELAY_SECONDS)
346
- ) {
347
- return err(`delay_seconds must be an integer from 1 to ${SELF_SCHEDULE_MAX_DELAY_SECONDS}.`);
348
- }
349
- let cleanMessage: string | undefined;
350
- if (updates.message !== undefined) {
351
- cleanMessage = updates.message.trim();
352
- if (!cleanMessage) return err("message is required when provided.");
353
- if (cleanMessage.length > SELF_SCHEDULE_MAX_MESSAGE_LENGTH) {
354
- return err(`message must be ${SELF_SCHEDULE_MAX_MESSAGE_LENGTH} characters or fewer.`);
355
- }
356
- }
357
-
358
- const updated = updatePendingSelfSchedule({
359
- topicId: topic.id,
360
- scheduleId,
361
- message: cleanMessage,
362
- deliverAt: updates.delaySeconds === undefined ? undefined : nowMs + updates.delaySeconds * 1000,
363
- now: nowMs,
364
- });
365
- if (!updated) {
366
- return err(
367
- `Pending self-schedule '${scheduleId}' was not found. It may have been cancelled, replaced, or already started.`,
368
- );
369
- }
370
- return ok(
371
- `Updated self-schedule ${updated.id}. It will resume at ${new Date(updated.deliverAt).toISOString()}.`,
372
- );
704
+ return defaultSelfConfigCore.updateSchedule(ctx, scheduleId, updates, nowMs);
373
705
  }
374
706
 
375
707
  export function cancelSelfConfigSchedule(
376
708
  ctx: SelfConfigContext,
377
709
  scheduleId: string,
378
710
  ): SelfConfigResult {
379
- const topic = requireAccessibleTopic(ctx);
380
- if (isResult(topic)) return topic;
381
- if (!cancelPendingSelfSchedule(topic.id, scheduleId)) {
382
- return err(
383
- `Pending self-schedule '${scheduleId}' was not found. It may have been cancelled, replaced, or already started.`,
384
- );
385
- }
386
- return ok(`Cancelled self-schedule ${scheduleId}.`);
711
+ return defaultSelfConfigCore.cancelSchedule(ctx, scheduleId);
387
712
  }
388
713
 
389
- export async function spawnSelfConfigTopic(
714
+ export function spawnSelfConfigTopic(
390
715
  ctx: SelfConfigContext,
391
716
  name?: string,
392
717
  ): Promise<SelfConfigResult> {
393
- const topic = requireTopic(ctx);
394
- if (isResult(topic)) return topic;
395
-
396
- let derived: TopicDto | null;
397
- try {
398
- derived = await createDerivedTopic(topic.id, ctx.userId, false, {
399
- name,
400
- });
401
- } catch (e) {
402
- if (e instanceof TopicTitleConflictError) return err(`${e.message} — pick a different name.`);
403
- throw e;
404
- }
405
- if (!derived) return err("Failed to spawn topic (source not found or permission denied).");
406
- return ok(`Spawned new topic "${derived.title}".\nLink: ${topicMarkdownLink(derived.id)}`);
718
+ return defaultSelfConfigCore.spawnTopic(ctx, name);
407
719
  }
408
720
 
409
- export async function forkSelfConfigTopic(
721
+ export function forkSelfConfigTopic(
410
722
  ctx: SelfConfigContext,
411
723
  name?: string,
412
724
  ): Promise<SelfConfigResult> {
413
- const topic = requireTopic(ctx);
414
- if (isResult(topic)) return topic;
415
-
416
- let derived: TopicDto | null;
417
- try {
418
- derived = await createDerivedTopic(topic.id, ctx.userId, true, {
419
- name,
420
- });
421
- } catch (e) {
422
- if (e instanceof TopicTitleConflictError) return err(`${e.message} — pick a different name.`);
423
- if (e instanceof TopicForkCompactionError) return err(e.message);
424
- throw e;
425
- }
426
- if (!derived) return err("Failed to fork topic (source not found or permission denied).");
427
- return ok(
428
- `Forked new topic "${derived.title}". History copied.\nLink: ${topicMarkdownLink(derived.id)}`,
429
- );
725
+ return defaultSelfConfigCore.forkTopic(ctx, name);
430
726
  }