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
@@ -13,8 +13,8 @@ import { getTopicBrief, setTopicBrief } from "#storage/api-topic-brief";
13
13
  import { getSharedWikiDir } from "#storage/wiki";
14
14
  import { wikiSummaryFilename } from "#storage/wiki-summary-names";
15
15
  import { ensurePersonalGeneral } from "#topics/personal-general";
16
- import type { AgentKind } from "#types";
17
- import type { BackgroundSessionDto } from "#types/api";
16
+ import type { AgentKind, AgentQueryOptions, UnifiedEvent } from "#types";
17
+ import type { BackgroundSessionDto, MessageDto } from "#types/api";
18
18
 
19
19
  /**
20
20
  * Rolling cap on the #General digest. The General-brief injection embeds
@@ -26,11 +26,9 @@ const MAX_BRIEF_ENTRIES = 8;
26
26
  interface ActiveArchiverSession extends BackgroundSessionDto {
27
27
  userId: string;
28
28
  expiresAt?: number;
29
- expiryTimer?: ReturnType<typeof setTimeout>;
29
+ expiryTimer?: unknown;
30
30
  }
31
31
 
32
- const activeArchiverSessions = new Map<string, ActiveArchiverSession>();
33
-
34
32
  function sessionText(value: string, maxLength?: number): string {
35
33
  const normalized = value.replace(/\s+/g, " ").trim();
36
34
  return maxLength === undefined ? normalized : normalized.slice(0, maxLength);
@@ -52,42 +50,6 @@ function formatArchiverTool(name: string, input: Record<string, unknown>): strin
52
50
  return `${name}${params ? `(${params})` : ""}`;
53
51
  }
54
52
 
55
- function updateArchiverSession(id: string, status: string, step?: string): void {
56
- const session = activeArchiverSessions.get(id);
57
- if (!session) return;
58
- session.status = sessionText(status, 160) || session.status;
59
- if (step) {
60
- const text = sessionText(step);
61
- if (text && session.steps.at(-1) !== text) {
62
- session.steps.push(text);
63
- }
64
- }
65
- }
66
-
67
- export function listActiveMemoryArchiverSessions(userId: string): BackgroundSessionDto[] {
68
- const now = Date.now();
69
- for (const [id, session] of activeArchiverSessions) {
70
- if (session.expiresAt !== undefined && session.expiresAt <= now) {
71
- if (session.expiryTimer) clearTimeout(session.expiryTimer);
72
- activeArchiverSessions.delete(id);
73
- }
74
- }
75
- return [...activeArchiverSessions.values()]
76
- .filter((session) => session.userId === userId)
77
- .map(({ userId: _userId, expiresAt: _expiresAt, expiryTimer: _expiryTimer, ...session }) => ({
78
- ...session,
79
- steps: [...session.steps],
80
- }));
81
- }
82
-
83
- // The wiki-archiver prompt is loaded once and cached — a missing/!malformed
84
- // file must not crash the delete path, so loading is wrapped at call sites.
85
- let _archiverDef: AgentDef | null = null;
86
- function getArchiverDef(): AgentDef {
87
- if (!_archiverDef) _archiverDef = loadAgentPrompt("wiki-archiver.md");
88
- return _archiverDef;
89
- }
90
-
91
53
  export interface RunArchiverTurnParams {
92
54
  /** Stringified user id (route layer hands us strings). */
93
55
  userId: string;
@@ -118,253 +80,350 @@ interface GeneralArchiverReply {
118
80
  usage?: { input: number; output: number };
119
81
  }
120
82
 
