negotium 0.1.6 → 0.1.8
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/README.md +2 -2
- package/dist/chunk-0ay6cjdy.js +1331 -0
- package/dist/chunk-0ay6cjdy.js.map +16 -0
- package/dist/chunk-1k7t7bnx.js +97 -0
- package/dist/chunk-1k7t7bnx.js.map +10 -0
- package/dist/chunk-5ahkew9c.js +806 -0
- package/dist/chunk-5ahkew9c.js.map +17 -0
- package/dist/chunk-6cpn7ht2.js +287 -0
- package/dist/chunk-6cpn7ht2.js.map +13 -0
- package/dist/chunk-6kctn20e.js +3529 -0
- package/dist/chunk-6kctn20e.js.map +20 -0
- package/dist/chunk-7xqj7aex.js +77 -0
- package/dist/chunk-7xqj7aex.js.map +11 -0
- package/dist/chunk-9p0ww3tk.js +41 -0
- package/dist/chunk-9p0ww3tk.js.map +10 -0
- package/dist/chunk-a92a7nrk.js +252 -0
- package/dist/chunk-a92a7nrk.js.map +10 -0
- package/dist/chunk-c500xe9z.js +33 -0
- package/dist/chunk-c500xe9z.js.map +10 -0
- package/dist/chunk-cds1qxsf.js +1644 -0
- package/dist/chunk-cds1qxsf.js.map +17 -0
- package/dist/chunk-e4407g8x.js +274 -0
- package/dist/chunk-e4407g8x.js.map +10 -0
- package/dist/chunk-e5pbggsh.js +363 -0
- package/dist/chunk-e5pbggsh.js.map +11 -0
- package/dist/chunk-edb07sjp.js +567 -0
- package/dist/chunk-edb07sjp.js.map +14 -0
- package/dist/chunk-exd0v74a.js +3012 -0
- package/dist/chunk-exd0v74a.js.map +32 -0
- package/dist/chunk-fgrme9tr.js +4398 -0
- package/dist/chunk-fgrme9tr.js.map +47 -0
- package/dist/chunk-fka9eqe1.js +581 -0
- package/dist/chunk-fka9eqe1.js.map +11 -0
- package/dist/chunk-g82jygez.js +108 -0
- package/dist/chunk-g82jygez.js.map +10 -0
- package/dist/chunk-ghs2j1fr.js +312 -0
- package/dist/chunk-ghs2j1fr.js.map +10 -0
- package/dist/chunk-gmy11efb.js +125 -0
- package/dist/chunk-gmy11efb.js.map +10 -0
- package/dist/chunk-gxma4c70.js +110 -0
- package/dist/chunk-gxma4c70.js.map +10 -0
- package/dist/chunk-hkdqhm34.js +61 -0
- package/dist/chunk-hkdqhm34.js.map +10 -0
- package/dist/chunk-hpa0cpzh.js +134 -0
- package/dist/chunk-hpa0cpzh.js.map +10 -0
- package/dist/chunk-hwy3n31w.js +21 -0
- package/dist/chunk-hwy3n31w.js.map +9 -0
- package/dist/chunk-j2behpy7.js +214 -0
- package/dist/chunk-j2behpy7.js.map +10 -0
- package/dist/chunk-jc0at0tc.js +102 -0
- package/dist/chunk-jc0at0tc.js.map +10 -0
- package/dist/chunk-jkqq5ef6.js +243 -0
- package/dist/chunk-jkqq5ef6.js.map +11 -0
- package/dist/chunk-jzkw2f01.js +51 -0
- package/dist/chunk-jzkw2f01.js.map +10 -0
- package/dist/chunk-jzz91jwh.js +70 -0
- package/dist/chunk-jzz91jwh.js.map +10 -0
- package/dist/chunk-k9z47mab.js +247 -0
- package/dist/chunk-k9z47mab.js.map +12 -0
- package/dist/chunk-n17yxpa8.js +23 -0
- package/dist/chunk-n17yxpa8.js.map +10 -0
- package/dist/chunk-naa09ags.js +2143 -0
- package/dist/chunk-naa09ags.js.map +15 -0
- package/dist/chunk-ns3ke88n.js +142 -0
- package/dist/chunk-ns3ke88n.js.map +10 -0
- package/dist/chunk-p3cr2ejg.js +1626 -0
- package/dist/chunk-p3cr2ejg.js.map +15 -0
- package/dist/chunk-q5bcpf23.js +2925 -0
- package/dist/chunk-q5bcpf23.js.map +20 -0
- package/dist/chunk-rafc55bs.js +424 -0
- package/dist/chunk-rafc55bs.js.map +11 -0
- package/dist/chunk-rbrxjvbj.js +2830 -0
- package/dist/chunk-rbrxjvbj.js.map +23 -0
- package/dist/chunk-sa874j5c.js +9 -0
- package/dist/chunk-sa874j5c.js.map +10 -0
- package/dist/chunk-sgsz2fpq.js +289 -0
- package/dist/chunk-sgsz2fpq.js.map +10 -0
- package/dist/chunk-sm6fh5c5.js +569 -0
- package/dist/chunk-sm6fh5c5.js.map +14 -0
- package/dist/chunk-t9zcg8f1.js +499 -0
- package/dist/chunk-t9zcg8f1.js.map +13 -0
- package/dist/chunk-vp2db42d.js +121 -0
- package/dist/chunk-vp2db42d.js.map +10 -0
- package/dist/chunk-w13jjf4a.js +46 -0
- package/dist/chunk-w13jjf4a.js.map +10 -0
- package/dist/chunk-x1arr7ze.js +63 -0
- package/dist/chunk-x1arr7ze.js.map +10 -0
- package/dist/chunk-xfq68rch.js +583 -0
- package/dist/chunk-xfq68rch.js.map +11 -0
- package/dist/main.js +132 -20
- package/dist/main.js.map +4 -4
- package/dist/runtime/scripts/faster-whisper-wrapper.py +87 -0
- package/dist/runtime/scripts/mcp-patchright-http.mjs +349 -0
- package/dist/runtime/src/agents/api-topic-agent-switch.ts +178 -0
- package/dist/runtime/src/agents/archiver.ts +396 -0
- package/dist/runtime/src/agents/auth-check.ts +64 -0
- package/dist/runtime/src/agents/claude-provider.ts +737 -0
- package/dist/runtime/src/agents/claude-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-native-multi-agent.ts +177 -0
- package/dist/runtime/src/agents/codex-provider.ts +785 -0
- package/dist/runtime/src/agents/codex-registry.ts +115 -0
- package/dist/runtime/src/agents/codex-tree-kill.ts +365 -0
- package/dist/runtime/src/agents/contracts.ts +69 -0
- package/dist/runtime/src/agents/deep-map.ts +13 -0
- package/dist/runtime/src/agents/execution-host.ts +91 -0
- package/dist/runtime/src/agents/fixtures/claude-attachments.jsonl +2 -0
- package/dist/runtime/src/agents/fixtures/codex-shell.jsonl +5 -0
- package/dist/runtime/src/agents/fork.ts +65 -0
- package/dist/runtime/src/agents/hosted-agent.ts +48 -0
- package/dist/runtime/src/agents/idle-archiver.ts +128 -0
- package/dist/runtime/src/agents/index.ts +205 -0
- package/dist/runtime/src/agents/maestro-provider.ts +206 -0
- package/dist/runtime/src/agents/maestro-registry.ts +31 -0
- package/dist/runtime/src/agents/mcp-tools/ask-user.ts +280 -0
- package/dist/runtime/src/agents/mcp-tools/common.ts +21 -0
- package/dist/runtime/src/agents/mcp-tools/self-config.ts +220 -0
- package/dist/runtime/src/agents/mcp-tools/spawn-subagent.ts +525 -0
- package/dist/runtime/src/agents/mcp-tools/visuals.ts +88 -0
- package/dist/runtime/src/agents/model-catalog.ts +151 -0
- package/dist/runtime/src/agents/registry.ts +24 -0
- package/dist/runtime/src/agents/rollout/claude.ts +331 -0
- package/dist/runtime/src/agents/rollout/codex.ts +569 -0
- package/dist/runtime/src/agents/rollout/shared.ts +205 -0
- package/dist/runtime/src/agents/self-config-core.ts +415 -0
- package/dist/runtime/src/agents/task-events.ts +44 -0
- package/dist/runtime/src/agents/tool-format.ts +352 -0
- package/dist/runtime/src/agents/topic-agent-switch.ts +193 -0
- package/dist/runtime/src/agents/topic-cleanup.ts +231 -0
- package/dist/runtime/src/agents/vault-tool-policy.ts +33 -0
- package/dist/runtime/src/application/execute-external-user-turn.ts +23 -0
- package/dist/runtime/src/application/submit-user-message.ts +47 -0
- package/dist/runtime/src/application/switch-topic-effort.ts +48 -0
- package/dist/runtime/src/application/switch-topic-model.ts +79 -0
- package/dist/runtime/src/application/topic-service.ts +139 -0
- package/dist/runtime/src/application/vault-command.ts +170 -0
- package/dist/runtime/src/bus.ts +319 -0
- package/dist/runtime/src/index.ts +333 -0
- package/dist/runtime/src/mcp/agent-health-server.ts +309 -0
- package/dist/runtime/src/mcp/background-bash-server.ts +346 -0
- package/dist/runtime/src/mcp/canonical-bridge-config.ts +84 -0
- package/dist/runtime/src/mcp/canonical-proxy-server.ts +171 -0
- package/dist/runtime/src/mcp/dm-ipc.ts +60 -0
- package/dist/runtime/src/mcp/mcp-helpers.ts +40 -0
- package/dist/runtime/src/mcp/paddleocr-server.ts +105 -0
- package/dist/runtime/src/mcp/peer-bridge.ts +114 -0
- package/dist/runtime/src/mcp/runtime-spec.ts +134 -0
- package/dist/runtime/src/mcp/session-comm/bridge-ipc-config.ts +27 -0
- package/dist/runtime/src/mcp/session-comm/peer-forward.ts +165 -0
- package/dist/runtime/src/mcp/session-comm/runtime.ts +115 -0
- package/dist/runtime/src/mcp/session-comm/server.ts +649 -0
- package/dist/runtime/src/mcp/session-comm/topic-config.ts +131 -0
- package/dist/runtime/src/mcp/session-comm/topics.ts +229 -0
- package/dist/runtime/src/mcp/stdio-protect.ts +84 -0
- package/dist/runtime/src/mcp/system-health-server.ts +130 -0
- package/dist/runtime/src/mcp/task-server.ts +173 -0
- package/dist/runtime/src/mcp/token-stats-server.ts +98 -0
- package/dist/runtime/src/mcp/vault-http.ts +219 -0
- package/dist/runtime/src/mcp/vault-run.ts +153 -0
- package/dist/runtime/src/mcp/vault-server.ts +104 -0
- package/dist/runtime/src/mcp/wiki-server.ts +755 -0
- package/dist/runtime/src/media/file-events.ts +33 -0
- package/dist/runtime/src/media/text-extractor.ts +312 -0
- package/dist/runtime/src/media/video.ts +92 -0
- package/dist/runtime/src/outbox/file-ops.ts +210 -0
- package/dist/runtime/src/outbox/index.ts +8 -0
- package/dist/runtime/src/outbox/utils.ts +51 -0
- package/dist/runtime/src/platform/background-bash/manager.ts +279 -0
- package/dist/runtime/src/platform/config.ts +371 -0
- package/dist/runtime/src/platform/constants.ts +18 -0
- package/dist/runtime/src/platform/debug-users.ts +14 -0
- package/dist/runtime/src/platform/delay.ts +1 -0
- package/dist/runtime/src/platform/error.ts +9 -0
- package/dist/runtime/src/platform/file-utils.ts +16 -0
- package/dist/runtime/src/platform/jsonl.ts +202 -0
- package/dist/runtime/src/platform/lifecycle.ts +174 -0
- package/dist/runtime/src/platform/logger.ts +31 -0
- package/dist/runtime/src/platform/maestro-bootstrap-env.ts +3 -0
- package/dist/runtime/src/platform/mcp-config.ts +865 -0
- package/dist/runtime/src/platform/modules.ts +145 -0
- package/dist/runtime/src/platform/node-plugins.ts +46 -0
- package/dist/runtime/src/platform/playwright/manager.ts +1233 -0
- package/dist/runtime/src/prompts/agents/wiki-archiver.md +261 -0
- package/dist/runtime/src/prompts/builders.ts +357 -0
- package/dist/runtime/src/prompts/sessions/channel-system.md +48 -0
- package/dist/runtime/src/prompts/sessions/manager-system.md +37 -0
- package/dist/runtime/src/prompts/sessions/topic-system.md +47 -0
- package/dist/runtime/src/prompts/sessions/visual-design.md +86 -0
- package/dist/runtime/src/query/active-rooms.ts +532 -0
- package/dist/runtime/src/query/control.ts +313 -0
- package/dist/runtime/src/query/index.ts +24 -0
- package/dist/runtime/src/query/session-inbox-cleanup.ts +44 -0
- package/dist/runtime/src/query/session-inbox-path.ts +42 -0
- package/dist/runtime/src/query/state.ts +94 -0
- package/dist/runtime/src/query/types.ts +83 -0
- package/dist/runtime/src/runtime/ask-callbacks.ts +153 -0
- package/dist/runtime/src/runtime/attachments.ts +151 -0
- package/dist/runtime/src/runtime/background-sessions.ts +83 -0
- package/dist/runtime/src/runtime/channel-context.ts +107 -0
- package/dist/runtime/src/runtime/errors.ts +69 -0
- package/dist/runtime/src/runtime/event-heartbeat.ts +77 -0
- package/dist/runtime/src/runtime/file-hooks.ts +81 -0
- package/dist/runtime/src/runtime/footer.ts +23 -0
- package/dist/runtime/src/runtime/inbox.ts +946 -0
- package/dist/runtime/src/runtime/tasks.ts +56 -0
- package/dist/runtime/src/runtime/topic-config.ts +29 -0
- package/dist/runtime/src/runtime/turn-runner.ts +2286 -0
- package/dist/runtime/src/runtime/usage-alert.ts +84 -0
- package/dist/runtime/src/runtime/visual-html.ts +109 -0
- package/dist/runtime/src/runtime/visual-store.ts +430 -0
- package/dist/runtime/src/runtime/visuals.ts +171 -0
- package/dist/runtime/src/security/sanitize.ts +27 -0
- package/dist/runtime/src/security/sensitive-path.ts +34 -0
- package/dist/runtime/src/storage/activity-log.ts +152 -0
- package/dist/runtime/src/storage/api-messages.ts +617 -0
- package/dist/runtime/src/storage/api-topic-brief.ts +127 -0
- package/dist/runtime/src/storage/api-topic-config.ts +192 -0
- package/dist/runtime/src/storage/api-topics.ts +844 -0
- package/dist/runtime/src/storage/app-settings.ts +44 -0
- package/dist/runtime/src/storage/browser-profiles.ts +133 -0
- package/dist/runtime/src/storage/conversations.ts +268 -0
- package/dist/runtime/src/storage/forum/agent-settings.ts +20 -0
- package/dist/runtime/src/storage/forum/index.ts +4 -0
- package/dist/runtime/src/storage/forum/repository.ts +283 -0
- package/dist/runtime/src/storage/forum/schema.ts +300 -0
- package/dist/runtime/src/storage/forum-db.ts +23 -0
- package/dist/runtime/src/storage/runtime-events.ts +130 -0
- package/dist/runtime/src/storage/runtime-leases.ts +189 -0
- package/dist/runtime/src/storage/runtime-process-leases.ts +216 -0
- package/dist/runtime/src/storage/runtime-topic-state.ts +148 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +300 -0
- package/dist/runtime/src/storage/self-schedules.ts +298 -0
- package/dist/runtime/src/storage/session-asks.ts +347 -0
- package/dist/runtime/src/storage/sqlite.ts +129 -0
- package/dist/runtime/src/storage/tasks.ts +179 -0
- package/dist/runtime/src/storage/token-stats.ts +144 -0
- package/dist/runtime/src/storage/topic-archive-state.ts +59 -0
- package/dist/runtime/src/storage/topic-archive.ts +74 -0
- package/dist/runtime/src/storage/topic-settings.ts +67 -0
- package/dist/runtime/src/storage/topic-transcript.ts +98 -0
- package/dist/runtime/src/storage/vault-crypto.ts +65 -0
- package/dist/runtime/src/storage/vault.ts +225 -0
- package/dist/runtime/src/storage/wiki-summary-names.ts +33 -0
- package/dist/runtime/src/storage/wiki.ts +85 -0
- package/dist/runtime/src/topics/create.ts +123 -0
- package/dist/runtime/src/topics/derive.ts +398 -0
- package/dist/runtime/src/topics/lifecycle.ts +311 -0
- package/dist/runtime/src/topics/links.ts +7 -0
- package/dist/runtime/src/topics/personal-general.ts +65 -0
- package/dist/runtime/src/topics/session.ts +381 -0
- package/dist/runtime/src/types/api.ts +497 -0
- package/dist/runtime/src/types.ts +255 -0
- package/dist/runtime/src/version.ts +1 -0
- package/dist/runtime/tsconfig.json +13 -0
- package/package.json +16 -3
- package/dist/main.d.ts +0 -3
- package/dist/main.d.ts.map +0 -1
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers shared by every agent's rollout encoder.
|
|
3
|
+
*
|
|
4
|
+
* Each rollout file under `agents/rollout/<agent>.ts` materializes a
|
|
5
|
+
* synthetic SDK-native JSONL from a provider-agnostic `ConversationEntry`
|
|
6
|
+
* stream. The cwd validation, UUID shape check, and chat-pair extraction
|
|
7
|
+
* are identical regardless of agent, so they live here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mkdirSync } from "node:fs";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { DM_WORKSPACE_DIR, TOPIC_WORKSPACE_DIR, WORKSPACE_DIR } from "#platform/config";
|
|
14
|
+
import { logger } from "#platform/logger";
|
|
15
|
+
import type { ConversationEntry } from "#storage/conversations";
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Captured fixture snapshots — `claude-attachments.jsonl`, `codex-shell.jsonl`,
|
|
21
|
+
* etc. — live one level above the per-agent rollout files (under
|
|
22
|
+
* `agents/fixtures/`) so each agent can pull from the same directory without
|
|
23
|
+
* duplicating fixtures.
|
|
24
|
+
*
|
|
25
|
+
* To refresh fixtures: run any live Codex/Claude session, copy the first few
|
|
26
|
+
* non-conversational entries from the resulting rollout file, and update
|
|
27
|
+
* `src/agents/fixtures/{codex-shell,claude-attachments}.jsonl`.
|
|
28
|
+
*/
|
|
29
|
+
export const FIXTURES_DIR = join(__dirname, "..", "fixtures");
|
|
30
|
+
|
|
31
|
+
/** Deep-clone to avoid mutating cached fixture objects. */
|
|
32
|
+
export function clone<T>(obj: T): T {
|
|
33
|
+
return structuredClone(obj);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* UUID v4/v7 shape — both 36 chars with the standard 8-4-4-4-12 hex layout.
|
|
38
|
+
* We do not enforce the version nibble because randomUUID (v4) and uuidv7
|
|
39
|
+
* coexist; the only requirement for our caller is that the value is safe to
|
|
40
|
+
* embed in a path component and that the SDK accepts it as a session/thread
|
|
41
|
+
* identifier.
|
|
42
|
+
*/
|
|
43
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
44
|
+
|
|
45
|
+
export function assertUuidLike(label: string, value: string): void {
|
|
46
|
+
if (!UUID_RE.test(value)) {
|
|
47
|
+
throw new Error(`rollout: ${label} is not a UUID-shaped string: ${value}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Reject cwds that escape Otium-managed workspace roots. We use `path.resolve`
|
|
53
|
+
* to normalize `..` traversal; symlink-based escapes are out of scope (the
|
|
54
|
+
* workspace roots are not expected to contain attacker-controlled symlinks).
|
|
55
|
+
*/
|
|
56
|
+
function assertCwdInWorkspace(cwd: string): void {
|
|
57
|
+
const abs = resolve(cwd);
|
|
58
|
+
// Synthetic rollouts for spawn/fork must be written under the same root as
|
|
59
|
+
// live topic turns or provider resume looks in a different cwd hash than the
|
|
60
|
+
// one we generated.
|
|
61
|
+
const roots = [WORKSPACE_DIR, DM_WORKSPACE_DIR, TOPIC_WORKSPACE_DIR];
|
|
62
|
+
const ok = roots.some((root) => abs === root || abs.startsWith(`${root}/`));
|
|
63
|
+
if (!ok) {
|
|
64
|
+
throw new Error(`rollout: cwd outside trusted workspace roots: ${cwd} (resolved=${abs})`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Best-effort guarantee that the resumed cwd will exist when the SDK stats it. */
|
|
69
|
+
export function ensureCwdExists(cwd: string): void {
|
|
70
|
+
assertCwdInWorkspace(cwd);
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(cwd, { recursive: true });
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logger.warn({ err, cwd }, "rollout: ensureCwdExists failed — caller should pre-create cwd");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Length-bound a string by Unicode code points rather than UTF-16 units, so a
|
|
80
|
+
* truncation never lands in the middle of a multi-byte sequence and produces
|
|
81
|
+
* a malformed character. Used for in-line tool annotations where we only need
|
|
82
|
+
* a rough preview, not the full payload.
|
|
83
|
+
*/
|
|
84
|
+
function truncate(text: string, n: number): string {
|
|
85
|
+
const cps = Array.from(text);
|
|
86
|
+
return cps.length > n ? `${cps.slice(0, n).join("")}…` : text;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reduce a UnifiedEvent stream to user/assistant message pairs. Tool calls and
|
|
91
|
+
* tool results are inlined as bracketed annotations on the surrounding
|
|
92
|
+
* assistant turn so the historical narrative is preserved when synthesizing a
|
|
93
|
+
* native rollout for a different SDK. Streaming text deltas are folded into
|
|
94
|
+
* their final consolidated `text` event to avoid double-counting partial
|
|
95
|
+
* tokens.
|
|
96
|
+
*/
|
|
97
|
+
export type ChatPair = { userText: string; assistantText: string };
|
|
98
|
+
|
|
99
|
+
interface ExtractOptions {
|
|
100
|
+
includeToolAnnotations: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function extractChatPairs(
|
|
104
|
+
entries: ConversationEntry[],
|
|
105
|
+
opts: ExtractOptions = { includeToolAnnotations: true },
|
|
106
|
+
): ChatPair[] {
|
|
107
|
+
const pairs: ChatPair[] = [];
|
|
108
|
+
let pendingUser: string | null = null;
|
|
109
|
+
let pendingAssistantParts: string[] = [];
|
|
110
|
+
let toolBuffer: string[] = [];
|
|
111
|
+
|
|
112
|
+
const flushAssistant = () => {
|
|
113
|
+
if (pendingUser === null) return;
|
|
114
|
+
const tools =
|
|
115
|
+
opts.includeToolAnnotations && toolBuffer.length > 0 ? `\n\n${toolBuffer.join("\n")}` : "";
|
|
116
|
+
const assistantText = pendingAssistantParts.join("").trim() + tools;
|
|
117
|
+
if (assistantText.trim()) {
|
|
118
|
+
pairs.push({ userText: pendingUser, assistantText });
|
|
119
|
+
}
|
|
120
|
+
pendingUser = null;
|
|
121
|
+
pendingAssistantParts = [];
|
|
122
|
+
toolBuffer = [];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const ev = entry.event;
|
|
127
|
+
switch (ev.type) {
|
|
128
|
+
case "user_message": {
|
|
129
|
+
// Explicit user prompt marker emitted by the query handler before each
|
|
130
|
+
// turn. Close the previous pair (in case the prior turn streamed
|
|
131
|
+
// assistant text without a clean termination) and stage this prompt.
|
|
132
|
+
flushAssistant();
|
|
133
|
+
pendingUser = (ev as { content: string }).content;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case "session":
|
|
137
|
+
// Session-id metadata. Provider sessions can change mid-conversation
|
|
138
|
+
// (e.g. agent flip after rollout reconstruction), but the SDK normally
|
|
139
|
+
// emits this *during* a turn — after the user prompt was already
|
|
140
|
+
// recorded, before the assistant text arrives. Only flush when there
|
|
141
|
+
// is actual assistant content in flight; otherwise we'd discard the
|
|
142
|
+
// staged user prompt and the next text event would synthesize a bogus
|
|
143
|
+
// "(continued)" pair.
|
|
144
|
+
if (pendingAssistantParts.length > 0 || toolBuffer.length > 0) {
|
|
145
|
+
flushAssistant();
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
case "text": {
|
|
149
|
+
// Per-block assistant text. claudeProvider emits one of these per text
|
|
150
|
+
// content block, so a single turn can yield multiple `text` events
|
|
151
|
+
// interleaved with tool_use. Accumulate (do NOT flush yet) — the
|
|
152
|
+
// turn's `result` event below carries the final concatenation and
|
|
153
|
+
// is the proper boundary.
|
|
154
|
+
if (pendingUser === null) {
|
|
155
|
+
pendingUser = "(continued)";
|
|
156
|
+
}
|
|
157
|
+
pendingAssistantParts.push((ev as { content: string }).content);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "result": {
|
|
161
|
+
// End-of-turn marker. `result.content` is the canonical final answer
|
|
162
|
+
// produced by the provider, so prefer it over the accumulated `text`
|
|
163
|
+
// chunks (which may be partial or out of order across SDKs).
|
|
164
|
+
if (pendingUser === null) {
|
|
165
|
+
pendingUser = "(continued)";
|
|
166
|
+
}
|
|
167
|
+
pendingAssistantParts = [(ev as { content: string }).content];
|
|
168
|
+
flushAssistant();
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
case "text_delta":
|
|
172
|
+
// Skip — final `text`/`result` event carries the complete content.
|
|
173
|
+
break;
|
|
174
|
+
case "tool_use": {
|
|
175
|
+
const u = ev as { name: string; input: Record<string, unknown> };
|
|
176
|
+
toolBuffer.push(`<!-- Tool: ${u.name} ${truncate(JSON.stringify(u.input), 200)} -->`);
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "tool_result": {
|
|
180
|
+
const u = ev as { content: string };
|
|
181
|
+
toolBuffer.push(`<!-- Tool result: ${truncate(u.content, 200)} -->`);
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "error": {
|
|
185
|
+
const u = ev as { content: string };
|
|
186
|
+
toolBuffer.push(`[Error: ${truncate(u.content, 200)}]`);
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
// Other UnifiedEvent variants (`tool_progress`, `tool_use_summary`,
|
|
190
|
+
// `file`, `text_delta` already handled above) carry no conversational
|
|
191
|
+
// content, so they do not contribute to a chat pair. We intentionally
|
|
192
|
+
// omit a default branch — the explicit user_message variant introduced
|
|
193
|
+
// by the recorder makes the previous "treat unrecognized as user" hack
|
|
194
|
+
// unnecessary, and adding new UnifiedEvent variants in the future
|
|
195
|
+
// should require an explicit decision here, not silent inheritance.
|
|
196
|
+
case "tool_progress":
|
|
197
|
+
case "tool_use_summary":
|
|
198
|
+
case "file":
|
|
199
|
+
case "status":
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
flushAssistant();
|
|
204
|
+
return pairs;
|
|
205
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { switchApiTopicAgent } from "#agents/api-topic-agent-switch";
|
|
2
|
+
import { resolveModelForAgent } from "#agents/model-catalog";
|
|
3
|
+
import { getRegistry } from "#agents/registry";
|
|
4
|
+
import { WsHub } from "#bus";
|
|
5
|
+
import { resolveTopicWorkspaceDir } from "#platform/config";
|
|
6
|
+
import { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
|
|
7
|
+
import { getTopic } from "#storage/api-topics";
|
|
8
|
+
import {
|
|
9
|
+
cancelPendingSelfSchedule,
|
|
10
|
+
createPendingSelfSchedule,
|
|
11
|
+
getPendingSelfSchedule,
|
|
12
|
+
updatePendingSelfSchedule,
|
|
13
|
+
} from "#storage/self-schedules";
|
|
14
|
+
import { createDerivedTopic, TopicTitleConflictError } from "#topics/derive";
|
|
15
|
+
import { topicMarkdownLink } from "#topics/links";
|
|
16
|
+
import type { AgentKind, EffortLevel } from "#types";
|
|
17
|
+
import type { TopicDto } from "#types/api";
|
|
18
|
+
|
|
19
|
+
export const SELF_CONFIG_MCP_KEY = "topic-config";
|
|
20
|
+
|
|
21
|
+
export type SelfConfigField = "agent" | "model" | "effort";
|
|
22
|
+
export const SELF_SCHEDULE_MAX_DELAY_SECONDS = 86_400;
|
|
23
|
+
export const SELF_SCHEDULE_MAX_MESSAGE_LENGTH = 10_000;
|
|
24
|
+
|
|
25
|
+
export interface SelfConfigContext {
|
|
26
|
+
topicId: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
/** Raw current user request. Used to prevent autonomous MCP agent switches. */
|
|
30
|
+
currentUserPrompt?: string;
|
|
31
|
+
/** Called after a successful set_* so the caller can trigger a follow-up turn. */
|
|
32
|
+
onConfigChanged?: (field: SelfConfigField) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SelfConfigResult {
|
|
36
|
+
text: string;
|
|
37
|
+
isError?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ok(text: string): SelfConfigResult {
|
|
41
|
+
return { text };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function err(text: string): SelfConfigResult {
|
|
45
|
+
return { text, isError: true };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function requireAccessibleTopic(ctx: SelfConfigContext): TopicDto | SelfConfigResult {
|
|
49
|
+
if (!ctx.topicId || !ctx.userId) return err("Error: missing topicId/userId context.");
|
|
50
|
+
const topic = getTopic(ctx.topicId);
|
|
51
|
+
if (!topic) return err(`Error: topic '${ctx.topicId}' not found.`);
|
|
52
|
+
if (!topic.participants.some((p) => p.userId === ctx.userId)) {
|
|
53
|
+
return err("Error: user is not a member of this topic.");
|
|
54
|
+
}
|
|
55
|
+
return topic;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function requireTopic(ctx: SelfConfigContext): TopicDto | SelfConfigResult {
|
|
59
|
+
const topic = requireAccessibleTopic(ctx);
|
|
60
|
+
if (isResult(topic)) return topic;
|
|
61
|
+
if (!topic.agent) return err("Error: this topic has no AI agent invited.");
|
|
62
|
+
return topic;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isResult(value: TopicDto | SelfConfigResult): value is SelfConfigResult {
|
|
66
|
+
return "text" in value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function currentAgent(topic: TopicDto): AgentKind {
|
|
70
|
+
return (topic.agent ?? "maestro") as AgentKind;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function effectiveDefaultModel(topic: TopicDto, agent: AgentKind): string {
|
|
74
|
+
const registry = getRegistry(agent);
|
|
75
|
+
return resolveModelForAgent(agent, topic.defaultModel, registry);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function escapeRegExp(value: string): string {
|
|
79
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function agentAliases(agent: AgentKind): string[] {
|
|
83
|
+
switch (agent) {
|
|
84
|
+
case "codex":
|
|
85
|
+
return ["codex", "코덱스", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"];
|
|
86
|
+
case "claude":
|
|
87
|
+
return ["claude", "클로드", "sonnet", "opus", "fable"];
|
|
88
|
+
case "maestro":
|
|
89
|
+
return [
|
|
90
|
+
"maestro",
|
|
91
|
+
"마에스트로",
|
|
92
|
+
"메스트로",
|
|
93
|
+
"deepseek",
|
|
94
|
+
"deepseek-pro",
|
|
95
|
+
"deepseek-flash",
|
|
96
|
+
"딥시크",
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hasExplicitAgentSwitchRequest(prompt: string | undefined, agent: AgentKind): boolean {
|
|
102
|
+
if (!prompt?.trim()) return false;
|
|
103
|
+
const text = prompt.toLowerCase().replace(/\s+/g, " ").trim();
|
|
104
|
+
const aliases = agentAliases(agent).map(escapeRegExp);
|
|
105
|
+
const target = `(?:${aliases.join("|")})`;
|
|
106
|
+
const switchVerb =
|
|
107
|
+
"(?:바꿔|바꿔줘|변경|변경해|전환|전환해|설정|설정해|써줘|사용|가|switch|change|set|use)";
|
|
108
|
+
const switchSubject = "(?:agent|runtime|model|에이전트|런타임|모델)";
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
new RegExp(`^/(?:agent|runtime)\\s+${target}(?:\\s|$)`, "iu"),
|
|
112
|
+
new RegExp(`${target}\\s*(?:로|으로)\\s*.{0,16}${switchVerb}`, "iu"),
|
|
113
|
+
new RegExp(`${switchSubject}.{0,24}${target}.{0,24}${switchVerb}`, "iu"),
|
|
114
|
+
new RegExp(`${switchVerb}.{0,24}${switchSubject}.{0,24}${target}`, "iu"),
|
|
115
|
+
new RegExp(`(?:switch|change|set|use).{0,24}${target}`, "iu"),
|
|
116
|
+
].some((pattern) => pattern.test(text));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function setSelfConfigModel(ctx: SelfConfigContext, model: string): SelfConfigResult {
|
|
120
|
+
const topic = requireTopic(ctx);
|
|
121
|
+
if (isResult(topic)) return topic;
|
|
122
|
+
|
|
123
|
+
const cfg = getApiTopicConfig(topic.id) ?? {};
|
|
124
|
+
if (cfg.modelLocked) return err("Model for this topic is locked by the user. Cannot override.");
|
|
125
|
+
|
|
126
|
+
const agent = currentAgent(topic);
|
|
127
|
+
const registry = getRegistry(agent);
|
|
128
|
+
if (resolveModelForAgent(agent, model, registry) !== model) {
|
|
129
|
+
return err(
|
|
130
|
+
`'${model}' is not a valid model for agent '${agent}'. If it belongs to another agent, call set_agent first.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
setApiTopicConfig(topic.id, { ...cfg, model });
|
|
135
|
+
// Keep the provider conversation. Codex and Claude both support changing
|
|
136
|
+
// the model for a later turn while resuming the same thread/session.
|
|
137
|
+
WsHub.get().broadcastTopicUpdated(topic.id);
|
|
138
|
+
ctx.onConfigChanged?.("model");
|
|
139
|
+
return ok(`Model for this topic set to '${model}' (agent=${agent}). Applies from the next turn.`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function getSelfConfigModel(ctx: SelfConfigContext): SelfConfigResult {
|
|
143
|
+
const topic = requireTopic(ctx);
|
|
144
|
+
if (isResult(topic)) return topic;
|
|
145
|
+
|
|
146
|
+
const cfg = getApiTopicConfig(topic.id);
|
|
147
|
+
const agent = currentAgent(topic);
|
|
148
|
+
const registry = getRegistry(agent);
|
|
149
|
+
const fallback = effectiveDefaultModel(topic, agent);
|
|
150
|
+
const resolved = cfg?.model ? resolveModelForAgent(agent, cfg.model, registry) : fallback;
|
|
151
|
+
const value = cfg?.model && resolved === cfg.model ? cfg.model : `default (${fallback})`;
|
|
152
|
+
const lock = cfg?.modelLocked ? " [locked by user]" : "";
|
|
153
|
+
return ok(`Model (agent=${agent}): ${value}${lock}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function setSelfConfigAgent(ctx: SelfConfigContext, agent: AgentKind): SelfConfigResult {
|
|
157
|
+
const topic = requireTopic(ctx);
|
|
158
|
+
if (isResult(topic)) return topic;
|
|
159
|
+
|
|
160
|
+
const cfg = getApiTopicConfig(topic.id) ?? {};
|
|
161
|
+
// Agent is a topic property; the same path is used by UI and MCP.
|
|
162
|
+
|
|
163
|
+
const existing = currentAgent(topic);
|
|
164
|
+
if (existing === agent) return ok(`Agent is already '${agent}'. No change.`);
|
|
165
|
+
if (cfg.agentLocked) return err("Agent for this topic is locked by the user. Cannot override.");
|
|
166
|
+
if (!hasExplicitAgentSwitchRequest(ctx.currentUserPrompt, agent)) {
|
|
167
|
+
return err(
|
|
168
|
+
`Agent switch to '${agent}' requires an explicit request in the current user message.`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Switching agents invalidates provider-native session ids and per-agent
|
|
173
|
+
// model/effort overrides. The helper mirrors Otium's switchTopicAgent:
|
|
174
|
+
// write rollout, manifest the synthetic session in the unified log, then
|
|
175
|
+
// commit config + durable session.
|
|
176
|
+
const switched = switchApiTopicAgent({
|
|
177
|
+
topicId: topic.id,
|
|
178
|
+
topicTitle: topic.title,
|
|
179
|
+
userId: ctx.userId,
|
|
180
|
+
fromAgent: existing,
|
|
181
|
+
agent,
|
|
182
|
+
cwd: ctx.cwd ?? resolveTopicWorkspaceDir(topic.id),
|
|
183
|
+
config: { ...cfg, model: undefined, effort: undefined },
|
|
184
|
+
defaultModel: topic.defaultModel,
|
|
185
|
+
defaultEffort: topic.defaultEffort,
|
|
186
|
+
reason: "self-config-agent-switch",
|
|
187
|
+
});
|
|
188
|
+
if (!switched.ok) return err(switched.error);
|
|
189
|
+
|
|
190
|
+
WsHub.get().broadcastTopicUpdated(topic.id);
|
|
191
|
+
ctx.onConfigChanged?.("agent");
|
|
192
|
+
const sessionNote =
|
|
193
|
+
switched.outcome.kind === "bridged"
|
|
194
|
+
? ` Conversation history was bridged into a ${agent} session (${switched.outcome.bridgedSessionId}).`
|
|
195
|
+
: switched.outcome.reason === "bridge-failed"
|
|
196
|
+
? " Conversation history bridge failed; the next turn starts fresh."
|
|
197
|
+
: " No prior conversation history was found; the next turn starts fresh.";
|
|
198
|
+
return ok(
|
|
199
|
+
`Agent for this topic set to '${agent}'. Model/effort reset to '${agent}' defaults.${sessionNote}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function getSelfConfigAgent(ctx: SelfConfigContext): SelfConfigResult {
|
|
204
|
+
const topic = requireTopic(ctx);
|
|
205
|
+
if (isResult(topic)) return topic;
|
|
206
|
+
|
|
207
|
+
const lock = "";
|
|
208
|
+
return ok(`Agent: ${currentAgent(topic)}${lock}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function setSelfConfigEffort(ctx: SelfConfigContext, effort: EffortLevel): SelfConfigResult {
|
|
212
|
+
const topic = requireTopic(ctx);
|
|
213
|
+
if (isResult(topic)) return topic;
|
|
214
|
+
|
|
215
|
+
const cfg = getApiTopicConfig(topic.id) ?? {};
|
|
216
|
+
if (cfg.effortLocked) return err("Effort for this topic is locked by the user. Cannot override.");
|
|
217
|
+
|
|
218
|
+
const agent = currentAgent(topic);
|
|
219
|
+
const registry = getRegistry(agent);
|
|
220
|
+
if (!registry.validateEffort(effort)) {
|
|
221
|
+
return err(
|
|
222
|
+
`'${effort}' is not a valid effort for agent '${agent}'. Valid: ${registry.validEfforts.join(", ")}.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
setApiTopicConfig(topic.id, { ...cfg, effort });
|
|
227
|
+
WsHub.get().broadcastTopicUpdated(topic.id);
|
|
228
|
+
ctx.onConfigChanged?.("effort");
|
|
229
|
+
return ok(
|
|
230
|
+
`Effort for this topic set to '${effort}' (agent=${agent}). Applies from the next turn.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getSelfConfigEffort(ctx: SelfConfigContext): SelfConfigResult {
|
|
235
|
+
const topic = requireTopic(ctx);
|
|
236
|
+
if (isResult(topic)) return topic;
|
|
237
|
+
|
|
238
|
+
const cfg = getApiTopicConfig(topic.id);
|
|
239
|
+
const agent = currentAgent(topic);
|
|
240
|
+
const registry = getRegistry(agent);
|
|
241
|
+
const fallback = registry.defaultEffort ? `default (${registry.defaultEffort})` : "default (off)";
|
|
242
|
+
const value = cfg?.effort ?? fallback;
|
|
243
|
+
const lock = cfg?.effortLocked ? " [locked by user]" : "";
|
|
244
|
+
return ok(`Effort (agent=${agent}): ${value}${lock}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Persist the topic's sole pending one-shot continuation. The inbox worker
|
|
249
|
+
* claims due rows only when the room is idle and keeps running claims durable
|
|
250
|
+
* until the turn settles.
|
|
251
|
+
*/
|
|
252
|
+
export function scheduleSelfConfigContinue(
|
|
253
|
+
ctx: SelfConfigContext,
|
|
254
|
+
delaySeconds: number,
|
|
255
|
+
message: string,
|
|
256
|
+
nowMs = Date.now(),
|
|
257
|
+
): SelfConfigResult {
|
|
258
|
+
const topic = requireTopic(ctx);
|
|
259
|
+
if (isResult(topic)) return topic;
|
|
260
|
+
if (
|
|
261
|
+
!Number.isInteger(delaySeconds) ||
|
|
262
|
+
delaySeconds < 1 ||
|
|
263
|
+
delaySeconds > SELF_SCHEDULE_MAX_DELAY_SECONDS
|
|
264
|
+
) {
|
|
265
|
+
return err(`delay_seconds must be an integer from 1 to ${SELF_SCHEDULE_MAX_DELAY_SECONDS}.`);
|
|
266
|
+
}
|
|
267
|
+
const cleanMessage = message.trim();
|
|
268
|
+
if (!cleanMessage) return err("message is required.");
|
|
269
|
+
if (cleanMessage.length > SELF_SCHEDULE_MAX_MESSAGE_LENGTH) {
|
|
270
|
+
return err(`message must be ${SELF_SCHEDULE_MAX_MESSAGE_LENGTH} characters or fewer.`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const deliverAtMs = nowMs + delaySeconds * 1000;
|
|
274
|
+
const created = createPendingSelfSchedule({
|
|
275
|
+
topicId: topic.id,
|
|
276
|
+
userId: ctx.userId,
|
|
277
|
+
message: cleanMessage,
|
|
278
|
+
deliverAt: deliverAtMs,
|
|
279
|
+
now: nowMs,
|
|
280
|
+
});
|
|
281
|
+
if (!created.ok) {
|
|
282
|
+
return err(
|
|
283
|
+
`This topic already has a pending self-schedule (${created.existing.id}) for ` +
|
|
284
|
+
`${new Date(created.existing.deliverAt).toISOString()}. ` +
|
|
285
|
+
"Use update_self_schedule or cancel_self_schedule instead of creating another one.",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const deliverAt = new Date(deliverAtMs).toISOString();
|
|
289
|
+
return ok(
|
|
290
|
+
`Scheduled this topic to resume in ${delaySeconds} seconds at ${deliverAt}. ` +
|
|
291
|
+
`Schedule ID: ${created.schedule.id}. ` +
|
|
292
|
+
`The continuation is durable across node restarts (delivery granularity is about 5 seconds).`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function getSelfConfigSchedule(
|
|
297
|
+
ctx: SelfConfigContext,
|
|
298
|
+
nowMs = Date.now(),
|
|
299
|
+
): SelfConfigResult {
|
|
300
|
+
const topic = requireAccessibleTopic(ctx);
|
|
301
|
+
if (isResult(topic)) return topic;
|
|
302
|
+
const schedule = getPendingSelfSchedule(topic.id);
|
|
303
|
+
if (!schedule) return ok("This topic has no pending self-schedule.");
|
|
304
|
+
const remainingSeconds = Math.max(0, Math.ceil((schedule.deliverAt - nowMs) / 1000));
|
|
305
|
+
return ok(
|
|
306
|
+
[
|
|
307
|
+
`Pending self-schedule: ${schedule.id}`,
|
|
308
|
+
`Deliver at: ${new Date(schedule.deliverAt).toISOString()} (in about ${remainingSeconds} seconds)`,
|
|
309
|
+
`Message: ${schedule.message}`,
|
|
310
|
+
].join("\n"),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function updateSelfConfigSchedule(
|
|
315
|
+
ctx: SelfConfigContext,
|
|
316
|
+
scheduleId: string,
|
|
317
|
+
updates: { delaySeconds?: number; message?: string },
|
|
318
|
+
nowMs = Date.now(),
|
|
319
|
+
): SelfConfigResult {
|
|
320
|
+
const topic = requireAccessibleTopic(ctx);
|
|
321
|
+
if (isResult(topic)) return topic;
|
|
322
|
+
if (updates.delaySeconds === undefined && updates.message === undefined) {
|
|
323
|
+
return err("Provide delay_seconds, message, or both.");
|
|
324
|
+
}
|
|
325
|
+
if (
|
|
326
|
+
updates.delaySeconds !== undefined &&
|
|
327
|
+
(!Number.isInteger(updates.delaySeconds) ||
|
|
328
|
+
updates.delaySeconds < 1 ||
|
|
329
|
+
updates.delaySeconds > SELF_SCHEDULE_MAX_DELAY_SECONDS)
|
|
330
|
+
) {
|
|
331
|
+
return err(`delay_seconds must be an integer from 1 to ${SELF_SCHEDULE_MAX_DELAY_SECONDS}.`);
|
|
332
|
+
}
|
|
333
|
+
let cleanMessage: string | undefined;
|
|
334
|
+
if (updates.message !== undefined) {
|
|
335
|
+
cleanMessage = updates.message.trim();
|
|
336
|
+
if (!cleanMessage) return err("message is required when provided.");
|
|
337
|
+
if (cleanMessage.length > SELF_SCHEDULE_MAX_MESSAGE_LENGTH) {
|
|
338
|
+
return err(`message must be ${SELF_SCHEDULE_MAX_MESSAGE_LENGTH} characters or fewer.`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const updated = updatePendingSelfSchedule({
|
|
343
|
+
topicId: topic.id,
|
|
344
|
+
scheduleId,
|
|
345
|
+
message: cleanMessage,
|
|
346
|
+
deliverAt: updates.delaySeconds === undefined ? undefined : nowMs + updates.delaySeconds * 1000,
|
|
347
|
+
now: nowMs,
|
|
348
|
+
});
|
|
349
|
+
if (!updated) {
|
|
350
|
+
return err(
|
|
351
|
+
`Pending self-schedule '${scheduleId}' was not found. It may have been cancelled, replaced, or already started.`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
return ok(
|
|
355
|
+
`Updated self-schedule ${updated.id}. It will resume at ${new Date(updated.deliverAt).toISOString()}.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function cancelSelfConfigSchedule(
|
|
360
|
+
ctx: SelfConfigContext,
|
|
361
|
+
scheduleId: string,
|
|
362
|
+
): SelfConfigResult {
|
|
363
|
+
const topic = requireAccessibleTopic(ctx);
|
|
364
|
+
if (isResult(topic)) return topic;
|
|
365
|
+
if (!cancelPendingSelfSchedule(topic.id, scheduleId)) {
|
|
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(`Cancelled self-schedule ${scheduleId}.`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function spawnSelfConfigTopic(
|
|
374
|
+
ctx: SelfConfigContext,
|
|
375
|
+
name?: string,
|
|
376
|
+
): Promise<SelfConfigResult> {
|
|
377
|
+
const topic = requireTopic(ctx);
|
|
378
|
+
if (isResult(topic)) return topic;
|
|
379
|
+
|
|
380
|
+
let derived: TopicDto | null;
|
|
381
|
+
try {
|
|
382
|
+
derived = await createDerivedTopic(topic.id, ctx.userId, false, {
|
|
383
|
+
name,
|
|
384
|
+
allowActiveSource: true,
|
|
385
|
+
});
|
|
386
|
+
} catch (e) {
|
|
387
|
+
if (e instanceof TopicTitleConflictError) return err(`${e.message} — pick a different name.`);
|
|
388
|
+
throw e;
|
|
389
|
+
}
|
|
390
|
+
if (!derived) return err("Failed to spawn topic (source not found or permission denied).");
|
|
391
|
+
return ok(`Spawned new topic "${derived.title}".\nLink: ${topicMarkdownLink(derived.id)}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function forkSelfConfigTopic(
|
|
395
|
+
ctx: SelfConfigContext,
|
|
396
|
+
name?: string,
|
|
397
|
+
): Promise<SelfConfigResult> {
|
|
398
|
+
const topic = requireTopic(ctx);
|
|
399
|
+
if (isResult(topic)) return topic;
|
|
400
|
+
|
|
401
|
+
let derived: TopicDto | null;
|
|
402
|
+
try {
|
|
403
|
+
derived = await createDerivedTopic(topic.id, ctx.userId, true, {
|
|
404
|
+
name,
|
|
405
|
+
allowActiveSource: true,
|
|
406
|
+
});
|
|
407
|
+
} catch (e) {
|
|
408
|
+
if (e instanceof TopicTitleConflictError) return err(`${e.message} — pick a different name.`);
|
|
409
|
+
throw e;
|
|
410
|
+
}
|
|
411
|
+
if (!derived) return err("Failed to fork topic (source not found or permission denied).");
|
|
412
|
+
return ok(
|
|
413
|
+
`Forked new topic "${derived.title}". History copied.\nLink: ${topicMarkdownLink(derived.id)}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readTasks, taskFileMtimeNs, taskScopeKey } from "#storage/tasks";
|
|
2
|
+
import type { AgentQueryOptions, UnifiedEvent } from "#types";
|
|
3
|
+
|
|
4
|
+
export interface TaskEventScope {
|
|
5
|
+
userId: string;
|
|
6
|
+
scopeKey: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function resolveTaskEventScope(opts: AgentQueryOptions): TaskEventScope | null {
|
|
10
|
+
if (opts.silent) return null;
|
|
11
|
+
if (!opts.userId) return null;
|
|
12
|
+
if (opts.sessionType === "dm" || opts.sessionType === "ephemeral") {
|
|
13
|
+
return { userId: opts.userId, scopeKey: "dm" };
|
|
14
|
+
}
|
|
15
|
+
if (opts.sessionType === "manager") {
|
|
16
|
+
return { userId: opts.userId, scopeKey: opts.topicId ?? "general" };
|
|
17
|
+
}
|
|
18
|
+
if (!opts.session) return null;
|
|
19
|
+
return {
|
|
20
|
+
userId: opts.userId,
|
|
21
|
+
scopeKey: taskScopeKey({ topicId: opts.topicId, session: opts.session }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The task MCP server runs as a child process of the agent SDK. Its writes do
|
|
27
|
+
* not appear in the provider stream, so after tool results and terminal
|
|
28
|
+
* results we stat the shared store and inject the full task snapshot when it
|
|
29
|
+
* changed.
|
|
30
|
+
*/
|
|
31
|
+
export async function* withTaskSnapshots(
|
|
32
|
+
inner: AsyncGenerator<UnifiedEvent>,
|
|
33
|
+
scope: TaskEventScope,
|
|
34
|
+
): AsyncGenerator<UnifiedEvent> {
|
|
35
|
+
let lastMtime = taskFileMtimeNs(scope.userId, scope.scopeKey);
|
|
36
|
+
for await (const event of inner) {
|
|
37
|
+
yield event;
|
|
38
|
+
if (event.type !== "tool_result" && event.type !== "result") continue;
|
|
39
|
+
const mtime = taskFileMtimeNs(scope.userId, scope.scopeKey);
|
|
40
|
+
if (mtime === lastMtime) continue;
|
|
41
|
+
lastMtime = mtime;
|
|
42
|
+
yield { type: "tasks", tasks: readTasks(scope.userId, scope.scopeKey) };
|
|
43
|
+
}
|
|
44
|
+
}
|