@dmsdc-ai/aigentry-deliberation 0.0.39 → 0.0.40

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/lib/session.js ADDED
@@ -0,0 +1,623 @@
1
+ /**
2
+ * Session State Machine domain — session CRUD, lifecycle, prompt building,
3
+ * markdown sync, archival, and core turn submission.
4
+ *
5
+ * Extracted from index.js to keep the main entry point focused on MCP tool
6
+ * registration while this module owns all session state management logic.
7
+ */
8
+
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import os from "os";
12
+
13
+ // ── Direct imports from sibling modules ──────────────────────────
14
+ import {
15
+ completePendingTeleptySemantic,
16
+ notifyTeleptyBus,
17
+ notifyTeleptySessionInject,
18
+ buildTeleptyTurnCompletedEnvelope,
19
+ getDefaultOrchestratorSessionId,
20
+ buildTurnCompletionNotificationText,
21
+ } from "./telepty.js";
22
+ import {
23
+ selectNextSpeaker,
24
+ buildSpeakerOrder,
25
+ loadRolePrompt,
26
+ normalizeSpeaker,
27
+ parseVotes,
28
+ inferSuggestedRole,
29
+ normalizeSessionActors,
30
+ } from "./speaker-discovery.js";
31
+ import { t } from "../i18n.js";
32
+
33
+ // ── Dependency injection ────────────────────────────────────────
34
+ // Functions that live in index.js but are needed here. Injected once
35
+ // via `initSessionDeps()` so we avoid circular imports.
36
+
37
+ let _deps = {
38
+ appendRuntimeLog: () => {},
39
+ writeTextAtomic: () => {},
40
+ readJsonFileSafe: () => null,
41
+ writeJsonFileAtomic: () => {},
42
+ withSessionLock: (ref, fn) => fn(),
43
+ getProjectSlug: () => path.basename(process.cwd()),
44
+ normalizeProjectSlug: (v) => (typeof v === "string" && v.trim()) ? v.trim() : path.basename(process.cwd()),
45
+ getProjectStateDir: () => "",
46
+ getSessionsDir: () => "",
47
+ getSessionFile: () => "",
48
+ getSessionProject: () => path.basename(process.cwd()),
49
+ listStateProjects: () => [],
50
+ getLocksDir: () => "",
51
+ GLOBAL_STATE_DIR: "",
52
+ OBSIDIAN_PROJECTS: "",
53
+ };
54
+
55
+ export function initSessionDeps(deps) {
56
+ Object.assign(_deps, deps);
57
+ }
58
+
59
+ // ── Session ID generation ─────────────────────────────────────
60
+
61
+ export function generateSessionId(topic) {
62
+ const slug = topic
63
+ .replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
64
+ .replace(/\s+/g, "-")
65
+ .toLowerCase()
66
+ .slice(0, 20);
67
+ const ts = Date.now().toString(36);
68
+ const rand = Math.random().toString(36).slice(2, 6);
69
+ return `${slug}-${ts}${rand}`;
70
+ }
71
+
72
+ export function generateTurnId() {
73
+ return `t-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
74
+ }
75
+
76
+ // ── Context detection ──────────────────────────────────────────
77
+
78
+ export function detectContextDirs() {
79
+ const dirs = [];
80
+ const slug = _deps.getProjectSlug();
81
+
82
+ if (process.env.DELIBERATION_CONTEXT_DIR) {
83
+ dirs.push(process.env.DELIBERATION_CONTEXT_DIR);
84
+ }
85
+ dirs.push(process.cwd());
86
+
87
+ const obsidianProject = path.join(_deps.OBSIDIAN_PROJECTS, slug);
88
+ if (fs.existsSync(obsidianProject)) {
89
+ dirs.push(obsidianProject);
90
+ }
91
+
92
+ return [...new Set(dirs)];
93
+ }
94
+
95
+ export function readContextFromDirs(dirs, maxChars = 15000) {
96
+ let context = "";
97
+ const seen = new Set();
98
+
99
+ for (const dir of dirs) {
100
+ if (!fs.existsSync(dir)) continue;
101
+
102
+ const files = fs.readdirSync(dir)
103
+ .filter(f => f.endsWith(".md") && !f.startsWith("_") && !f.startsWith("."))
104
+ .sort();
105
+
106
+ for (const file of files) {
107
+ if (seen.has(file)) continue;
108
+ seen.add(file);
109
+
110
+ const fullPath = path.join(dir, file);
111
+ let raw;
112
+ try { raw = fs.readFileSync(fullPath, "utf-8"); } catch { continue; }
113
+
114
+ let body = raw;
115
+ if (body.startsWith("---")) {
116
+ const end = body.indexOf("---", 3);
117
+ if (end !== -1) body = body.slice(end + 3).trim();
118
+ }
119
+
120
+ const truncated = body.length > 1200
121
+ ? body.slice(0, 1200) + "\n(...)"
122
+ : body;
123
+
124
+ context += `### ${file.replace(".md", "")}\n${truncated}\n\n---\n\n`;
125
+
126
+ if (context.length > maxChars) {
127
+ context = context.slice(0, maxChars) + "\n\n(...context truncated)";
128
+ return context;
129
+ }
130
+ }
131
+ }
132
+ return context || "(No context files found)";
133
+ }
134
+
135
+ // ── Archive directory ──────────────────────────────────────────
136
+
137
+ export function getArchiveDir(projectSlug) {
138
+ const slug = _deps.normalizeProjectSlug(projectSlug);
139
+ const obsidianDir = path.join(_deps.OBSIDIAN_PROJECTS, slug, "deliberations");
140
+ if (fs.existsSync(path.join(_deps.OBSIDIAN_PROJECTS, slug))) {
141
+ return obsidianDir;
142
+ }
143
+ return path.join(_deps.getProjectStateDir(slug), "archive");
144
+ }
145
+
146
+ // ── Session record lookup ──────────────────────────────────────
147
+
148
+ export function findSessionRecord(sessionRef, { preferProject, activeOnly = false } = {}) {
149
+ if (!sessionRef) return null;
150
+
151
+ if (typeof sessionRef === "object" && sessionRef !== null && sessionRef.id) {
152
+ const project = _deps.getSessionProject(sessionRef, preferProject);
153
+ const file = _deps.getSessionFile(sessionRef.id, project);
154
+ const state = _deps.readJsonFileSafe(file);
155
+ if (!state) return null;
156
+ const normalized = normalizeSessionActors(state);
157
+ if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
158
+ return null;
159
+ }
160
+ return { file, project, state: normalized };
161
+ }
162
+
163
+ const sessionId = String(sessionRef);
164
+ const preferred = _deps.normalizeProjectSlug(preferProject);
165
+ const projects = [...new Set([preferred, ..._deps.listStateProjects()])];
166
+ for (const project of projects) {
167
+ const file = _deps.getSessionFile(sessionId, project);
168
+ const state = _deps.readJsonFileSafe(file);
169
+ if (!state) continue;
170
+ const normalized = normalizeSessionActors(state);
171
+ if (activeOnly && normalized.status !== "active" && normalized.status !== "awaiting_synthesis") {
172
+ continue;
173
+ }
174
+ return { file, project: normalized.project || project, state: normalized };
175
+ }
176
+ return null;
177
+ }
178
+
179
+ // ── State helpers ──────────────────────────────────────────────
180
+
181
+ export function ensureDirs(projectSlug) {
182
+ const slug = projectSlug || _deps.getProjectSlug();
183
+ fs.mkdirSync(_deps.getSessionsDir(slug), { recursive: true });
184
+ fs.mkdirSync(getArchiveDir(slug), { recursive: true });
185
+ fs.mkdirSync(_deps.getLocksDir(slug), { recursive: true });
186
+ }
187
+
188
+ export function loadSession(sessionRef) {
189
+ const record = findSessionRecord(sessionRef);
190
+ return record?.state || null;
191
+ }
192
+
193
+ export function saveSession(state) {
194
+ ensureDirs(state.project);
195
+ state.updated = new Date().toISOString();
196
+ _deps.writeTextAtomic(_deps.getSessionFile(state), JSON.stringify(state, null, 2));
197
+ syncMarkdown(state);
198
+ }
199
+
200
+ export function listActiveSessions(projectSlug) {
201
+ const projects = projectSlug
202
+ ? [_deps.normalizeProjectSlug(projectSlug)]
203
+ : [...new Set([_deps.getProjectSlug(), ..._deps.listStateProjects()])];
204
+
205
+ return projects.flatMap(project => {
206
+ const dir = _deps.getSessionsDir(project);
207
+ if (!fs.existsSync(dir)) return [];
208
+
209
+ return fs.readdirSync(dir)
210
+ .filter(f => f.endsWith(".json"))
211
+ .map(f => {
212
+ try {
213
+ const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
214
+ return normalizeSessionActors(data);
215
+ } catch {
216
+ return null;
217
+ }
218
+ })
219
+ .filter(s => s && (s.status === "active" || s.status === "awaiting_synthesis"));
220
+ });
221
+ }
222
+
223
+ export function resolveSessionId(sessionId) {
224
+ // Use session_id directly if provided
225
+ if (sessionId) return sessionId;
226
+
227
+ // Auto-select when only one active session
228
+ const active = listActiveSessions();
229
+ if (active.length === 0) return null;
230
+ if (active.length === 1) return active[0].id;
231
+
232
+ // null if multiple (need to show list)
233
+ return "MULTIPLE";
234
+ }
235
+
236
+ export function syncMarkdown(state) {
237
+ const filename = `deliberation-${state.id}.md`;
238
+ const mdPath = path.join(_deps.getProjectStateDir(state.project), filename);
239
+ try {
240
+ _deps.writeTextAtomic(mdPath, stateToMarkdown(state));
241
+ } catch { /* ignore sync failures */ }
242
+ }
243
+
244
+ export function cleanupSyncMarkdown(state) {
245
+ const filename = `deliberation-${state.id}.md`;
246
+ const statePath = path.join(_deps.getProjectStateDir(state.project), filename);
247
+ try { fs.unlinkSync(statePath); } catch { /* ignore */ }
248
+ // Also clean up legacy files in CWD (from older versions)
249
+ const cwdPath = path.join(process.cwd(), filename);
250
+ try { fs.unlinkSync(cwdPath); } catch { /* ignore */ }
251
+ }
252
+
253
+ export function formatSourceMetadataLine(meta) {
254
+ if (!meta || typeof meta !== "object") return "";
255
+ const parts = [];
256
+ if (meta.source_machine_id) parts.push(`machine: ${meta.source_machine_id}`);
257
+ if (meta.source_session_id) parts.push(`session: ${meta.source_session_id}`);
258
+ if (meta.transport_scope) parts.push(`transport: ${meta.transport_scope}`);
259
+ if (meta.reply_origin) parts.push(`origin: ${meta.reply_origin}`);
260
+ if (meta.timestamp) parts.push(`timestamp: ${meta.timestamp}`);
261
+ if (Array.isArray(meta.artifact_refs) && meta.artifact_refs.length > 0) {
262
+ parts.push(`artifacts: ${meta.artifact_refs.join(", ")}`);
263
+ }
264
+ return parts.length > 0 ? `> _source: ${parts.join(" | ")}_\n\n` : "";
265
+ }
266
+
267
+ export function stateToMarkdown(s) {
268
+ const speakerOrder = buildSpeakerOrder(s.speakers, s.current_speaker, "end");
269
+ let md = `---
270
+ title: "Deliberation - ${s.topic}"
271
+ session_id: "${s.id}"
272
+ created: ${s.created}
273
+ updated: ${s.updated || new Date().toISOString()}
274
+ type: deliberation
275
+ status: ${s.status}
276
+ project: "${s.project}"
277
+ participants: ${JSON.stringify(speakerOrder)}
278
+ rounds: ${s.max_rounds}
279
+ current_round: ${s.current_round}
280
+ current_speaker: "${s.current_speaker}"
281
+ tags: [deliberation]
282
+ ---
283
+
284
+ # Deliberation: ${s.topic}
285
+
286
+ **Session:** ${s.id} | **Project:** ${s.project} | **Status:** ${s.status} | **Round:** ${s.current_round}/${s.max_rounds} | **Next:** ${s.current_speaker}
287
+
288
+ ---
289
+
290
+ `;
291
+
292
+ if (s.synthesis) {
293
+ md += `## Synthesis\n\n${s.synthesis}\n\n---\n\n`;
294
+ }
295
+
296
+ if (s.structured_synthesis) {
297
+ md += `## Structured Synthesis\n\n\`\`\`json\n${JSON.stringify(s.structured_synthesis, null, 2)}\n\`\`\`\n\n---\n\n`;
298
+ }
299
+
300
+ if (s.execution_contract) {
301
+ md += `## Execution Contract\n\n\`\`\`json\n${JSON.stringify(s.execution_contract, null, 2)}\n\`\`\`\n\n---\n\n`;
302
+ }
303
+
304
+ md += `## Debate Log\n\n`;
305
+ for (const entry of s.log) {
306
+ md += `### ${entry.speaker} — Round ${entry.round}\n\n`;
307
+ if (entry.channel_used || entry.fallback_reason) {
308
+ const parts = [];
309
+ if (entry.channel_used) parts.push(`channel: ${entry.channel_used}`);
310
+ if (entry.fallback_reason) parts.push(`fallback: ${entry.fallback_reason}`);
311
+ md += `> _${parts.join(" | ")}_\n\n`;
312
+ }
313
+ md += formatSourceMetadataLine(entry.source_metadata);
314
+ md += `${entry.content}\n\n`;
315
+ if (entry.attachments && entry.attachments.length > 0) {
316
+ for (const att of entry.attachments) {
317
+ if (att.type === "image") {
318
+ md += `![Attachment](${att.path})\n\n`;
319
+ }
320
+ }
321
+ }
322
+ md += `---\n\n`;
323
+ }
324
+ return md;
325
+ }
326
+
327
+ export function archiveState(state) {
328
+ ensureDirs(state.project);
329
+ const slug = state.topic
330
+ .replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
331
+ .replace(/\s+/g, "-")
332
+ .slice(0, 30);
333
+ const ts = new Date().toISOString().slice(0, 16).replace(/:/g, "");
334
+ const filename = `deliberation-${ts}-${slug}.md`;
335
+ const dest = path.join(getArchiveDir(state.project), filename);
336
+ _deps.writeTextAtomic(dest, stateToMarkdown(state));
337
+
338
+ // Write machine-readable execution_contract sidecar for automation consumers
339
+ if (state.execution_contract) {
340
+ const contractDest = dest.replace(/\.md$/, ".contract.json");
341
+ _deps.writeTextAtomic(contractDest, JSON.stringify({
342
+ ...state.execution_contract,
343
+ _meta: {
344
+ archived_from: state.id,
345
+ project: state.project,
346
+ topic: state.topic,
347
+ archived_at: new Date().toISOString(),
348
+ },
349
+ }, null, 2));
350
+ }
351
+
352
+ return dest;
353
+ }
354
+
355
+ // ── Multiple sessions error ────────────────────────────────────
356
+
357
+ export function multipleSessionsError() {
358
+ const active = listActiveSessions();
359
+ const list = active.map(s => `- **${s.id}** [${s.project || "unknown"}]: "${s.topic}" (Round ${s.current_round}/${s.max_rounds}, next: ${s.current_speaker})`).join("\n");
360
+ return t(`Multiple active sessions found. Please specify session_id:\n\n${list}`, `여러 활성 세션이 있습니다. session_id를 지정하세요:\n\n${list}`, "en");
361
+ }
362
+
363
+ // ── Prompt building ────────────────────────────────────────────
364
+
365
+ export function truncatePromptText(text, maxChars) {
366
+ const value = String(text || "").trim();
367
+ if (!value || !Number.isFinite(maxChars) || maxChars <= 0 || value.length <= maxChars) {
368
+ return value;
369
+ }
370
+ const remaining = value.length - maxChars;
371
+ return `${value.slice(0, maxChars).trimEnd()}\n...(truncated ${remaining} chars)`;
372
+ }
373
+
374
+ export function getPromptBudgetForSpeaker(speaker, includeHistoryEntries = 4) {
375
+ const defaultBudget = {
376
+ maxEntries: Math.max(0, includeHistoryEntries),
377
+ maxCharsPerEntry: 1600,
378
+ maxTotalChars: 6400,
379
+ maxTopicChars: 3200,
380
+ };
381
+ switch (speaker) {
382
+ case "codex":
383
+ return {
384
+ maxEntries: Math.min(Math.max(0, includeHistoryEntries), 3),
385
+ maxCharsPerEntry: 1200,
386
+ maxTotalChars: 3600,
387
+ maxTopicChars: 2200,
388
+ };
389
+ case "gemini":
390
+ return {
391
+ maxEntries: Math.min(Math.max(0, includeHistoryEntries), 4),
392
+ maxCharsPerEntry: 1400,
393
+ maxTotalChars: 5600,
394
+ maxTopicChars: 2800,
395
+ };
396
+ default:
397
+ return defaultBudget;
398
+ }
399
+ }
400
+
401
+ export function formatRecentLogForPrompt(state, maxEntries = 4, options = {}) {
402
+ const entries = Array.isArray(state.log) ? state.log.slice(-Math.max(0, maxEntries)) : [];
403
+ if (entries.length === 0) {
404
+ return "(No previous responses yet)";
405
+ }
406
+ const maxCharsPerEntry = options.maxCharsPerEntry || 1600;
407
+ const maxTotalChars = options.maxTotalChars || maxCharsPerEntry * entries.length;
408
+ const rendered = [];
409
+ let usedChars = 0;
410
+
411
+ for (const entry of entries) {
412
+ const header = `- ${entry.speaker} (Round ${entry.round})`;
413
+ const remainingChars = Math.max(0, maxTotalChars - usedChars - header.length - 1);
414
+ const entryBudget = Math.max(200, Math.min(maxCharsPerEntry, remainingChars || maxCharsPerEntry));
415
+ const content = truncatePromptText(entry.content, entryBudget);
416
+ const block = `${header}\n${content}`;
417
+ rendered.push(block);
418
+ usedChars += block.length + 2;
419
+ if (usedChars >= maxTotalChars) {
420
+ break;
421
+ }
422
+ }
423
+
424
+ return rendered.join("\n\n");
425
+ }
426
+
427
+ export function buildActiveReportingSection(state, speaker) {
428
+ const orchestratorSessionId = state?.orchestrator_session_id;
429
+ if (!orchestratorSessionId) return "";
430
+
431
+ return `\n[active_reporting_rule]
432
+ - After you successfully submit this turn with \`deliberation_respond(...)\`, send a brief status update to the orchestrator session.
433
+ - Primary method (telepty available):
434
+ \`telepty inject --from \"$TELEPTY_SESSION_ID\" --reply-to ${orchestratorSessionId} ${orchestratorSessionId} \"[deliberation status] session_id: ${state.id} speaker: ${speaker} round: ${state.current_round}/${state.max_rounds} submitted. no further reply needed.\"\`
435
+ - Fallback (telepty unavailable): Include the status in your next tool call or response context.
436
+ - Keep it to one short line. Do not wait for a reply.
437
+ - This rule applies regardless of transport type (CLI, browser, telepty_bus).
438
+ [/active_reporting_rule]
439
+ `;
440
+ }
441
+
442
+ export function buildClipboardTurnPrompt(state, speaker, prompt, includeHistoryEntries = 4) {
443
+ const promptBudget = getPromptBudgetForSpeaker(speaker, includeHistoryEntries);
444
+ const recent = formatRecentLogForPrompt(state, promptBudget.maxEntries, promptBudget);
445
+ const extraPrompt = prompt ? `\n[Additional instructions]\n${prompt}\n` : "";
446
+ const topic = truncatePromptText(state.topic, promptBudget.maxTopicChars);
447
+ const noToolRule = speaker === "codex"
448
+ ? `\n- Do not inspect files, run shell commands, browse, or call tools. Answer only from the provided discussion context.`
449
+ : "";
450
+ const activeReportingSection = buildActiveReportingSection(state, speaker);
451
+
452
+ // Role prompt injection
453
+ const speakerRole = (state.speaker_roles || {})[speaker] || "free";
454
+ const rolePromptText = loadRolePrompt(speakerRole);
455
+ const roleSection = rolePromptText
456
+ ? `\n[role]\nrole: ${speakerRole}\n${rolePromptText}\n[/role]\n`
457
+ : "";
458
+
459
+ return `[deliberation_turn_request]
460
+ session_id: ${state.id}
461
+ project: ${state.project}
462
+ topic: ${topic}
463
+ round: ${state.current_round}/${state.max_rounds}
464
+ target_speaker: ${speaker}
465
+ required_turn: ${state.current_speaker}${roleSection}${activeReportingSection}
466
+
467
+ [recent_log]
468
+ ${recent}
469
+ [/recent_log]${extraPrompt}
470
+
471
+ [response_rule]
472
+ - Write only ${speaker}'s response for this turn reflecting the discussion context above
473
+ - Output markdown body only (no unnecessary headers/footers)${speakerRole !== "free" ? `\n- Analyze and respond from the perspective of assigned role (${speakerRole})` : ""}
474
+ - Keep the response concise and decision-oriented${noToolRule}
475
+ - Must include one of [AGREE], [DISAGREE], or [CONDITIONAL: reason] at the end of response
476
+ [/response_rule]
477
+ [/deliberation_turn_request]
478
+ `;
479
+ }
480
+
481
+ // ── Core turn submission ───────────────────────────────────────
482
+
483
+ export function submitDeliberationTurn({ session_id, speaker, content, turn_id, channel_used, fallback_reason, attachments, source_metadata }) {
484
+ const resolved = resolveSessionId(session_id);
485
+ if (!resolved) {
486
+ return { content: [{ type: "text", text: t("No active deliberation.", "활성 deliberation이 없습니다.", "en") }] };
487
+ }
488
+ if (resolved === "MULTIPLE") {
489
+ return { content: [{ type: "text", text: multipleSessionsError() }] };
490
+ }
491
+
492
+ let completionState = null;
493
+ let completionEntry = null;
494
+ const result = _deps.withSessionLock(resolved, () => {
495
+ const state = loadSession(resolved);
496
+ if (!state || state.status !== "active") {
497
+ return { content: [{ type: "text", text: t(`Session "${resolved}" is not active.`, `세션 "${resolved}"이 활성 상태가 아닙니다.`, "en") }] };
498
+ }
499
+
500
+ const normalizedSpeaker = normalizeSpeaker(speaker);
501
+ if (!normalizedSpeaker) {
502
+ return { content: [{ type: "text", text: t("Speaker value is empty. Please specify a speaker name.", "speaker 값이 비어 있습니다. 응답자 이름을 지정하세요.", "en") }] };
503
+ }
504
+
505
+ state.speakers = buildSpeakerOrder(state.speakers, state.current_speaker, "end");
506
+ const normalizedCurrentSpeaker = normalizeSpeaker(state.current_speaker);
507
+ if (!normalizedCurrentSpeaker || !state.speakers.includes(normalizedCurrentSpeaker)) {
508
+ state.current_speaker = state.speakers[0];
509
+ } else {
510
+ state.current_speaker = normalizedCurrentSpeaker;
511
+ }
512
+
513
+ if (state.current_speaker !== normalizedSpeaker) {
514
+ return {
515
+ content: [{
516
+ type: "text",
517
+ text: t(`[${state.id}] It is currently **${state.current_speaker}**'s turn. ${normalizedSpeaker} please wait.`, `[${state.id}] 지금은 **${state.current_speaker}** 차례입니다. ${normalizedSpeaker}는 대기하세요.`, state?.lang),
518
+ }],
519
+ };
520
+ }
521
+
522
+ // turn_id validation (optional — must match if provided)
523
+ if (turn_id && state.pending_turn_id && turn_id !== state.pending_turn_id) {
524
+ return {
525
+ content: [{
526
+ type: "text",
527
+ text: t(`[${state.id}] turn_id mismatch. Expected: "${state.pending_turn_id}", received: "${turn_id}". May be a stale request or duplicate submission.`, `[${state.id}] turn_id 불일치. 예상: "${state.pending_turn_id}", 수신: "${turn_id}". 오래된 요청이거나 중복 제출일 수 있습니다.`, state?.lang),
528
+ }],
529
+ };
530
+ }
531
+
532
+ const votes = parseVotes(content);
533
+ if (votes.length === 0) {
534
+ _deps.appendRuntimeLog("WARN", `INVALID_TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | reason: no_vote_marker`);
535
+ }
536
+ const suggestedRole = inferSuggestedRole(content);
537
+ const assignedRole = (state.speaker_roles || {})[normalizedSpeaker] || "free";
538
+ const roleDrift = assignedRole !== "free" && suggestedRole !== "free" && assignedRole !== suggestedRole;
539
+ const logEntry = {
540
+ round: state.current_round,
541
+ speaker: normalizedSpeaker,
542
+ content,
543
+ timestamp: new Date().toISOString(),
544
+ turn_id: state.pending_turn_id || null,
545
+ channel_used: channel_used || null,
546
+ fallback_reason: fallback_reason || null,
547
+ votes: votes.length > 0 ? votes : undefined,
548
+ suggested_next_role: suggestedRole !== "free" ? suggestedRole : undefined,
549
+ role_drift: roleDrift || undefined,
550
+ attachments: attachments || undefined,
551
+ source_metadata: source_metadata || undefined,
552
+ };
553
+ state.log.push(logEntry);
554
+ completePendingTeleptySemantic({
555
+ sessionId: state.id,
556
+ speaker: normalizedSpeaker,
557
+ turnId: state.pending_turn_id || turn_id || null,
558
+ });
559
+ _deps.appendRuntimeLog("INFO", `TURN: ${state.id} | R${state.current_round} | speaker: ${normalizedSpeaker} | votes: ${votes.length > 0 ? votes.map(v => v.vote).join(",") : "none"} | channel: ${channel_used || "respond"} | attachments: ${attachments ? attachments.length : 0}${source_metadata?.source_machine_id ? ` | source_machine: ${source_metadata.source_machine_id}` : ""}`);
560
+
561
+ state.current_speaker = selectNextSpeaker(state);
562
+
563
+ // Round transition: check if all speakers have spoken this round
564
+ const roundEntries = state.log.filter(e => e.round === state.current_round);
565
+ const spokeSpeakers = new Set(roundEntries.map(e => e.speaker));
566
+ const allSpoke = state.speakers.every(s => spokeSpeakers.has(s));
567
+
568
+ if (allSpoke) {
569
+ if (state.current_round >= state.max_rounds) {
570
+ state.status = "awaiting_synthesis";
571
+ state.current_speaker = "none";
572
+ saveSession(state);
573
+ return {
574
+ content: [{
575
+ type: "text",
576
+ text: t(`✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} complete. Forum updated (${state.log.length} responses accumulated).\n\n🏁 **All rounds complete!**\nCreate a synthesis report with deliberation_synthesize(session_id: "${state.id}").`, `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료. Forum 업데이트됨 (${state.log.length}건 응답 축적).\n\n🏁 **모든 라운드 종료!**\ndeliberation_synthesize(session_id: "${state.id}")로 합성 보고서를 작성하세요.`, state?.lang),
577
+ }],
578
+ };
579
+ }
580
+ state.current_round += 1;
581
+ }
582
+
583
+ if (state.status === "active") {
584
+ state.pending_turn_id = generateTurnId();
585
+ }
586
+
587
+ if (!state.orchestrator_session_id) {
588
+ state.orchestrator_session_id = getDefaultOrchestratorSessionId() || null;
589
+ }
590
+ completionEntry = {
591
+ ...logEntry,
592
+ turn_id: logEntry.turn_id || turn_id || null,
593
+ };
594
+ completionState = {
595
+ ...state,
596
+ log: [...state.log],
597
+ };
598
+ saveSession(state);
599
+ return {
600
+ content: [{
601
+ type: "text",
602
+ text: t(`✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} complete. Forum updated (${state.log.length} responses accumulated).\n\n**Next:** ${state.current_speaker} (Round ${state.current_round})`, `✅ [${state.id}] ${normalizedSpeaker} Round ${state.log[state.log.length - 1].round} 완료. Forum 업데이트됨 (${state.log.length}건 응답 축적).\n\n**다음:** ${state.current_speaker} (Round ${state.current_round})`, state?.lang),
603
+ }],
604
+ };
605
+ });
606
+
607
+ if (completionState && completionEntry) {
608
+ const envelope = buildTeleptyTurnCompletedEnvelope({ state: completionState, entry: completionEntry });
609
+ notifyTeleptyBus(envelope).catch(() => {});
610
+
611
+ const orchestratorSessionId = completionState.orchestrator_session_id || null;
612
+ if (orchestratorSessionId) {
613
+ const notificationText = buildTurnCompletionNotificationText(completionState, completionEntry);
614
+ notifyTeleptySessionInject({
615
+ targetSessionId: orchestratorSessionId,
616
+ prompt: notificationText,
617
+ fromSessionId: `deliberation:${completionState.id}`,
618
+ }).catch(() => {});
619
+ }
620
+ }
621
+
622
+ return result;
623
+ }