121
- /**
122
- * Fire a background wiki-archiver turn for a deleted topic or active idle
123
- * snapshot.
124
- *
125
- * Reads the forensic archive file produced by `archiveTopicMessages` and
126
- * distils it into the **shared** wiki (summaries / articles / topic brief /
127
- * indexes / skills) via the wiki MCP, which the turn launches in topic-id mode
128
- * so its writes land in the same root normal forum turns query.
129
- *
130
- * Fire-and-forget: the async generator is drained on a detached promise so the
131
- * caller's HTTP response returns immediately. Every failure is best-effort and
132
- * logged only — a broken archiver must never block or fail a topic deletion.
133
- */
134
- export function runArchiverTurn(params: RunArchiverTurnParams): boolean {
135
- const {
136
- userId,
137
- topicId,
138
- topicTitle,
139
- archivePath,
140
- rawArchivePaths = [],
141
- messageCount,
142
- mode = "deleted-topic",
143
- } = params;
144
-
145
- let archiverDef: AgentDef;
146
- try {
147
- archiverDef = getArchiverDef();
148
- } catch (err) {
149
- logger.warn({ err }, "archiver: failed to load wiki-archiver.md — skipping");
150
- return false;
151
- }
83
+ export interface ArchiverStorageHost {
84
+ getWikiDir(): string;
85
+ fileExists(path: string): boolean;
86
+ listDirectory(path: string): string[];
87
+ readTextFile(path: string): string;
88
+ fileSize(path: string): number;
89
+ fileModifiedAt(path: string): number;
90
+ getGeneralTopicId(userId: string): string;
91
+ getTopicBrief(topicId: string): { briefMd: string } | null;
92
+ setTopicBrief(
93
+ topicId: string,
94
+ fields: { briefMd: string; latestSummaryMd?: string; summaryDate?: string },
95
+ ): void;
96
+ }
152
97
 
153
- const wikiDir = getSharedWikiDir();
154
- const safeTopic = sanitizeTopicName(topicTitle, true);
155
- // Keep the established Claude default for archive quality and compatibility.
156
- // Every provider now receives the same host-resolved wiki MCP; callers may
157
- // override both agent and model as a matching pair when desired.
158
- const agent: AgentKind = params.agent ?? "claude";
159
- const model = params.model;
160
-
161
- const prompt =
162
- mode === "active-topic"
163
- ? [
164
- `세션 "${topicTitle}" 의 최근 idle 대화 snapshot입니다. 아래 아카이브에서 기억을 추출해 이 토픽 위키에 저장해줘.`,
165
- `archive_path: ${archivePath}`,
166
- ...rawArchivePaths.map((path) => `raw_archive_path: ${path}`),
167
- `wiki_dir: ${wikiDir}`,
168
- ].join("\n")
169
- : [
170
- `세션 "${topicTitle}" 이(가) 삭제되었습니다. 아래 아카이브에서 기억을 추출해 위키에 저장해줘.`,
171
- `archive_path: ${archivePath}`,
172
- ...rawArchivePaths.map((path) => `raw_archive_path: ${path}`),
173
- `wiki_dir: ${wikiDir}`,
174
- "",
175
- "#General에 표시될 짧은 한국어 완료 메시지로 최종 응답해줘. " +
176
- "도구 호출 로그나 원문 전문은 쓰지 말고, 저장한 요약/브리프/문서만 간단히 말해줘.",
177
- ].join("\n");
178
-
179
- // cwd = workspace root so the archiver's relative `wiki/...` Glob/Read paths
180
- // resolve against the same shared wiki the MCP writes to.
181
- const abortController = new AbortController();
182
- const events = runAgent({
183
- agent,
184
- prompt,
185
- cwd: WORKSPACE_DIR,
186
- systemPrompt: archiverDef.prompt,
187
- userId,
188
- // Throwaway session name → wiki MCP runs in topic-id (shared-root) mode.
189
- session: `__archiver_${safeTopic}`,
190
- sessionType: "forum",
191
- topicId,
192
- abortController,
193
- model,
194
- // Limit MCP surface to the wiki server (no playwright/bg-bash/etc.).
195
- mcpEnabled: ["wiki"],
196
- // Hidden run: don't record to the cross-agent conversation log.
197
- silent: true,
198
- });
98
+ export interface ArchiverMessagingHost {
99
+ appendMessage(message: MessageDto): void;
100
+ broadcastMessage(topicId: string, message: MessageDto): void;
101
+ }
199
102
 
200
- const activeSessionId = `memory:${randomUUID()}`;
201
- let archiveBytes = 0;
202
- try {
203
- archiveBytes = statSync(archivePath).size;
204
- } catch {
205
- // The provider will report the actionable read failure.
206
- }
207
- activeArchiverSessions.set(activeSessionId, {
208
- id: activeSessionId,
209
- kind: "memory",
210
- title: `Archive ${topicTitle}`,
211
- topicId,
212
- userId,
213
- startedAt: new Date().toISOString(),
214
- status: "Starting",
215
- active: true,
216
- agent,
217
- model: model ?? archiverDef.model,
218
- prompt,
219
- promptTitle: "Prompt",
220
- steps: [
221
- `Archive prepared · ${messageCount.toLocaleString()} messages · ${formatArchiverBytes(archiveBytes)}`,
222
- ],
223
- });
103
+ export interface ArchiverConfigHost {
104
+ readonly workspaceDir: string;
105
+ readonly completedSessionRetentionMs: number;
106
+ loadAgentDefinition(): AgentDef;
107
+ sanitizeTopicName(topicTitle: string): string;
108
+ createId(): string;
109
+ now(): Date;
110
+ schedule(callback: () => void, delayMs: number): unknown;
111
+ cancelScheduled(handle: unknown): void;
112
+ unrefScheduled?(handle: unknown): void;
113
+ info(context: Record<string, unknown>, message: string): void;
114
+ warn(context: Record<string, unknown>, message: string): void;
115
+ }
224
116
 
225
- logger.info(
226
- { userId, topicTitle, archivePath, agent, model },
227
- "archiver: starting background turn",
228
- );
229
-
230
- // Baseline for locating the summary file the turn is about to write (fallback
231
- // when the predicted filename misses). Stamped before draining.
232
- const startMs = Date.now();
233
-
234
- void (async () => {
235
- let ok = false;
236
- let sawDelta = false;
237
- let accumulatedText = "";
238
- let resultText = "";
239
- let finalText = "";
240
- let errorText = "";
241
- let usage: GeneralArchiverReply["usage"] | undefined;
242
- try {
243
- // Drain the stream — the turn's side effects (wiki writes) are the point;
244
- // only the final assistant text is surfaced to #General for deleted topics.
245
- for await (const event of events) {
246
- switch (event.type) {
247
- case "session":
248
- updateArchiverSession(activeSessionId, "Running", "Provider session started");
249
- break;
250
- case "tool_use":
251
- updateArchiverSession(
252
- activeSessionId,
253
- `Running ${event.name}`,
254
- formatArchiverTool(event.name, event.input),
255
- );
256
- break;
257
- case "tool_progress":
258
- {
259
- const progress = `${event.toolName === "thinking" ? "Thinking" : event.toolName} · ${Math.max(0, Math.floor(event.elapsed))}s`;
260
- updateArchiverSession(activeSessionId, progress, progress);
261
- }
262
- break;
263
- case "reasoning":
264
- updateArchiverSession(activeSessionId, "Reasoning", `Reasoning: ${event.content}`);
265
- break;
266
- case "tool_use_summary":
267
- updateArchiverSession(activeSessionId, event.summary, event.summary);
268
- break;
269
- case "status":
270
- updateArchiverSession(activeSessionId, event.content, event.content);
271
- break;
272
- case "tool_result":
273
- updateArchiverSession(
274
- activeSessionId,
275
- event.isError ? "Tool failed" : "Processing tool result",
276
- event.isError
277
- ? `Tool failed: ${sessionText(event.content) || "unknown error"}`
278
- : `Tool result · ${formatArchiverBytes(event.metadata?.returnedBytes ?? Buffer.byteLength(event.content))}`,
279
- );
280
- break;
281
- case "text_delta":
282
- sawDelta = true;
283
- accumulatedText += event.content;
284
- break;
285
- case "text":
286
- if (!sawDelta) accumulatedText += event.content;
287
- break;
288
- case "result":
289
- updateArchiverSession(
290
- activeSessionId,
291
- "Finalizing memory",
292
- `Memory received · ${formatArchiverBytes(Buffer.byteLength(event.content))}${
293
- event.usage
294
- ? ` · ${event.usage.inputTokens.toLocaleString()} in / ${event.usage.outputTokens.toLocaleString()} out`
295
- : ""
296
- }`,
297
- );
298
- resultText = event.content;
299
- usage = event.usage
300
- ? { input: event.usage.inputTokens, output: event.usage.outputTokens }
301
- : undefined;
302
- break;
303
- case "error":
304
- errorText = event.content;
305
- updateArchiverSession(
306
- activeSessionId,
307
- "Failed",
308
- `Memory archive failed: ${sessionText(event.content)}`,
309
- );
310
- break;
311
- default:
312
- break;
117
+ export interface ArchiverAgentRuntimeHost {
118
+ run(options: AgentQueryOptions): AsyncIterable<UnifiedEvent>;
119
+ }
120
+
121
+ export interface ArchiverHost {
122
+ storage: ArchiverStorageHost;
123
+ messaging: ArchiverMessagingHost;
124
+ config: ArchiverConfigHost;
125
+ agentRuntime: ArchiverAgentRuntimeHost;
126
+ }
127
+
128
+ export interface ArchiverRuntime {
129
+ runArchiverTurn(params: RunArchiverTurnParams): boolean;
130
+ listActiveMemoryArchiverSessions(userId: string): BackgroundSessionDto[];
131
+ }
132
+
133
+ export function createArchiverRuntime(host: ArchiverHost): ArchiverRuntime {
134
+ const activeSessions = new Map<string, ActiveArchiverSession>();
135
+ let archiverDef: AgentDef | null = null;
136
+
137
+ const updateSession = (id: string, status: string, step?: string): void => {
138
+ const session = activeSessions.get(id);
139
+ if (!session) return;
140
+ session.status = sessionText(status, 160) || session.status;
141
+ if (step) {
142
+ const text = sessionText(step);
143
+ if (text && session.steps.at(-1) !== text) {
144
+ session.steps.push(text);
145
+ }
146
+ }
147
+ };
148
+
149
+ const listSessions = (userId: string): BackgroundSessionDto[] => {
150
+ const now = host.config.now().getTime();
151
+ for (const [id, session] of activeSessions) {
152
+ if (session.expiresAt !== undefined && session.expiresAt <= now) {
153
+ if (session.expiryTimer !== undefined) {
154
+ host.config.cancelScheduled(session.expiryTimer);
313
155
  }
156
+ activeSessions.delete(id);
314
157
  }
315
- ok = !errorText;
316
- logger.info({ userId, topicTitle }, "archiver: background turn completed");
158
+ }
159
+ return [...activeSessions.values()]
160
+ .filter((session) => session.userId === userId)
161
+ .map(({ userId: _userId, expiresAt: _expiresAt, expiryTimer: _expiryTimer, ...session }) => ({
162
+ ...session,
163
+ steps: [...session.steps],
164
+ }));
165
+ };
166
+
167
+ const getAgentDefinition = (): AgentDef => {
168
+ archiverDef ??= host.config.loadAgentDefinition();
169
+ return archiverDef;
170
+ };
171
+
172
+ /**
173
+ * Fire a background wiki-archiver turn for a deleted topic or active idle
174
+ * snapshot.
175
+ *
176
+ * Reads the forensic archive file produced by `archiveTopicMessages` and
177
+ * distils it into the **shared** wiki (summaries / articles / topic brief /
178
+ * indexes / skills) via the wiki MCP, which the turn launches in topic-id mode
179
+ * so its writes land in the same root normal forum turns query.
180
+ *
181
+ * Fire-and-forget: the async generator is drained on a detached promise so the
182
+ * caller's HTTP response returns immediately. Every failure is best-effort and
183
+ * logged only — a broken archiver must never block or fail a topic deletion.
184
+ */
185
+ const runTurn = (params: RunArchiverTurnParams): boolean => {
186
+ const {
187
+ userId,
188
+ topicId,
189
+ topicTitle,
190
+ archivePath,
191
+ rawArchivePaths = [],
192
+ messageCount,
193
+ mode = "deleted-topic",
194
+ } = params;
195
+
196
+ let definition: AgentDef;
197
+ try {
198
+ definition = getAgentDefinition();
317
199
  } catch (err) {
318
- errorText = err instanceof Error ? err.message : String(err);
319
- updateArchiverSession(
320
- activeSessionId,
321
- "Failed",
322
- `Memory archive failed: ${sessionText(errorText)}`,
323
- );
324
- logger.warn({ err, userId, topicTitle }, "archiver: background turn failed");
325
- } finally {
326
- finalText = (accumulatedText.trim() ? accumulatedText : resultText).trim();
327
- const completedSession = activeArchiverSessions.get(activeSessionId);
328
- if (completedSession) {
329
- if (finalText) completedSession.output = finalText;
330
- updateArchiverSession(
200
+ host.config.warn({ err }, "archiver: failed to load wiki-archiver.md — skipping");
201
+ return false;
202
+ }
203
+
204
+ const wikiDir = host.storage.getWikiDir();
205
+ const safeTopic = host.config.sanitizeTopicName(topicTitle);
206
+ // Keep the established Claude default for archive quality and compatibility.
207
+ // Every provider now receives the same host-resolved wiki MCP; callers may
208
+ // override both agent and model as a matching pair when desired.
209
+ const agent: AgentKind = params.agent ?? "claude";
210
+ const model = params.model;
211
+
212
+ const prompt =
213
+ mode === "active-topic"
214
+ ? [
215
+ `세션 "${topicTitle}" 의 최근 idle 대화 snapshot입니다. 아래 아카이브에서 기억을 추출해 이 토픽 위키에 저장해줘.`,
216
+ `archive_path: ${archivePath}`,
217
+ ...rawArchivePaths.map((path) => `raw_archive_path: ${path}`),
218
+ `wiki_dir: ${wikiDir}`,
219
+ ].join("\n")
220
+ : [
221
+ `세션 "${topicTitle}" 이(가) 삭제되었습니다. 아래 아카이브에서 기억을 추출해 위키에 저장해줘.`,
222
+ `archive_path: ${archivePath}`,
223
+ ...rawArchivePaths.map((path) => `raw_archive_path: ${path}`),
224
+ `wiki_dir: ${wikiDir}`,
225
+ "",
226
+ "#General에 표시될 짧은 한국어 완료 메시지로 최종 응답해줘. " +
227
+ "도구 호출 로그나 원문 전문은 쓰지 말고, 저장한 요약/브리프/문서만 간단히 말해줘.",
228
+ ].join("\n");
229
+
230
+ // cwd = workspace root so the archiver's relative `wiki/...` Glob/Read paths
231
+ // resolve against the same shared wiki the MCP writes to.
232
+ const abortController = new AbortController();
233
+ const events = host.agentRuntime.run({
234
+ agent,
235
+ prompt,
236
+ cwd: host.config.workspaceDir,
237
+ systemPrompt: definition.prompt,
238
+ userId,
239
+ // Throwaway session name → wiki MCP runs in topic-id (shared-root) mode.
240
+ session: `__archiver_${safeTopic}`,
241
+ sessionType: "forum",
242
+ topicId,
243
+ abortController,
244
+ model,
245
+ // Limit MCP surface to the wiki server (no playwright/bg-bash/etc.).
246
+ mcpEnabled: ["wiki"],
247
+ // Hidden run: don't record to the cross-agent conversation log.
248
+ silent: true,
249
+ });
250
+
251
+ const activeSessionId = `memory:${host.config.createId()}`;
252
+ let archiveBytes = 0;
253
+ try {
254
+ archiveBytes = host.storage.fileSize(archivePath);
255
+ } catch {
256
+ // The provider will report the actionable read failure.
257
+ }
258
+ activeSessions.set(activeSessionId, {
259
+ id: activeSessionId,
260
+ kind: "memory",
261
+ title: `Archive ${topicTitle}`,
262
+ topicId,
263
+ userId,
264
+ startedAt: host.config.now().toISOString(),
265
+ status: "Starting",
266
+ active: true,
267
+ agent,
268
+ model: model ?? definition.model,
269
+ prompt,
270
+ promptTitle: "Prompt",
271
+ steps: [
272
+ `Archive prepared · ${messageCount.toLocaleString()} messages · ${formatArchiverBytes(archiveBytes)}`,
273
+ ],
274
+ });
275
+
276
+ host.config.info(
277
+ { userId, topicTitle, archivePath, agent, model },
278
+ "archiver: starting background turn",
279
+ );
280
+
281
+ // Baseline for locating the summary file the turn is about to write (fallback
282
+ // when the predicted filename misses). Stamped before draining.
283
+ const startMs = host.config.now().getTime();
284
+
285
+ void (async () => {
286
+ let ok = false;
287
+ let sawDelta = false;
288
+ let accumulatedText = "";
289
+ let resultText = "";
290
+ let finalText = "";
291
+ let errorText = "";
292
+ let usage: GeneralArchiverReply["usage"] | undefined;
293
+ try {
294
+ // Drain the stream — the turn's side effects (wiki writes) are the point;
295
+ // only the final assistant text is surfaced to #General for deleted topics.
296
+ for await (const event of events) {
297
+ switch (event.type) {
298
+ case "session":
299
+ updateSession(activeSessionId, "Running", "Provider session started");
300
+ break;
301
+ case "tool_use":
302
+ updateSession(
303
+ activeSessionId,
304
+ `Running ${event.name}`,
305
+ formatArchiverTool(event.name, event.input),
306
+ );
307
+ break;
308
+ case "tool_progress":
309
+ {
310
+ const progress = `${event.toolName === "thinking" ? "Thinking" : event.toolName} · ${Math.max(0, Math.floor(event.elapsed))}s`;
311
+ updateSession(activeSessionId, progress, progress);
312
+ }
313
+ break;
314
+ case "reasoning":
315
+ updateSession(activeSessionId, "Reasoning", `Reasoning: ${event.content}`);
316
+ break;
317
+ case "tool_use_summary":
318
+ updateSession(activeSessionId, event.summary, event.summary);
319
+ break;
320
+ case "status":
321
+ updateSession(activeSessionId, event.content, event.content);
322
+ break;
323
+ case "tool_result":
324
+ updateSession(
325
+ activeSessionId,
326
+ event.isError ? "Tool failed" : "Processing tool result",
327
+ event.isError
328
+ ? `Tool failed: ${sessionText(event.content) || "unknown error"}`
329
+ : `Tool result · ${formatArchiverBytes(event.metadata?.returnedBytes ?? Buffer.byteLength(event.content))}`,
330
+ );
331
+ break;
332
+ case "text_delta":
333
+ sawDelta = true;
334
+ accumulatedText += event.content;
335
+ break;
336
+ case "text":
337
+ if (!sawDelta) accumulatedText += event.content;
338
+ break;
339
+ case "result":
340
+ updateSession(
341
+ activeSessionId,
342
+ "Finalizing memory",
343
+ `Memory received · ${formatArchiverBytes(Buffer.byteLength(event.content))}${
344
+ event.usage
345
+ ? ` · ${event.usage.inputTokens.toLocaleString()} in / ${event.usage.outputTokens.toLocaleString()} out`
346
+ : ""
347
+ }`,
348
+ );
349
+ resultText = event.content;
350
+ usage = event.usage
351
+ ? { input: event.usage.inputTokens, output: event.usage.outputTokens }
352
+ : undefined;
353
+ break;
354
+ case "error":
355
+ errorText = event.content;
356
+ updateSession(
357
+ activeSessionId,
358
+ "Failed",
359
+ `Memory archive failed: ${sessionText(event.content)}`,
360
+ );
361
+ break;
362
+ default:
363
+ break;
364
+ }
365
+ }
366
+ ok = !errorText;
367
+ host.config.info({ userId, topicTitle }, "archiver: background turn completed");
368
+ } catch (err) {
369
+ errorText = err instanceof Error ? err.message : String(err);
370
+ updateSession(
331
371
  activeSessionId,
332
- ok ? "Completed" : "Failed",
333
- ok
334
- ? `Memory archive completed · ${formatArchiverBytes(Buffer.byteLength(finalText))}`
335
- : `Memory archive failed: ${sessionText(errorText) || "unknown error"}`,
372
+ "Failed",
373
+ `Memory archive failed: ${sessionText(errorText)}`,
336
374
  );
337
- completedSession.active = false;
338
- completedSession.expiresAt = Date.now() + COMPLETED_BACKGROUND_SESSION_RETENTION_MS;
339
- completedSession.expiryTimer = setTimeout(() => {
340
- if (activeArchiverSessions.get(activeSessionId) === completedSession) {
341
- activeArchiverSessions.delete(activeSessionId);
342
- }
343
- }, COMPLETED_BACKGROUND_SESSION_RETENTION_MS);
344
- completedSession.expiryTimer.unref?.();
375
+ host.config.warn({ err, userId, topicTitle }, "archiver: background turn failed");
376
+ } finally {
377
+ finalText = (accumulatedText.trim() ? accumulatedText : resultText).trim();
378
+ const completedSession = activeSessions.get(activeSessionId);
379
+ if (completedSession) {
380
+ if (finalText) completedSession.output = finalText;
381
+ updateSession(
382
+ activeSessionId,
383
+ ok ? "Completed" : "Failed",
384
+ ok
385
+ ? `Memory archive completed · ${formatArchiverBytes(Buffer.byteLength(finalText))}`
386
+ : `Memory archive failed: ${sessionText(errorText) || "unknown error"}`,
387
+ );
388
+ completedSession.active = false;
389
+ completedSession.expiresAt =
390
+ host.config.now().getTime() + host.config.completedSessionRetentionMs;
391
+ completedSession.expiryTimer = host.config.schedule(() => {
392
+ if (activeSessions.get(activeSessionId) === completedSession) {
393
+ activeSessions.delete(activeSessionId);
394
+ }
395
+ }, host.config.completedSessionRetentionMs);
396
+ host.config.unrefScheduled?.(completedSession.expiryTimer);
397
+ }
398
+ try {
399
+ params.onSettled?.(ok);
400
+ } catch (err) {
401
+ host.config.warn({ err, userId, topicTitle }, "archiver: settlement callback failed");
402
+ }
345
403
  }
346
- try {
347
- params.onSettled?.(ok);
348
- } catch (err) {
349
- logger.warn({ err, userId, topicTitle }, "archiver: settlement callback failed");
404
+ if (mode === "deleted-topic") {
405
+ // Roll the deleted topic into the #General memory hub regardless of LLM
406
+ // success — even a failed distillation should leave a digest breadcrumb.
407
+ const text = finalText.trimEnd();
408
+ finalizeGeneralMemory(
409
+ host,
410
+ userId,
411
+ topicTitle,
412
+ messageCount,
413
+ startMs,
414
+ ok,
415
+ topicId,
416
+ ok && text ? { text, agent, model, usage } : undefined,
417
+ );
350
418
  }
351
- }
352
- if (mode === "deleted-topic") {
353
- // Roll the deleted topic into the #General memory hub regardless of LLM
354
- // success — even a failed distillation should leave a digest breadcrumb.
355
- const text = finalText.trimEnd();
356
- finalizeGeneralMemory(
357
- userId,
358
- topicTitle,
359
- messageCount,
360
- startMs,
361
- ok,
362
- topicId,
363
- ok && text ? { text, agent, model, usage } : undefined,
364
- );
365
- }
366
- })();
367
- return true;
419
+ })();
420
+ return true;
421
+ };
422
+
423
+ return Object.freeze({
424
+ runArchiverTurn: runTurn,
425
+ listActiveMemoryArchiverSessions: listSessions,
426
+ });
368
427
  }
369
428
 
370
429
  // --- #General memory-hub digest ------------------------------------------
@@ -386,23 +445,24 @@ function distillOneLine(summaryMd: string): string {
386
445
  * when one is available and by title for legacy/ephemeral fallback paths.
387
446
  */
388
447
  function findSummaryFile(
448
+ storage: ArchiverStorageHost,
389
449
  topicTitle: string,
390
450
  date: string,
391
451
  sinceMs: number,
392
452
  topicId?: string,
393
453
  ): string | null {
394
- const dir = join(getSharedWikiDir(), "summaries");
395
- if (!existsSync(dir)) return null;
454
+ const dir = join(storage.getWikiDir(), "summaries");
455
+ if (!storage.fileExists(dir)) return null;
396
456
 
397
457
  const predicted = join(dir, wikiSummaryFilename(date, topicTitle, topicId));
398
- if (existsSync(predicted)) return predicted;
458
+ if (storage.fileExists(predicted)) return predicted;
399
459
 
400
460
  let best: { path: string; mtime: number } | null = null;
401
- for (const f of readdirSync(dir)) {
461
+ for (const f of storage.listDirectory(dir)) {
402
462
  if (!f.endsWith(".md")) continue;
403
463
  const p = join(dir, f);
404
464
  try {
405
- const m = statSync(p).mtimeMs;
465
+ const m = storage.fileModifiedAt(p);
406
466
  if (m >= sinceMs && (!best || m > best.mtime)) best = { path: p, mtime: m };
407
467
  } catch {
408
468
  /* skip unreadable */
@@ -418,6 +478,7 @@ function findSummaryFile(
418
478
  * the user's private hub. Best-effort — never throws.
419
479
  */
420
480
  function finalizeGeneralMemory(
481
+ host: ArchiverHost,
421
482
  userId: string,
422
483
  topicTitle: string,
423
484
  messageCount: number,
@@ -426,15 +487,17 @@ function finalizeGeneralMemory(
426
487
  topicId?: string,
427
488
  generalReply?: GeneralArchiverReply,
428
489
  ): void {
429
- const generalTopicId = ensurePersonalGeneral(userId).id;
430
- const date = new Date().toISOString().slice(0, 10);
490
+ const generalTopicId = host.storage.getGeneralTopicId(userId);
491
+ const date = host.config.now().toISOString().slice(0, 10);
431
492
  try {
432
- const summaryPath = ok ? findSummaryFile(topicTitle, date, startMs, topicId) : null;
433
- const summaryMd = summaryPath ? readFileSync(summaryPath, "utf-8") : "";
493
+ const summaryPath = ok
494
+ ? findSummaryFile(host.storage, topicTitle, date, startMs, topicId)
495
+ : null;
496
+ const summaryMd = summaryPath ? host.storage.readTextFile(summaryPath) : "";
434
497
  const oneLine = (summaryMd && distillOneLine(summaryMd)) || `${messageCount}개 메시지 아카이브`;
435
498
 
436
499
  // Rolling digest: dedupe same-title, prepend newest, cap at MAX_BRIEF_ENTRIES.
437
- const prev = getTopicBrief(generalTopicId);
500
+ const prev = host.storage.getTopicBrief(generalTopicId);
438
501
  const prevEntries = (prev?.briefMd ?? "")
439
502
  .split("\n")
440
503
  .map((l) => l.trim())
@@ -446,16 +509,16 @@ function finalizeGeneralMemory(
446
509
  ].slice(0, MAX_BRIEF_ENTRIES);
447
510
  const briefMd = `# 워크스페이스 메모리 허브\n\n삭제된 토픽에서 추출한 최근 기억 다이제스트입니다. 자세한 내용은 \`wiki_query\`로 조회하세요.\n\n## 최근 아카이브\n${rolled.join("\n")}`;
448
511
 
449
- setTopicBrief(generalTopicId, {
512
+ host.storage.setTopicBrief(generalTopicId, {
450
513
  briefMd,
451
514
  ...(summaryMd ? { latestSummaryMd: summaryMd, summaryDate: date } : {}),
452
515
  });
453
- logger.info(
516
+ host.config.info(
454
517
  { topicTitle, summaryPath, entries: rolled.length },
455
518
  "archiver: updated #General memory hub brief",
456
519
  );
457
520
  } catch (err) {
458
- logger.warn({ err, topicTitle }, "archiver: failed to update #General brief");
521
+ host.config.warn({ err, topicTitle }, "archiver: failed to update #General brief");
459
522
  }
460
523
 
461
524
  // (a) Visible completion message in #General so participants see the hub absorbed the topic.
@@ -478,15 +541,63 @@ function finalizeGeneralMemory(
478
541
  }
479
542
  : { authorId: "system" };
480
543
  const msg = {
481
- id: randomUUID(),
544
+ id: host.config.createId(),
482
545
  topicId: generalTopicId,
483
546
  text,
484
547
  ...replyMeta,
485
- createdAt: new Date().toISOString(),
548
+ createdAt: host.config.now().toISOString(),
486
549
  };
487
- appendApiMessage(msg);
488
- WsHub.get().broadcastMessage(generalTopicId, msg);
550
+ host.messaging.appendMessage(msg);
551
+ host.messaging.broadcastMessage(generalTopicId, msg);
489
552
  } catch (err) {
490
- logger.warn({ err, topicTitle }, "archiver: failed to post #General notification");
553
+ host.config.warn({ err, topicTitle }, "archiver: failed to post #General notification");
491
554
  }
492
555
  }
556
+
557
+ const defaultArchiverRuntime = createArchiverRuntime({
558
+ storage: {
559
+ getWikiDir: getSharedWikiDir,
560
+ fileExists: existsSync,
561
+ listDirectory: readdirSync,
562
+ readTextFile: (path) => readFileSync(path, "utf-8"),
563
+ fileSize: (path) => statSync(path).size,
564
+ fileModifiedAt: (path) => statSync(path).mtimeMs,
565
+ getGeneralTopicId: (userId) => ensurePersonalGeneral(userId).id,
566
+ getTopicBrief,
567
+ setTopicBrief: (topicId, fields) => {
568
+ setTopicBrief(topicId, fields);
569
+ },
570
+ },
571
+ messaging: {
572
+ appendMessage: appendApiMessage,
573
+ broadcastMessage: (topicId, message) => {
574
+ WsHub.get().broadcastMessage(topicId, message);
575
+ },
576
+ },
577
+ config: {
578
+ workspaceDir: WORKSPACE_DIR,
579
+ completedSessionRetentionMs: COMPLETED_BACKGROUND_SESSION_RETENTION_MS,
580
+ loadAgentDefinition: () => loadAgentPrompt("wiki-archiver.md"),
581
+ sanitizeTopicName: (topicTitle) => sanitizeTopicName(topicTitle, true),
582
+ createId: randomUUID,
583
+ now: () => new Date(),
584
+ schedule: (callback, delayMs) => setTimeout(callback, delayMs),
585
+ cancelScheduled: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
586
+ unrefScheduled: (handle) => {
587
+ (handle as ReturnType<typeof setTimeout>).unref?.();
588
+ },
589
+ info: (context, message) => logger.info(context, message),
590
+ warn: (context, message) => logger.warn(context, message),
591
+ },
592
+ agentRuntime: {
593
+ run: runAgent,
594
+ },
595
+ });
596
+
597
+ export function runArchiverTurn(params: RunArchiverTurnParams): boolean {
598
+ return defaultArchiverRuntime.runArchiverTurn(params);
599
+ }
600
+
601
+ export function listActiveMemoryArchiverSessions(userId: string): BackgroundSessionDto[] {
602
+ return defaultArchiverRuntime.listActiveMemoryArchiverSessions(userId);
603
+ }