feat: honor handoff agent type + local memory store
- fix: pass forcedRole/forcedAgentType through handoff execution chain so reviewer failover actually runs codex instead of retrying Claude - fix: preserve intended_role in service handoff records - feat: replace Memento MCP with local SQLite memory store - feat: add memory selection with TTL decay
This commit is contained in:
@@ -17,8 +17,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +28,7 @@ import {
|
|||||||
isReviewerMutatingShellCommand,
|
isReviewerMutatingShellCommand,
|
||||||
isReviewerRuntime,
|
isReviewerRuntime,
|
||||||
} from './reviewer-runtime.js';
|
} from './reviewer-runtime.js';
|
||||||
|
import { selectCompactMemoriesFromSummary } from './memory-selection.js';
|
||||||
|
|
||||||
interface ContainerInput {
|
interface ContainerInput {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
@@ -87,23 +86,18 @@ interface AssistantContentBlock {
|
|||||||
text?: string;
|
text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MementoCallToolResult {
|
|
||||||
isError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paths configurable via env vars.
|
// Paths configurable via env vars.
|
||||||
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
|
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
|
||||||
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
|
||||||
|
const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR;
|
||||||
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
|
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
|
||||||
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
|
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
|
||||||
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
|
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
|
||||||
const MEMENTO_SSE_URL = process.env.MEMENTO_MCP_SSE_URL || '';
|
|
||||||
const MEMENTO_ACCESS_KEY = process.env.MEMENTO_ACCESS_KEY || '';
|
|
||||||
const MEMENTO_TIMEOUT_MS = 4_000;
|
|
||||||
|
|
||||||
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
|
||||||
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
|
||||||
const IPC_POLL_MS = 500;
|
const IPC_POLL_MS = 500;
|
||||||
|
const HOST_TASKS_DIR = path.join(HOST_IPC_DIR, 'tasks');
|
||||||
|
|
||||||
/** SSOT: src/agent-protocol.ts — keep in sync */
|
/** SSOT: src/agent-protocol.ts — keep in sync */
|
||||||
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g;
|
||||||
@@ -270,100 +264,53 @@ function getSessionSummary(sessionId: string, transcriptPath: string): string |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
promise.then(
|
|
||||||
(value) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(value);
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimSummary(summary: string, maxChars: number): string {
|
function trimSummary(summary: string, maxChars: number): string {
|
||||||
if (summary.length <= maxChars) return summary;
|
if (summary.length <= maxChars) return summary;
|
||||||
return summary.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
|
return summary.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callMementoTool(
|
function writeHostTaskIpcFile(data: object): string {
|
||||||
name: string,
|
fs.mkdirSync(HOST_TASKS_DIR, { recursive: true });
|
||||||
args: Record<string, unknown>,
|
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`;
|
||||||
timeoutMs = MEMENTO_TIMEOUT_MS,
|
const filepath = path.join(HOST_TASKS_DIR, filename);
|
||||||
): Promise<boolean> {
|
const tempPath = `${filepath}.tmp`;
|
||||||
if (!MEMENTO_SSE_URL || !MEMENTO_ACCESS_KEY) return false;
|
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2));
|
||||||
|
fs.renameSync(tempPath, filepath);
|
||||||
const transport = new SSEClientTransport(new URL(MEMENTO_SSE_URL), {
|
return filename;
|
||||||
requestInit: {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${MEMENTO_ACCESS_KEY}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const client = new Client({ name: 'ejclaw-precompact', version: '1.0.0' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`);
|
|
||||||
const result = await withTimeout(
|
|
||||||
client.callTool({
|
|
||||||
name,
|
|
||||||
arguments: args,
|
|
||||||
}) as Promise<MementoCallToolResult>,
|
|
||||||
timeoutMs,
|
|
||||||
`${name}/call`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.isError) {
|
|
||||||
log(`Memento tool returned error: ${name}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
log(`Memento tool failed (${name}): ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
|
async function persistCompactMemory(summary: string, sessionId: string): Promise<void> {
|
||||||
const normalized = summary.trim();
|
const normalized = summary.trim();
|
||||||
if (!normalized) return;
|
if (!normalized || !GROUP_FOLDER) return;
|
||||||
|
|
||||||
const tasks: Promise<boolean>[] = [
|
try {
|
||||||
callMementoTool('reflect', {
|
const scopeKey = `room:${GROUP_FOLDER}`;
|
||||||
summary: normalized,
|
const selected = selectCompactMemoriesFromSummary(normalized, scopeKey);
|
||||||
}),
|
if (selected.length === 0) {
|
||||||
];
|
log('Skipped compact memory persist - no salient room memory found');
|
||||||
|
return;
|
||||||
if (GROUP_FOLDER) {
|
|
||||||
tasks.push(
|
|
||||||
callMementoTool('remember', {
|
|
||||||
content: trimSummary(normalized, 300),
|
|
||||||
topic: 'room-memory',
|
|
||||||
type: 'fact',
|
|
||||||
keywords: [`room:${GROUP_FOLDER}`],
|
|
||||||
source: `compact:${sessionId}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(tasks);
|
for (const memory of selected) {
|
||||||
const succeeded = results.filter(
|
const file = writeHostTaskIpcFile({
|
||||||
(result) => result.status === 'fulfilled' && result.value,
|
type: 'persist_memory',
|
||||||
).length;
|
scopeKind: 'room',
|
||||||
|
scopeKey,
|
||||||
if (succeeded > 0) {
|
content: trimSummary(memory.content, 300),
|
||||||
log(`Persisted compact memory (${succeeded}/${results.length})`);
|
keywords: memory.keywords,
|
||||||
|
memory_kind: memory.memoryKind,
|
||||||
|
source_kind: 'compact',
|
||||||
|
source_ref: sessionId ? `compact:${sessionId}` : null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
log(`Persisted compact memory via IPC (${file})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(
|
||||||
|
`Failed to persist compact memory via IPC: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,18 +676,6 @@ async function runQuery(
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(process.env.MEMENTO_MCP_SSE_URL
|
|
||||||
? {
|
|
||||||
'memento-mcp': {
|
|
||||||
command: process.env.MEMENTO_MCP_REMOTE_PATH || 'mcp-remote',
|
|
||||||
args: [
|
|
||||||
process.env.MEMENTO_MCP_SSE_URL,
|
|
||||||
'--header',
|
|
||||||
`Authorization:Bearer ${process.env.MEMENTO_ACCESS_KEY || ''}`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
hooks: {
|
hooks: {
|
||||||
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
|
||||||
|
|||||||
73
runners/agent-runner/src/memory-selection.ts
Normal file
73
runners/agent-runner/src/memory-selection.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export interface SelectedCompactMemory {
|
||||||
|
content: string;
|
||||||
|
keywords: string[];
|
||||||
|
memoryKind: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SELECTED_MEMORIES = 3;
|
||||||
|
|
||||||
|
const MEMORY_PATTERNS: Array<{
|
||||||
|
kind: string | null;
|
||||||
|
regex: RegExp;
|
||||||
|
keyword: string;
|
||||||
|
}> = [
|
||||||
|
{ kind: 'room_norm', regex: /(규칙|원칙|금지|반드시|하지 않|세션 시작 시에만|새 세션 시작 시에만)/i, keyword: 'rule' },
|
||||||
|
{ kind: 'preference', regex: /(선호|원함|원한다|원하는|원했다)/i, keyword: 'preference' },
|
||||||
|
{ kind: 'decision', regex: /(합의|결정|방향|기준|우선|책임지는 방향)/i, keyword: 'decision' },
|
||||||
|
{ kind: 'project_fact', regex: /(owner|reviewer|trigger|모드|메모리|기억|세션 리셋|recall|persist|compact)/i, keyword: 'memory' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeSentence(raw: string): string | null {
|
||||||
|
const normalized = raw
|
||||||
|
.trim()
|
||||||
|
.replace(/^[-*]\s*/, '')
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSummaryIntoSentences(summary: string): string[] {
|
||||||
|
return summary
|
||||||
|
.split(/\n+|(?<=[.!?。])\s+/)
|
||||||
|
.map(normalizeSentence)
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyMemorySentence(content: string): SelectedCompactMemory | null {
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
const matchedPatterns = MEMORY_PATTERNS.filter(({ regex }) => regex.test(content));
|
||||||
|
if (matchedPatterns.length === 0) return null;
|
||||||
|
|
||||||
|
const primary = matchedPatterns[0];
|
||||||
|
const keywords = [
|
||||||
|
...new Set(
|
||||||
|
matchedPatterns
|
||||||
|
.map((pattern) => pattern.keyword)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
keywords,
|
||||||
|
memoryKind: primary?.kind ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectCompactMemoriesFromSummary(
|
||||||
|
summary: string,
|
||||||
|
roomKey: string,
|
||||||
|
): SelectedCompactMemory[] {
|
||||||
|
if (!summary.trim()) return [];
|
||||||
|
|
||||||
|
const selected = splitSummaryIntoSentences(summary)
|
||||||
|
.map((sentence) => classifyMemorySentence(sentence))
|
||||||
|
.filter((value): value is SelectedCompactMemory => Boolean(value))
|
||||||
|
.slice(0, MAX_SELECTED_MEMORIES)
|
||||||
|
.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
keywords: [...new Set([roomKey, ...entry.keywords])],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
43
runners/agent-runner/test/memory-selection.test.ts
Normal file
43
runners/agent-runner/test/memory-selection.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { selectCompactMemoriesFromSummary } from '../src/memory-selection.js';
|
||||||
|
|
||||||
|
describe('memory selection', () => {
|
||||||
|
it('keeps stable room memories and drops transient status lines', () => {
|
||||||
|
const selected = selectCompactMemoriesFromSummary(
|
||||||
|
[
|
||||||
|
'방 메모리는 새 세션 시작 시에만 주입한다.',
|
||||||
|
'테스트 599개 통과.',
|
||||||
|
'사용자는 수동 명령보다 자동 기억 형성을 원한다.',
|
||||||
|
].join(' '),
|
||||||
|
'room:ejclaw',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected).toHaveLength(2);
|
||||||
|
expect(selected[0].content).toBe('방 메모리는 새 세션 시작 시에만 주입한다.');
|
||||||
|
expect(selected[0].memoryKind).toBe('room_norm');
|
||||||
|
expect(selected[1].content).toBe('사용자는 수동 명령보다 자동 기억 형성을 원한다.');
|
||||||
|
expect(selected[1].memoryKind).toBe('preference');
|
||||||
|
expect(selected.every((memory) => memory.keywords.includes('room:ejclaw'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no memories for purely operational summaries', () => {
|
||||||
|
const selected = selectCompactMemoriesFromSummary(
|
||||||
|
'메멘토 대체 v1 로컬 구현은 끝냈습니다. 테스트 599개 통과.',
|
||||||
|
'room:ejclaw',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps stable rule sentences even when they contain operational words', () => {
|
||||||
|
const selected = selectCompactMemoriesFromSummary(
|
||||||
|
'배포 전에 항상 테스트를 돌리는 것이 원칙이다.',
|
||||||
|
'room:ejclaw',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(selected).toHaveLength(1);
|
||||||
|
expect(selected[0].content).toBe('배포 전에 항상 테스트를 돌리는 것이 원칙이다.');
|
||||||
|
expect(selected[0].memoryKind).toBe('room_norm');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -316,10 +316,6 @@ function prepareCodexSessionEnvironment(args: {
|
|||||||
? fs.readFileSync(sessionConfigPath, 'utf-8')
|
? fs.readFileSync(sessionConfigPath, 'utf-8')
|
||||||
: '';
|
: '';
|
||||||
toml = toml.replace(/\n?\[mcp_servers\.ejclaw\][\s\S]*?(?=\n\[|$)/, '');
|
toml = toml.replace(/\n?\[mcp_servers\.ejclaw\][\s\S]*?(?=\n\[|$)/, '');
|
||||||
toml = toml.replace(
|
|
||||||
/\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
const mcpSection = `
|
const mcpSection = `
|
||||||
[mcp_servers.ejclaw]
|
[mcp_servers.ejclaw]
|
||||||
command = "node"
|
command = "node"
|
||||||
@@ -333,24 +329,9 @@ EJCLAW_GROUP_FOLDER = ${JSON.stringify(args.group.folder)}
|
|||||||
EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')}
|
EJCLAW_IS_MAIN = ${JSON.stringify(args.isMain ? '1' : '0')}
|
||||||
EJCLAW_AGENT_TYPE = ${JSON.stringify(args.env.EJCLAW_AGENT_TYPE)}
|
EJCLAW_AGENT_TYPE = ${JSON.stringify(args.env.EJCLAW_AGENT_TYPE)}
|
||||||
`;
|
`;
|
||||||
const mementoSseUrl =
|
|
||||||
args.envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL;
|
|
||||||
const mementoAccessKey =
|
|
||||||
args.envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || '';
|
|
||||||
const mementoRemotePath =
|
|
||||||
args.envVars.MEMENTO_MCP_REMOTE_PATH ||
|
|
||||||
process.env.MEMENTO_MCP_REMOTE_PATH ||
|
|
||||||
'mcp-remote';
|
|
||||||
const mementoSection = mementoSseUrl
|
|
||||||
? `
|
|
||||||
[mcp_servers.memento-mcp]
|
|
||||||
command = ${JSON.stringify(mementoRemotePath)}
|
|
||||||
args = [${JSON.stringify(mementoSseUrl)}, "--header", ${JSON.stringify(`Authorization:Bearer ${mementoAccessKey}`)}]
|
|
||||||
`
|
|
||||||
: '';
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
sessionConfigPath,
|
sessionConfigPath,
|
||||||
toml.trimEnd() + '\n' + mcpSection + mementoSection,
|
toml.trimEnd() + '\n' + mcpSection,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,9 +455,6 @@ export function prepareGroupEnvironment(
|
|||||||
'CLAUDE_EFFORT',
|
'CLAUDE_EFFORT',
|
||||||
'CODEX_MODEL',
|
'CODEX_MODEL',
|
||||||
'CODEX_EFFORT',
|
'CODEX_EFFORT',
|
||||||
'MEMENTO_MCP_SSE_URL',
|
|
||||||
'MEMENTO_ACCESS_KEY',
|
|
||||||
'MEMENTO_MCP_REMOTE_PATH',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const env = buildBaseRunnerEnv({
|
const env = buildBaseRunnerEnv({
|
||||||
|
|||||||
147
src/db.test.ts
147
src/db.test.ts
@@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
_initTestDatabase,
|
_initTestDatabase,
|
||||||
_initTestDatabaseFromFile,
|
_initTestDatabaseFromFile,
|
||||||
|
_setMemoryTimestampsForTests,
|
||||||
_setRegisteredGroupForTests,
|
_setRegisteredGroupForTests,
|
||||||
assignRoom,
|
assignRoom,
|
||||||
claimServiceHandoff,
|
claimServiceHandoff,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
getNewMessagesBySeq,
|
getNewMessagesBySeq,
|
||||||
getOpenWorkItem,
|
getOpenWorkItem,
|
||||||
getPendingServiceHandoffs,
|
getPendingServiceHandoffs,
|
||||||
|
recallMemories,
|
||||||
getRegisteredAgentTypesForJid,
|
getRegisteredAgentTypesForJid,
|
||||||
getMessagesSince,
|
getMessagesSince,
|
||||||
getNewMessages,
|
getNewMessages,
|
||||||
@@ -46,6 +48,7 @@ import {
|
|||||||
setSession,
|
setSession,
|
||||||
setRouterStateForService,
|
setRouterStateForService,
|
||||||
setExplicitRoomMode,
|
setExplicitRoomMode,
|
||||||
|
rememberMemory,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
updateRegisteredGroupName,
|
updateRegisteredGroupName,
|
||||||
@@ -1224,6 +1227,28 @@ describe('service handoff completion', () => {
|
|||||||
'dc:handoff': '5',
|
'dc:handoff': '5',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('stores the intended handoff role when provided', () => {
|
||||||
|
const handoff = createServiceHandoff({
|
||||||
|
chat_jid: 'dc:handoff-role',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
source_service_id: 'claude',
|
||||||
|
target_service_id: 'codex-review',
|
||||||
|
target_agent_type: 'codex',
|
||||||
|
prompt: 'please review',
|
||||||
|
reason: 'reviewer-claude-429',
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(handoff.intended_role).toBe('reviewer');
|
||||||
|
expect(getPendingServiceHandoffs('codex-review')).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: handoff.id,
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
reason: 'reviewer-claude-429',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('message seq cursors', () => {
|
describe('message seq cursors', () => {
|
||||||
@@ -1280,6 +1305,128 @@ describe('message seq cursors', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('memories', () => {
|
||||||
|
it('recalls scoped memories through FTS and exact keyword matching', () => {
|
||||||
|
rememberMemory({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:test-group',
|
||||||
|
content: '세션 재시작 후에도 방 메모리를 주입한다.',
|
||||||
|
keywords: ['room:test-group', 'session-reset'],
|
||||||
|
sourceKind: 'compact',
|
||||||
|
sourceRef: 'compact:1',
|
||||||
|
});
|
||||||
|
rememberMemory({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:test-group',
|
||||||
|
content: '이 메모리는 다른 검색어다.',
|
||||||
|
keywords: ['room:test-group'],
|
||||||
|
sourceKind: 'compact',
|
||||||
|
sourceRef: 'compact:2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const byText = recallMemories({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:test-group',
|
||||||
|
text: 'session reset',
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(byText).toHaveLength(1);
|
||||||
|
expect(byText[0].content).toContain('방 메모리를 주입한다');
|
||||||
|
|
||||||
|
const byKeyword = recallMemories({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:test-group',
|
||||||
|
keywords: ['session-reset'],
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
expect(byKeyword).toHaveLength(1);
|
||||||
|
expect(byKeyword[0].content).toContain('방 메모리를 주입한다');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archives old memories when a scope exceeds its bounded limit', () => {
|
||||||
|
for (let index = 0; index < 305; index += 1) {
|
||||||
|
rememberMemory({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:bounded',
|
||||||
|
content: `memory-${index}`,
|
||||||
|
keywords: ['room:bounded'],
|
||||||
|
sourceKind: 'compact',
|
||||||
|
sourceRef: `compact:${index}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recalled = recallMemories({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:bounded',
|
||||||
|
limit: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recalled).toHaveLength(300);
|
||||||
|
expect(recalled.some((memory) => memory.content === 'memory-0')).toBe(false);
|
||||||
|
expect(recalled.some((memory) => memory.content === 'memory-304')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archives stale compact memories before recall using last_used_at TTL', () => {
|
||||||
|
const staleId = rememberMemory({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ttl',
|
||||||
|
content: '오래된 compact memory',
|
||||||
|
keywords: ['room:ttl'],
|
||||||
|
sourceKind: 'compact',
|
||||||
|
sourceRef: 'compact:stale',
|
||||||
|
});
|
||||||
|
rememberMemory({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ttl',
|
||||||
|
content: '최근에 다시 쓰인 compact memory',
|
||||||
|
keywords: ['room:ttl'],
|
||||||
|
sourceKind: 'compact',
|
||||||
|
sourceRef: 'compact:fresh',
|
||||||
|
});
|
||||||
|
|
||||||
|
_setMemoryTimestampsForTests(staleId, {
|
||||||
|
createdAt: '2020-01-01T00:00:00.000Z',
|
||||||
|
lastUsedAt: '2020-01-02T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const recalled = recallMemories({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ttl',
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recalled.some((memory) => memory.content === '오래된 compact memory')).toBe(false);
|
||||||
|
expect(
|
||||||
|
recalled.some((memory) => memory.content === '최근에 다시 쓰인 compact memory'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps explicit memories even when they are old', () => {
|
||||||
|
const explicitId = rememberMemory({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ttl-explicit',
|
||||||
|
content: '관리자가 남긴 고정 규칙',
|
||||||
|
keywords: ['room:ttl-explicit'],
|
||||||
|
sourceKind: 'explicit',
|
||||||
|
sourceRef: 'msg:1',
|
||||||
|
});
|
||||||
|
|
||||||
|
_setMemoryTimestampsForTests(explicitId, {
|
||||||
|
createdAt: '2020-01-01T00:00:00.000Z',
|
||||||
|
lastUsedAt: '2020-01-02T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const recalled = recallMemories({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ttl-explicit',
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recalled).toHaveLength(1);
|
||||||
|
expect(recalled[0].content).toBe('관리자가 남긴 고정 규칙');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('work items', () => {
|
describe('work items', () => {
|
||||||
it('tracks produced, retry, and delivered states', () => {
|
it('tracks produced, retry, and delivered states', () => {
|
||||||
const item = createProducedWorkItem({
|
const item = createProducedWorkItem({
|
||||||
|
|||||||
397
src/db.ts
397
src/db.ts
@@ -72,6 +72,31 @@ export interface AssignRoomInput {
|
|||||||
ownerAgentConfig?: RegisteredGroup['agentConfig'];
|
ownerAgentConfig?: RegisteredGroup['agentConfig'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MemoryScopeKind = 'room' | 'user' | 'project' | 'global';
|
||||||
|
export type MemorySourceKind = 'compact' | 'explicit' | 'import' | 'system';
|
||||||
|
|
||||||
|
export interface MemoryRecord {
|
||||||
|
id: number;
|
||||||
|
scopeKind: MemoryScopeKind;
|
||||||
|
scopeKey: string;
|
||||||
|
content: string;
|
||||||
|
keywords: string[];
|
||||||
|
memoryKind: string | null;
|
||||||
|
sourceKind: MemorySourceKind;
|
||||||
|
sourceRef: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
lastUsedAt: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecallMemoryQuery {
|
||||||
|
scopeKind: MemoryScopeKind;
|
||||||
|
scopeKey: string;
|
||||||
|
text?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RoomRegistrationSnapshot {
|
interface RoomRegistrationSnapshot {
|
||||||
name: string;
|
name: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
@@ -134,6 +159,7 @@ export interface ServiceHandoff {
|
|||||||
start_seq: number | null;
|
start_seq: number | null;
|
||||||
end_seq: number | null;
|
end_seq: number | null;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
|
intended_role: PairedRoomRole | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
claimed_at: string | null;
|
claimed_at: string | null;
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
@@ -383,14 +409,53 @@ function createSchema(database: Database): void {
|
|||||||
start_seq INTEGER,
|
start_seq INTEGER,
|
||||||
end_seq INTEGER,
|
end_seq INTEGER,
|
||||||
reason TEXT,
|
reason TEXT,
|
||||||
|
intended_role TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
claimed_at TEXT,
|
claimed_at TEXT,
|
||||||
completed_at TEXT,
|
completed_at TEXT,
|
||||||
last_error TEXT,
|
last_error TEXT,
|
||||||
CHECK (status IN ('pending', 'claimed', 'completed', 'failed'))
|
CHECK (status IN ('pending', 'claimed', 'completed', 'failed')),
|
||||||
|
CHECK (intended_role IN ('owner', 'reviewer', 'arbiter') OR intended_role IS NULL)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
|
CREATE INDEX IF NOT EXISTS idx_service_handoffs_target
|
||||||
ON service_handoffs(target_service_id, status, created_at);
|
ON service_handoffs(target_service_id, status, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS memories (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
scope_kind TEXT NOT NULL,
|
||||||
|
scope_key TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
keywords_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
memory_kind TEXT,
|
||||||
|
source_kind TEXT NOT NULL,
|
||||||
|
source_ref TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TEXT,
|
||||||
|
archived_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_scope
|
||||||
|
ON memories(scope_kind, scope_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_active
|
||||||
|
ON memories(scope_kind, scope_key, archived_at, created_at);
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
|
||||||
|
content,
|
||||||
|
keywords,
|
||||||
|
content='',
|
||||||
|
tokenize='unicode61'
|
||||||
|
);
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(rowid, content, keywords)
|
||||||
|
VALUES (new.id, new.content, new.keywords_json);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(memories_fts, rowid, content, keywords)
|
||||||
|
VALUES ('delete', old.id, old.content, old.keywords_json);
|
||||||
|
END;
|
||||||
|
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
||||||
|
INSERT INTO memories_fts(memories_fts, rowid, content, keywords)
|
||||||
|
VALUES ('delete', old.id, old.content, old.keywords_json);
|
||||||
|
INSERT INTO memories_fts(rowid, content, keywords)
|
||||||
|
VALUES (new.id, new.content, new.keywords_json);
|
||||||
|
END;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Add context_mode column if it doesn't exist (migration for existing DBs)
|
// Add context_mode column if it doesn't exist (migration for existing DBs)
|
||||||
@@ -506,6 +571,12 @@ function createSchema(database: Database): void {
|
|||||||
/* column already exists */
|
/* column already exists */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
database.exec(`ALTER TABLE service_handoffs ADD COLUMN intended_role TEXT`);
|
||||||
|
} catch {
|
||||||
|
/* column already exists */
|
||||||
|
}
|
||||||
|
|
||||||
database.exec(
|
database.exec(
|
||||||
`UPDATE room_settings
|
`UPDATE room_settings
|
||||||
SET mode_source = 'explicit'
|
SET mode_source = 'explicit'
|
||||||
@@ -955,6 +1026,323 @@ export function _setStoredRoomOwnerAgentTypeForTests(
|
|||||||
).run(ownerAgentType, new Date().toISOString(), chatJid);
|
).run(ownerAgentType, new Date().toISOString(), chatJid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal - for tests only. */
|
||||||
|
export function _setMemoryTimestampsForTests(
|
||||||
|
id: number,
|
||||||
|
args: {
|
||||||
|
createdAt?: string;
|
||||||
|
lastUsedAt?: string | null;
|
||||||
|
archivedAt?: string | null;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const existing = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT created_at, last_used_at, archived_at
|
||||||
|
FROM memories
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.get(id) as
|
||||||
|
| {
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
archived_at: string | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
if (!existing) throw new Error(`Memory ${id} not found`);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE memories
|
||||||
|
SET created_at = ?, last_used_at = ?, archived_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(
|
||||||
|
args.createdAt ?? existing.created_at,
|
||||||
|
args.lastUsedAt === undefined ? existing.last_used_at : args.lastUsedAt,
|
||||||
|
args.archivedAt === undefined ? existing.archived_at : args.archivedAt,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MEMORY_SCOPE_LIMITS: Record<MemoryScopeKind, number> = {
|
||||||
|
room: 300,
|
||||||
|
user: 100,
|
||||||
|
project: 200,
|
||||||
|
global: 100,
|
||||||
|
};
|
||||||
|
const COMPACT_MEMORY_TTL_DAYS = 30;
|
||||||
|
|
||||||
|
interface MemoryDatabaseRow {
|
||||||
|
id: number;
|
||||||
|
scope_kind: string;
|
||||||
|
scope_key: string;
|
||||||
|
content: string;
|
||||||
|
keywords_json: string;
|
||||||
|
memory_kind: string | null;
|
||||||
|
source_kind: string;
|
||||||
|
source_ref: string | null;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at: string | null;
|
||||||
|
archived_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMemoryKeywords(keywords?: string[]): string[] {
|
||||||
|
if (!Array.isArray(keywords)) return [];
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
keywords
|
||||||
|
.map((keyword) => keyword.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMemoryKeywords(raw: string | null | undefined): string[] {
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return normalizeMemoryKeywords(Array.isArray(parsed) ? parsed : []);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMemoryRow(row: MemoryDatabaseRow): MemoryRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
scopeKind: row.scope_kind as MemoryScopeKind,
|
||||||
|
scopeKey: row.scope_key,
|
||||||
|
content: row.content,
|
||||||
|
keywords: parseMemoryKeywords(row.keywords_json),
|
||||||
|
memoryKind: row.memory_kind,
|
||||||
|
sourceKind: row.source_kind as MemorySourceKind,
|
||||||
|
sourceRef: row.source_ref,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
lastUsedAt: row.last_used_at,
|
||||||
|
archivedAt: row.archived_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMemoryFtsQuery(query: RecallMemoryQuery): string | null {
|
||||||
|
const tokens = normalizeMemoryKeywords([
|
||||||
|
...(query.text?.match(/[\p{L}\p{N}_:-]+/gu) ?? []),
|
||||||
|
...(query.keywords ?? []),
|
||||||
|
]);
|
||||||
|
if (tokens.length === 0) return null;
|
||||||
|
return tokens.map((token) => `"${token.replaceAll('"', '""')}"`).join(' OR ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemoryRowsForScope(
|
||||||
|
scopeKind: MemoryScopeKind,
|
||||||
|
scopeKey: string,
|
||||||
|
): MemoryDatabaseRow[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT *
|
||||||
|
FROM memories
|
||||||
|
WHERE scope_kind = ?
|
||||||
|
AND scope_key = ?
|
||||||
|
AND archived_at IS NULL
|
||||||
|
ORDER BY COALESCE(last_used_at, created_at) DESC, id DESC`,
|
||||||
|
)
|
||||||
|
.all(scopeKind, scopeKey) as MemoryDatabaseRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryFtsRowOrder(query: RecallMemoryQuery): Map<number, number> {
|
||||||
|
const ftsQuery = buildMemoryFtsQuery(query);
|
||||||
|
if (!ftsQuery) return new Map();
|
||||||
|
try {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT memories.id AS id
|
||||||
|
FROM memories_fts
|
||||||
|
JOIN memories ON memories.id = memories_fts.rowid
|
||||||
|
WHERE memories_fts MATCH ?
|
||||||
|
AND memories.scope_kind = ?
|
||||||
|
AND memories.scope_key = ?
|
||||||
|
AND memories.archived_at IS NULL
|
||||||
|
ORDER BY bm25(memories_fts), memories.created_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(
|
||||||
|
ftsQuery,
|
||||||
|
query.scopeKind,
|
||||||
|
query.scopeKey,
|
||||||
|
Math.max(25, (query.limit ?? 10) * 8),
|
||||||
|
) as Array<{ id: number }>;
|
||||||
|
return new Map(rows.map((row, index) => [row.id, rows.length - index]));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ query, error },
|
||||||
|
'Memory FTS query failed; falling back to scope-only recall',
|
||||||
|
);
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchMemories(ids: number[]): void {
|
||||||
|
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
||||||
|
if (uniqueIds.length === 0) return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`UPDATE memories
|
||||||
|
SET last_used_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
);
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const id of uniqueIds) stmt.run(now, id);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function archiveMemory(id: number): void {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE memories
|
||||||
|
SET archived_at = COALESCE(archived_at, ?)
|
||||||
|
WHERE id = ?`,
|
||||||
|
).run(new Date().toISOString(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompactMemoryExpiryCutoff(nowIso: string): string {
|
||||||
|
return new Date(
|
||||||
|
new Date(nowIso).getTime() - COMPACT_MEMORY_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||||
|
).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expireStaleMemories(args?: {
|
||||||
|
scopeKind?: MemoryScopeKind;
|
||||||
|
scopeKey?: string;
|
||||||
|
now?: string;
|
||||||
|
}): number {
|
||||||
|
const nowIso = args?.now ?? new Date().toISOString();
|
||||||
|
const cutoff = buildCompactMemoryExpiryCutoff(nowIso);
|
||||||
|
const scopeClause =
|
||||||
|
args?.scopeKind && args?.scopeKey
|
||||||
|
? 'AND scope_kind = ? AND scope_key = ?'
|
||||||
|
: '';
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`UPDATE memories
|
||||||
|
SET archived_at = COALESCE(archived_at, ?)
|
||||||
|
WHERE archived_at IS NULL
|
||||||
|
AND source_kind = 'compact'
|
||||||
|
AND COALESCE(last_used_at, created_at) < ?
|
||||||
|
${scopeClause}`,
|
||||||
|
);
|
||||||
|
const result = (
|
||||||
|
args?.scopeKind && args?.scopeKey
|
||||||
|
? stmt.run(nowIso, cutoff, args.scopeKind, args.scopeKey)
|
||||||
|
: stmt.run(nowIso, cutoff)
|
||||||
|
) as { changes?: number };
|
||||||
|
return result.changes ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enforceMemoryBounds(
|
||||||
|
scopeKind: MemoryScopeKind,
|
||||||
|
scopeKey: string,
|
||||||
|
): void {
|
||||||
|
const limit = MEMORY_SCOPE_LIMITS[scopeKind];
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id
|
||||||
|
FROM memories
|
||||||
|
WHERE scope_kind = ?
|
||||||
|
AND scope_key = ?
|
||||||
|
AND archived_at IS NULL
|
||||||
|
ORDER BY COALESCE(last_used_at, created_at) DESC, id DESC
|
||||||
|
LIMIT -1 OFFSET ?`,
|
||||||
|
)
|
||||||
|
.all(scopeKind, scopeKey, limit) as Array<{ id: number }>;
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`UPDATE memories
|
||||||
|
SET archived_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
);
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
for (const row of rows) stmt.run(now, row.id);
|
||||||
|
});
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rememberMemory(input: {
|
||||||
|
scopeKind: MemoryScopeKind;
|
||||||
|
scopeKey: string;
|
||||||
|
content: string;
|
||||||
|
keywords?: string[];
|
||||||
|
memoryKind?: string | null;
|
||||||
|
sourceKind: MemorySourceKind;
|
||||||
|
sourceRef?: string | null;
|
||||||
|
}): number {
|
||||||
|
const normalizedContent = input.content.trim();
|
||||||
|
if (!normalizedContent) {
|
||||||
|
throw new Error('Memory content cannot be empty');
|
||||||
|
}
|
||||||
|
const normalizedKeywords = normalizeMemoryKeywords(input.keywords);
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO memories (
|
||||||
|
scope_kind,
|
||||||
|
scope_key,
|
||||||
|
content,
|
||||||
|
keywords_json,
|
||||||
|
memory_kind,
|
||||||
|
source_kind,
|
||||||
|
source_ref,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
).run(
|
||||||
|
input.scopeKind,
|
||||||
|
input.scopeKey,
|
||||||
|
normalizedContent,
|
||||||
|
JSON.stringify(normalizedKeywords),
|
||||||
|
input.memoryKind ?? null,
|
||||||
|
input.sourceKind,
|
||||||
|
input.sourceRef ?? null,
|
||||||
|
createdAt,
|
||||||
|
);
|
||||||
|
const row = db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number };
|
||||||
|
expireStaleMemories({
|
||||||
|
scopeKind: input.scopeKind,
|
||||||
|
scopeKey: input.scopeKey,
|
||||||
|
});
|
||||||
|
enforceMemoryBounds(input.scopeKind, input.scopeKey);
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recallMemories(query: RecallMemoryQuery): MemoryRecord[] {
|
||||||
|
const limit = Math.max(1, query.limit ?? 6);
|
||||||
|
expireStaleMemories({
|
||||||
|
scopeKind: query.scopeKind,
|
||||||
|
scopeKey: query.scopeKey,
|
||||||
|
});
|
||||||
|
const rows = getMemoryRowsForScope(query.scopeKind, query.scopeKey);
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const exactKeywords = new Set(normalizeMemoryKeywords(query.keywords));
|
||||||
|
const ftsOrder = queryFtsRowOrder(query);
|
||||||
|
const useQueryScoring = exactKeywords.size > 0 || Boolean(query.text?.trim());
|
||||||
|
|
||||||
|
const scored = rows
|
||||||
|
.map((row, index) => {
|
||||||
|
const keywords = parseMemoryKeywords(row.keywords_json);
|
||||||
|
const exactMatches = keywords.filter((keyword) => exactKeywords.has(keyword))
|
||||||
|
.length;
|
||||||
|
const ftsScore = ftsOrder.get(row.id) ?? 0;
|
||||||
|
const recencyScore = rows.length - index;
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
matched: exactMatches > 0 || ftsScore > 0,
|
||||||
|
score: exactMatches * 100 + ftsScore * 10 + recencyScore,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => (useQueryScoring ? entry.matched : true))
|
||||||
|
.sort((a, b) => b.score - a.score || b.row.id - a.row.id)
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
const memories = scored.map((entry) => mapMemoryRow(entry.row));
|
||||||
|
touchMemories(memories.map((memory) => memory.id));
|
||||||
|
return memories;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store chat metadata only (no message content).
|
* Store chat metadata only (no message content).
|
||||||
* Used for all chats to enable group discovery without storing sensitive content.
|
* Used for all chats to enable group discovery without storing sensitive content.
|
||||||
@@ -3139,6 +3527,7 @@ export function createServiceHandoff(input: {
|
|||||||
start_seq?: number | null;
|
start_seq?: number | null;
|
||||||
end_seq?: number | null;
|
end_seq?: number | null;
|
||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
|
intended_role?: PairedRoomRole | null;
|
||||||
}): ServiceHandoff {
|
}): ServiceHandoff {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO service_handoffs (
|
`INSERT INTO service_handoffs (
|
||||||
@@ -3150,8 +3539,9 @@ export function createServiceHandoff(input: {
|
|||||||
prompt,
|
prompt,
|
||||||
start_seq,
|
start_seq,
|
||||||
end_seq,
|
end_seq,
|
||||||
reason
|
reason,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
intended_role
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
input.chat_jid,
|
input.chat_jid,
|
||||||
input.group_folder,
|
input.group_folder,
|
||||||
@@ -3162,6 +3552,7 @@ export function createServiceHandoff(input: {
|
|||||||
input.start_seq ?? null,
|
input.start_seq ?? null,
|
||||||
input.end_seq ?? null,
|
input.end_seq ?? null,
|
||||||
input.reason ?? null,
|
input.reason ?? null,
|
||||||
|
input.intended_role ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const lastId = (
|
const lastId = (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
assignRoom,
|
assignRoom,
|
||||||
createTask,
|
createTask,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
|
recallMemories,
|
||||||
getRegisteredAgentTypesForJid,
|
getRegisteredAgentTypesForJid,
|
||||||
getRegisteredGroup,
|
getRegisteredGroup,
|
||||||
getStoredRoomSettings,
|
getStoredRoomSettings,
|
||||||
@@ -196,6 +197,56 @@ describe('schedule_task authorization', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('persist_memory authorization', () => {
|
||||||
|
it('allows a room runtime to persist memory for its own room scope', async () => {
|
||||||
|
await processTaskIpc(
|
||||||
|
{
|
||||||
|
type: 'persist_memory',
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:other-group',
|
||||||
|
content: 'compact summary',
|
||||||
|
keywords: ['room:other-group'],
|
||||||
|
source_kind: 'compact',
|
||||||
|
source_ref: 'compact:test',
|
||||||
|
},
|
||||||
|
'other-group',
|
||||||
|
false,
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const recalled = recallMemories({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:other-group',
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
expect(recalled).toHaveLength(1);
|
||||||
|
expect(recalled[0].content).toBe('compact summary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks persist_memory when scopeKey does not match the source group', async () => {
|
||||||
|
await processTaskIpc(
|
||||||
|
{
|
||||||
|
type: 'persist_memory',
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:third-group',
|
||||||
|
content: 'should be denied',
|
||||||
|
keywords: ['room:third-group'],
|
||||||
|
source_kind: 'compact',
|
||||||
|
},
|
||||||
|
'other-group',
|
||||||
|
false,
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const recalled = recallMemories({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:third-group',
|
||||||
|
limit: 10,
|
||||||
|
});
|
||||||
|
expect(recalled).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- pause_task authorization ---
|
// --- pause_task authorization ---
|
||||||
|
|
||||||
describe('pause_task authorization', () => {
|
describe('pause_task authorization', () => {
|
||||||
|
|||||||
76
src/ipc.ts
76
src/ipc.ts
@@ -12,6 +12,7 @@ import {
|
|||||||
deleteTask,
|
deleteTask,
|
||||||
findDuplicateCiWatcher,
|
findDuplicateCiWatcher,
|
||||||
getTaskById,
|
getTaskById,
|
||||||
|
rememberMemory,
|
||||||
updateTask,
|
updateTask,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { isValidGroupFolder } from './group-folder.js';
|
import { isValidGroupFolder } from './group-folder.js';
|
||||||
@@ -303,6 +304,13 @@ export async function processTaskIpc(
|
|||||||
owner_agent_type?: AgentType;
|
owner_agent_type?: AgentType;
|
||||||
isMain?: boolean;
|
isMain?: boolean;
|
||||||
workDir?: string;
|
workDir?: string;
|
||||||
|
scopeKind?: string;
|
||||||
|
scopeKey?: string;
|
||||||
|
content?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
memory_kind?: string | null;
|
||||||
|
source_kind?: string;
|
||||||
|
source_ref?: string | null;
|
||||||
},
|
},
|
||||||
sourceGroup: string, // Verified identity from IPC directory
|
sourceGroup: string, // Verified identity from IPC directory
|
||||||
isMain: boolean, // Verified from directory path
|
isMain: boolean, // Verified from directory path
|
||||||
@@ -657,6 +665,74 @@ export async function processTaskIpc(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'persist_memory': {
|
||||||
|
if (
|
||||||
|
data.scopeKind !== 'room' ||
|
||||||
|
typeof data.scopeKey !== 'string' ||
|
||||||
|
typeof data.content !== 'string'
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ sourceGroup, data },
|
||||||
|
'Invalid persist_memory request - missing required fields',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedScopeKey = `room:${sourceGroup}`;
|
||||||
|
if (data.scopeKey !== expectedScopeKey) {
|
||||||
|
logger.warn(
|
||||||
|
{ sourceGroup, scopeKey: data.scopeKey, expectedScopeKey },
|
||||||
|
'Unauthorized persist_memory attempt blocked',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.source_kind !== undefined &&
|
||||||
|
data.source_kind !== 'compact' &&
|
||||||
|
data.source_kind !== 'explicit' &&
|
||||||
|
data.source_kind !== 'import' &&
|
||||||
|
data.source_kind !== 'system'
|
||||||
|
) {
|
||||||
|
logger.warn(
|
||||||
|
{ sourceGroup, sourceKind: data.source_kind },
|
||||||
|
'Invalid persist_memory request - unknown source_kind',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.keywords) && !data.keywords.every((v) => typeof v === 'string')) {
|
||||||
|
logger.warn(
|
||||||
|
{ sourceGroup, keywords: data.keywords },
|
||||||
|
'Invalid persist_memory request - keywords must be strings',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
rememberMemory({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: data.scopeKey,
|
||||||
|
content: data.content,
|
||||||
|
keywords: data.keywords,
|
||||||
|
memoryKind:
|
||||||
|
typeof data.memory_kind === 'string' ? data.memory_kind : null,
|
||||||
|
sourceKind:
|
||||||
|
(data.source_kind as 'compact' | 'explicit' | 'import' | 'system' | undefined) ??
|
||||||
|
'compact',
|
||||||
|
sourceRef:
|
||||||
|
typeof data.source_ref === 'string' ? data.source_ref : null,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
sourceGroup,
|
||||||
|
scopeKey: data.scopeKey,
|
||||||
|
sourceKind: data.source_kind ?? 'compact',
|
||||||
|
},
|
||||||
|
'Memory persisted via IPC',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
logger.warn({ type: data.type }, 'Unknown IPC task type');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
buildRoomMemoryKey,
|
|
||||||
formatRoomMemoryBriefing,
|
|
||||||
} from './memento-client.js';
|
|
||||||
|
|
||||||
describe('memento-client helpers', () => {
|
|
||||||
it('builds a stable room memory key from the group folder', () => {
|
|
||||||
expect(buildRoomMemoryKey('ejclaw')).toBe('room:ejclaw');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats recalled fragments into a compact session briefing', () => {
|
|
||||||
const briefing = formatRoomMemoryBriefing('room:ejclaw', [
|
|
||||||
{
|
|
||||||
id: 'frag-1',
|
|
||||||
content: '사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.',
|
|
||||||
type: 'decision',
|
|
||||||
topic: 'room-memory',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'frag-2',
|
|
||||||
content: '자동 recall/reflect를 호스트가 책임지는 방향으로 합의함.',
|
|
||||||
type: 'fact',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(briefing).toContain('## Shared Room Memory');
|
|
||||||
expect(briefing).toContain('room:ejclaw');
|
|
||||||
expect(briefing).toContain(
|
|
||||||
'[decision / room-memory] 사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.',
|
|
||||||
);
|
|
||||||
expect(briefing).toContain(
|
|
||||||
'[fact] 자동 recall/reflect를 호스트가 책임지는 방향으로 합의함.',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims overly long briefings to the configured max length', () => {
|
|
||||||
const briefing = formatRoomMemoryBriefing(
|
|
||||||
'room:ejclaw',
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id: 'frag-1',
|
|
||||||
content: 'a'.repeat(300),
|
|
||||||
type: 'fact',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
120,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(briefing).toBeDefined();
|
|
||||||
expect(briefing!.length).toBeLessThanOrEqual(120);
|
|
||||||
expect(briefing!.endsWith('…')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
||||||
|
|
||||||
import { getEnv } from './env.js';
|
|
||||||
import { logger } from './logger.js';
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 4_000;
|
|
||||||
const DEFAULT_PAGE_SIZE = 6;
|
|
||||||
const DEFAULT_TOKEN_BUDGET = 1_200;
|
|
||||||
const DEFAULT_MAX_BRIEFING_CHARS = 2_000;
|
|
||||||
|
|
||||||
interface MementoConfig {
|
|
||||||
sseUrl: string;
|
|
||||||
accessKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MementoContentBlock {
|
|
||||||
type?: string;
|
|
||||||
text?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MementoCallToolResult {
|
|
||||||
content?: MementoContentBlock[];
|
|
||||||
isError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MementoRecallFragment {
|
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
topic?: string;
|
|
||||||
type?: string;
|
|
||||||
importance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MementoRecallResponse {
|
|
||||||
success?: boolean;
|
|
||||||
fragments?: MementoRecallFragment[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedConfig: MementoConfig | null | undefined;
|
|
||||||
|
|
||||||
function getMementoConfig(): MementoConfig | null {
|
|
||||||
if (cachedConfig !== undefined) return cachedConfig;
|
|
||||||
|
|
||||||
const sseUrl = getEnv('MEMENTO_MCP_SSE_URL');
|
|
||||||
const accessKey = getEnv('MEMENTO_ACCESS_KEY');
|
|
||||||
|
|
||||||
cachedConfig =
|
|
||||||
sseUrl && accessKey
|
|
||||||
? {
|
|
||||||
sseUrl,
|
|
||||||
accessKey,
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return cachedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withTimeout<T>(
|
|
||||||
promise: Promise<T>,
|
|
||||||
timeoutMs: number,
|
|
||||||
label: string,
|
|
||||||
): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
promise.then(
|
|
||||||
(value) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(value);
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(error);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractToolText(result: MementoCallToolResult): string | null {
|
|
||||||
if (!Array.isArray(result.content)) return null;
|
|
||||||
|
|
||||||
const text = result.content
|
|
||||||
.filter(
|
|
||||||
(block): block is MementoContentBlock & { text: string } =>
|
|
||||||
block.type === 'text' && typeof block.text === 'string',
|
|
||||||
)
|
|
||||||
.map((block) => block.text)
|
|
||||||
.join('\n')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return text || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseToolJson<T>(
|
|
||||||
result: MementoCallToolResult,
|
|
||||||
toolName: string,
|
|
||||||
): T | null {
|
|
||||||
const text = extractToolText(result);
|
|
||||||
if (!text) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(text) as T;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
{ toolName, error, text },
|
|
||||||
'Failed to parse Memento tool response JSON',
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callMementoTool<T>(
|
|
||||||
name: string,
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
||||||
): Promise<T | null> {
|
|
||||||
const config = getMementoConfig();
|
|
||||||
if (!config) return null;
|
|
||||||
|
|
||||||
const transport = new SSEClientTransport(new URL(config.sseUrl), {
|
|
||||||
requestInit: {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${config.accessKey}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const client = new Client({ name: 'ejclaw-host', version: '1.0.0' });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withTimeout(client.connect(transport), timeoutMs, `${name}/connect`);
|
|
||||||
const result = await withTimeout(
|
|
||||||
client.callTool({
|
|
||||||
name,
|
|
||||||
arguments: args,
|
|
||||||
}) as Promise<MementoCallToolResult>,
|
|
||||||
timeoutMs,
|
|
||||||
`${name}/call`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.isError) {
|
|
||||||
logger.warn({ toolName: name, args }, 'Memento tool returned an error');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return parseToolJson<T>(result, name);
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn({ toolName: name, args, error }, 'Memento tool call failed');
|
|
||||||
return null;
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimToMaxChars(text: string, maxChars: number): string {
|
|
||||||
if (text.length <= maxChars) return text;
|
|
||||||
return text.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildRoomMemoryKey(groupFolder: string): string {
|
|
||||||
return `room:${groupFolder}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRoomMemoryBriefing(
|
|
||||||
roomKey: string,
|
|
||||||
fragments: MementoRecallFragment[],
|
|
||||||
maxChars = DEFAULT_MAX_BRIEFING_CHARS,
|
|
||||||
): string | undefined {
|
|
||||||
if (fragments.length === 0) return undefined;
|
|
||||||
|
|
||||||
const lines = fragments
|
|
||||||
.map((fragment) => {
|
|
||||||
const meta = [fragment.type, fragment.topic].filter(Boolean).join(' / ');
|
|
||||||
const prefix = meta ? `- [${meta}] ` : '- ';
|
|
||||||
return `${prefix}${fragment.content.trim()}`;
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
if (lines.length === 0) return undefined;
|
|
||||||
|
|
||||||
const text = [
|
|
||||||
'## Shared Room Memory',
|
|
||||||
`Room key: \`${roomKey}\``,
|
|
||||||
'Treat this as background context for a fresh session start. The current conversation always takes precedence.',
|
|
||||||
...lines,
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
return trimToMaxChars(text, maxChars);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildRoomMemoryBriefing(args: {
|
|
||||||
groupFolder: string;
|
|
||||||
groupName: string;
|
|
||||||
timeoutMs?: number;
|
|
||||||
maxChars?: number;
|
|
||||||
}): Promise<string | undefined> {
|
|
||||||
const roomKey = buildRoomMemoryKey(args.groupFolder);
|
|
||||||
const recallResponse = await callMementoTool<MementoRecallResponse>(
|
|
||||||
'recall',
|
|
||||||
{
|
|
||||||
text: `shared room memory for ${args.groupName} (${roomKey})`,
|
|
||||||
keywords: [roomKey],
|
|
||||||
pageSize: DEFAULT_PAGE_SIZE,
|
|
||||||
tokenBudget: DEFAULT_TOKEN_BUDGET,
|
|
||||||
includeLinks: false,
|
|
||||||
excludeSeen: false,
|
|
||||||
},
|
|
||||||
args.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!recallResponse?.success || !recallResponse.fragments?.length) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatRoomMemoryBriefing(
|
|
||||||
roomKey,
|
|
||||||
recallResponse.fragments,
|
|
||||||
args.maxChars ?? DEFAULT_MAX_BRIEFING_CHARS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -142,7 +142,7 @@ vi.mock('./codex-token-rotation.js', () => ({
|
|||||||
markCodexTokenHealthy: vi.fn(),
|
markCodexTokenHealthy: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./memento-client.js', () => ({
|
vi.mock('./sqlite-memory-store.js', () => ({
|
||||||
buildRoomMemoryBriefing: vi.fn(),
|
buildRoomMemoryBriefing: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ import * as agentRunner from './agent-runner.js';
|
|||||||
import type { AgentOutput } from './agent-runner.js';
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
import * as codexTokenRotation from './codex-token-rotation.js';
|
import * as codexTokenRotation from './codex-token-rotation.js';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { buildRoomMemoryBriefing } from './memento-client.js';
|
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
||||||
import { runAgentForGroup } from './message-agent-executor.js';
|
import { runAgentForGroup } from './message-agent-executor.js';
|
||||||
import * as pairedExecutionContext from './paired-execution-context.js';
|
import * as pairedExecutionContext from './paired-execution-context.js';
|
||||||
import * as sessionRecovery from './session-recovery.js';
|
import * as sessionRecovery from './session-recovery.js';
|
||||||
@@ -397,6 +397,123 @@ describe('runAgentForGroup room memory', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('honors a forced reviewer role even when the paired task status is active', async () => {
|
||||||
|
const group = { ...makeGroup(), folder: 'test-group' };
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'paired-task-active',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
review_requested_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runAgentForGroup(makeDeps(), {
|
||||||
|
group,
|
||||||
|
prompt: 'please retry review',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-forced-reviewer',
|
||||||
|
forcedRole: 'reviewer',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
|
group,
|
||||||
|
expect.objectContaining({
|
||||||
|
roomRoleContext: {
|
||||||
|
serviceId: 'claude',
|
||||||
|
role: 'reviewer',
|
||||||
|
ownerServiceId: 'codex-main',
|
||||||
|
reviewerServiceId: 'claude',
|
||||||
|
failoverOwner: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a forced agent type for reviewer failover handoffs', async () => {
|
||||||
|
const group: RegisteredGroup = {
|
||||||
|
...makeGroup(),
|
||||||
|
agentType: 'codex',
|
||||||
|
folder: 'test-group',
|
||||||
|
};
|
||||||
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
arbiter_service_id: null,
|
||||||
|
activated_at: null,
|
||||||
|
reason: null,
|
||||||
|
explicit: false,
|
||||||
|
});
|
||||||
|
vi.mocked(db.getLatestOpenPairedTaskForChat).mockReturnValue({
|
||||||
|
id: 'paired-task-reviewer-failover',
|
||||||
|
chat_jid: 'group@test',
|
||||||
|
group_folder: 'test-group',
|
||||||
|
owner_service_id: 'codex-main',
|
||||||
|
reviewer_service_id: 'claude',
|
||||||
|
title: null,
|
||||||
|
source_ref: 'HEAD',
|
||||||
|
plan_notes: null,
|
||||||
|
round_trip_count: 0,
|
||||||
|
review_requested_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
status: 'active',
|
||||||
|
arbiter_verdict: null,
|
||||||
|
arbiter_requested_at: null,
|
||||||
|
completion_reason: null,
|
||||||
|
created_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
updated_at: '2026-03-31T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
await runAgentForGroup(
|
||||||
|
{
|
||||||
|
...makeDeps(),
|
||||||
|
getSessions: () => ({ 'test-group:reviewer': 'claude-review-session' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group,
|
||||||
|
prompt: 'please retry review with codex',
|
||||||
|
chatJid: 'group@test',
|
||||||
|
runId: 'run-forced-reviewer-codex',
|
||||||
|
forcedRole: 'reviewer',
|
||||||
|
forcedAgentType: 'codex',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(agentRunner.runAgentProcess).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
agentType: 'codex',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: undefined,
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows silent reviewer outputs', async () => {
|
it('allows silent reviewer outputs', async () => {
|
||||||
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
|
const group = { ...makeGroup(), folder: 'test-group', workDir: '/repo' };
|
||||||
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({
|
||||||
@@ -1005,6 +1122,7 @@ describe('runAgentForGroup Claude rotation', () => {
|
|||||||
start_seq: 10,
|
start_seq: 10,
|
||||||
end_seq: 12,
|
end_seq: 12,
|
||||||
reason: 'claude-usage-exhausted',
|
reason: 'claude-usage-exhausted',
|
||||||
|
intended_role: 'owner',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1207,6 +1325,7 @@ describe('runAgentForGroup Claude rotation', () => {
|
|||||||
target_service_id: 'codex-review',
|
target_service_id: 'codex-review',
|
||||||
target_agent_type: 'codex',
|
target_agent_type: 'codex',
|
||||||
reason: 'claude-org-access-denied',
|
reason: 'claude-org-access-denied',
|
||||||
|
intended_role: 'owner',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from './db.js';
|
} from './db.js';
|
||||||
import { GroupQueue } from './group-queue.js';
|
import { GroupQueue } from './group-queue.js';
|
||||||
import { createScopedLogger } from './logger.js';
|
import { createScopedLogger } from './logger.js';
|
||||||
import { buildRoomMemoryBriefing } from './memento-client.js';
|
import { buildRoomMemoryBriefing } from './sqlite-memory-store.js';
|
||||||
import {
|
import {
|
||||||
completePairedExecutionContext,
|
completePairedExecutionContext,
|
||||||
preparePairedExecutionContext,
|
preparePairedExecutionContext,
|
||||||
@@ -44,7 +44,9 @@ import {
|
|||||||
shouldRetryFreshSessionOnAgentFailure,
|
shouldRetryFreshSessionOnAgentFailure,
|
||||||
} from './session-recovery.js';
|
} from './session-recovery.js';
|
||||||
import {
|
import {
|
||||||
|
ARBITER_AGENT_TYPE,
|
||||||
CODEX_REVIEW_SERVICE_ID,
|
CODEX_REVIEW_SERVICE_ID,
|
||||||
|
REVIEWER_AGENT_TYPE,
|
||||||
SERVICE_SESSION_SCOPE,
|
SERVICE_SESSION_SCOPE,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
isClaudeService,
|
isClaudeService,
|
||||||
@@ -69,7 +71,7 @@ import {
|
|||||||
} from './codex-token-rotation.js';
|
} from './codex-token-rotation.js';
|
||||||
import type { CodexRotationReason } from './agent-error-detection.js';
|
import type { CodexRotationReason } from './agent-error-detection.js';
|
||||||
import { getTokenCount } from './token-rotation.js';
|
import { getTokenCount } from './token-rotation.js';
|
||||||
import type { RegisteredGroup } from './types.js';
|
import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
// ── Main executor ─────────────────────────────────────────────────
|
// ── Main executor ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -92,6 +94,8 @@ export async function runAgentForGroup(
|
|||||||
startSeq?: number | null;
|
startSeq?: number | null;
|
||||||
endSeq?: number | null;
|
endSeq?: number | null;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
onOutput?: (output: AgentOutput) => Promise<void>;
|
onOutput?: (output: AgentOutput) => Promise<void>;
|
||||||
},
|
},
|
||||||
): Promise<'success' | 'error'> {
|
): Promise<'success' | 'error'> {
|
||||||
@@ -107,7 +111,13 @@ export async function runAgentForGroup(
|
|||||||
const pairedTask = currentLease.reviewer_service_id
|
const pairedTask = currentLease.reviewer_service_id
|
||||||
? getLatestOpenPairedTaskForChat(chatJid)
|
? getLatestOpenPairedTaskForChat(chatJid)
|
||||||
: null;
|
: null;
|
||||||
const activeRole = resolveActiveRole(pairedTask?.status);
|
const inferredRole = resolveActiveRole(pairedTask?.status);
|
||||||
|
const canHonorForcedRole = Boolean(
|
||||||
|
args.forcedRole === 'owner' ||
|
||||||
|
(args.forcedRole === 'reviewer' && currentLease.reviewer_service_id) ||
|
||||||
|
(args.forcedRole === 'arbiter' && currentLease.arbiter_service_id),
|
||||||
|
);
|
||||||
|
const activeRole = canHonorForcedRole ? args.forcedRole! : inferredRole;
|
||||||
const effectiveServiceId =
|
const effectiveServiceId =
|
||||||
activeRole === 'arbiter'
|
activeRole === 'arbiter'
|
||||||
? currentLease.arbiter_service_id!
|
? currentLease.arbiter_service_id!
|
||||||
@@ -121,10 +131,11 @@ export async function runAgentForGroup(
|
|||||||
group.agentType,
|
group.agentType,
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveAgentType = resolveEffectiveAgentType(
|
const configuredAgentType = resolveEffectiveAgentType(
|
||||||
activeRole,
|
activeRole,
|
||||||
group.agentType,
|
group.agentType,
|
||||||
);
|
);
|
||||||
|
const effectiveAgentType = args.forcedAgentType ?? configuredAgentType;
|
||||||
const effectiveGroup =
|
const effectiveGroup =
|
||||||
effectiveAgentType !== roleAgentPlan.ownerAgentType
|
effectiveAgentType !== roleAgentPlan.ownerAgentType
|
||||||
? { ...group, agentType: effectiveAgentType }
|
? { ...group, agentType: effectiveAgentType }
|
||||||
@@ -137,7 +148,9 @@ export async function runAgentForGroup(
|
|||||||
);
|
);
|
||||||
// Arbiter always starts fresh — never resume a previous session
|
// Arbiter always starts fresh — never resume a previous session
|
||||||
const sessionId =
|
const sessionId =
|
||||||
activeRole === 'arbiter' ? undefined : sessions[sessionFolder];
|
activeRole === 'arbiter' || args.forcedAgentType
|
||||||
|
? undefined
|
||||||
|
: sessions[sessionFolder];
|
||||||
const memoryBriefing = sessionId
|
const memoryBriefing = sessionId
|
||||||
? undefined
|
? undefined
|
||||||
: await buildRoomMemoryBriefing({
|
: await buildRoomMemoryBriefing({
|
||||||
@@ -203,6 +216,29 @@ export async function runAgentForGroup(
|
|||||||
role: activeRole,
|
role: activeRole,
|
||||||
serviceId: effectiveServiceId,
|
serviceId: effectiveServiceId,
|
||||||
});
|
});
|
||||||
|
log.info(
|
||||||
|
{
|
||||||
|
forcedRole: args.forcedRole,
|
||||||
|
forcedAgentType: args.forcedAgentType ?? null,
|
||||||
|
inferredRole,
|
||||||
|
canHonorForcedRole,
|
||||||
|
pairedTaskId: pairedTask?.id,
|
||||||
|
pairedTaskStatus: pairedTask?.status,
|
||||||
|
configuredAgentType,
|
||||||
|
effectiveServiceId,
|
||||||
|
effectiveAgentType,
|
||||||
|
groupAgentType: group.agentType,
|
||||||
|
configuredReviewerAgentType: REVIEWER_AGENT_TYPE,
|
||||||
|
configuredArbiterAgentType: ARBITER_AGENT_TYPE,
|
||||||
|
reviewerServiceId: currentLease.reviewer_service_id,
|
||||||
|
arbiterServiceId: currentLease.arbiter_service_id,
|
||||||
|
reviewerMode,
|
||||||
|
arbiterMode,
|
||||||
|
sessionFolder,
|
||||||
|
resumedSession: sessionId ?? null,
|
||||||
|
},
|
||||||
|
'Resolved execution target for agent turn',
|
||||||
|
);
|
||||||
|
|
||||||
// ── MoA prompt enrichment ─────────────────────────────────────
|
// ── MoA prompt enrichment ─────────────────────────────────────
|
||||||
// When MoA is enabled and we're in arbiter mode, query external API
|
// When MoA is enabled and we're in arbiter mode, query external API
|
||||||
@@ -293,6 +329,7 @@ export async function runAgentForGroup(
|
|||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
end_seq: endSeq ?? null,
|
end_seq: endSeq ?? null,
|
||||||
reason: `arbiter-claude-${reason}`,
|
reason: `arbiter-claude-${reason}`,
|
||||||
|
intended_role: 'arbiter',
|
||||||
});
|
});
|
||||||
log.warn(
|
log.warn(
|
||||||
{ reason },
|
{ reason },
|
||||||
@@ -314,6 +351,7 @@ export async function runAgentForGroup(
|
|||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
end_seq: endSeq ?? null,
|
end_seq: endSeq ?? null,
|
||||||
reason: `reviewer-claude-${reason}`,
|
reason: `reviewer-claude-${reason}`,
|
||||||
|
intended_role: 'reviewer',
|
||||||
});
|
});
|
||||||
log.warn(
|
log.warn(
|
||||||
{ reason },
|
{ reason },
|
||||||
@@ -333,6 +371,7 @@ export async function runAgentForGroup(
|
|||||||
start_seq: startSeq ?? null,
|
start_seq: startSeq ?? null,
|
||||||
end_seq: endSeq ?? null,
|
end_seq: endSeq ?? null,
|
||||||
reason: `claude-${reason}`,
|
reason: `claude-${reason}`,
|
||||||
|
intended_role: activeRole,
|
||||||
});
|
});
|
||||||
log.warn(
|
log.warn(
|
||||||
{ reason },
|
{ reason },
|
||||||
|
|||||||
@@ -183,7 +183,10 @@ vi.mock('./session-commands.js', () => ({
|
|||||||
import * as agentRunner from './agent-runner.js';
|
import * as agentRunner from './agent-runner.js';
|
||||||
import * as db from './db.js';
|
import * as db from './db.js';
|
||||||
import { resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { createMessageRuntime } from './message-runtime.js';
|
import {
|
||||||
|
createMessageRuntime,
|
||||||
|
resolveHandoffRoleOverride,
|
||||||
|
} from './message-runtime.js';
|
||||||
import * as config from './config.js';
|
import * as config from './config.js';
|
||||||
import * as serviceRouting from './service-routing.js';
|
import * as serviceRouting from './service-routing.js';
|
||||||
import type { Channel, RegisteredGroup } from './types.js';
|
import type { Channel, RegisteredGroup } from './types.js';
|
||||||
@@ -223,6 +226,33 @@ describe('createMessageRuntime', () => {
|
|||||||
vi.mocked(config.isReviewService).mockReturnValue(false);
|
vi.mocked(config.isReviewService).mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefers intended_role over reason prefixes for handoff role resolution', () => {
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
intended_role: 'reviewer',
|
||||||
|
reason: 'claude-429',
|
||||||
|
}),
|
||||||
|
).toBe('reviewer');
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
intended_role: null,
|
||||||
|
reason: 'arbiter-claude-429',
|
||||||
|
}),
|
||||||
|
).toBe('arbiter');
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
intended_role: null,
|
||||||
|
reason: 'reviewer-claude-usage-exhausted',
|
||||||
|
}),
|
||||||
|
).toBe('reviewer');
|
||||||
|
expect(
|
||||||
|
resolveHandoffRoleOverride({
|
||||||
|
intended_role: null,
|
||||||
|
reason: 'claude-usage-exhausted',
|
||||||
|
}),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores generic failure bot messages in paired rooms', async () => {
|
it('ignores generic failure bot messages in paired rooms', async () => {
|
||||||
const chatJid = 'group@test';
|
const chatJid = 'group@test';
|
||||||
const group = makeGroup('codex');
|
const group = makeGroup('codex');
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
Channel,
|
Channel,
|
||||||
NewMessage,
|
NewMessage,
|
||||||
|
type AgentType,
|
||||||
|
type PairedRoomRole,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
type PairedTask,
|
type PairedTask,
|
||||||
type PairedTurnOutput,
|
type PairedTurnOutput,
|
||||||
@@ -94,6 +96,21 @@ export function isDuplicateOfLastBotFinal(
|
|||||||
return normalizedLast === normalizedCurrent && normalizedLast.length > 0;
|
return normalizedLast === normalizedCurrent && normalizedLast.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveHandoffRoleOverride(
|
||||||
|
handoff: Pick<ServiceHandoff, 'intended_role' | 'reason'>,
|
||||||
|
): PairedRoomRole | undefined {
|
||||||
|
if (handoff.intended_role) {
|
||||||
|
return handoff.intended_role;
|
||||||
|
}
|
||||||
|
if (handoff.reason?.startsWith('reviewer-')) {
|
||||||
|
return 'reviewer';
|
||||||
|
}
|
||||||
|
if (handoff.reason?.startsWith('arbiter-')) {
|
||||||
|
return 'arbiter';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MessageRuntimeDeps {
|
export interface MessageRuntimeDeps {
|
||||||
assistantName: string;
|
assistantName: string;
|
||||||
idleTimeout: number;
|
idleTimeout: number;
|
||||||
@@ -415,6 +432,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
startSeq?: number | null;
|
startSeq?: number | null;
|
||||||
endSeq?: number | null;
|
endSeq?: number | null;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
},
|
},
|
||||||
): Promise<'success' | 'error'> =>
|
): Promise<'success' | 'error'> =>
|
||||||
runAgentForGroup(
|
runAgentForGroup(
|
||||||
@@ -434,6 +453,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
startSeq: options?.startSeq,
|
startSeq: options?.startSeq,
|
||||||
endSeq: options?.endSeq,
|
endSeq: options?.endSeq,
|
||||||
hasHumanMessage: options?.hasHumanMessage,
|
hasHumanMessage: options?.hasHumanMessage,
|
||||||
|
forcedRole: options?.forcedRole,
|
||||||
|
forcedAgentType: options?.forcedAgentType,
|
||||||
onOutput,
|
onOutput,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -447,6 +468,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
startSeq: number | null;
|
startSeq: number | null;
|
||||||
endSeq: number | null;
|
endSeq: number | null;
|
||||||
hasHumanMessage?: boolean;
|
hasHumanMessage?: boolean;
|
||||||
|
forcedRole?: PairedRoomRole;
|
||||||
|
forcedAgentType?: AgentType;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
outputStatus: 'success' | 'error';
|
outputStatus: 'success' | 'error';
|
||||||
deliverySucceeded: boolean;
|
deliverySucceeded: boolean;
|
||||||
@@ -460,7 +483,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
}> => {
|
}> => {
|
||||||
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
|
const { group, prompt, chatJid, runId, channel, startSeq, endSeq } = args;
|
||||||
const isClaudeCodeAgent =
|
const isClaudeCodeAgent =
|
||||||
(group.agentType || 'claude-code') === 'claude-code';
|
(args.forcedAgentType ?? group.agentType ?? 'claude-code') ===
|
||||||
|
'claude-code';
|
||||||
|
|
||||||
const turnController = new MessageTurnController({
|
const turnController = new MessageTurnController({
|
||||||
chatJid,
|
chatJid,
|
||||||
@@ -478,7 +502,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
const workItem = createProducedWorkItem({
|
const workItem = createProducedWorkItem({
|
||||||
group_folder: group.folder,
|
group_folder: group.folder,
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
agent_type: group.agentType || 'claude-code',
|
agent_type:
|
||||||
|
args.forcedAgentType ?? group.agentType ?? 'claude-code',
|
||||||
start_seq: startSeq,
|
start_seq: startSeq,
|
||||||
end_seq: endSeq,
|
end_seq: endSeq,
|
||||||
result_payload: text,
|
result_payload: text,
|
||||||
@@ -503,7 +528,13 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
chatJid,
|
chatJid,
|
||||||
runId,
|
runId,
|
||||||
(result) => turnController.handleOutput(result),
|
(result) => turnController.handleOutput(result),
|
||||||
{ startSeq, endSeq, hasHumanMessage: args.hasHumanMessage },
|
{
|
||||||
|
startSeq,
|
||||||
|
endSeq,
|
||||||
|
hasHumanMessage: args.hasHumanMessage,
|
||||||
|
forcedRole: args.forcedRole,
|
||||||
|
forcedAgentType: args.forcedAgentType,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { deliverySucceeded, visiblePhase } =
|
const { deliverySucceeded, visiblePhase } =
|
||||||
@@ -574,20 +605,35 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
|
|
||||||
// Reviewer/arbiter failover handoffs should run via the appropriate
|
// Reviewer/arbiter failover handoffs should run via the appropriate
|
||||||
// channel so they execute in the correct role mode.
|
// channel so they execute in the correct role mode.
|
||||||
const isReviewerHandoff = handoff.reason?.startsWith('reviewer-');
|
const handoffRole = resolveHandoffRoleOverride(handoff);
|
||||||
const isArbiterHandoff = handoff.reason?.startsWith('arbiter-');
|
|
||||||
let handoffChannel = channel;
|
let handoffChannel = channel;
|
||||||
if (isReviewerHandoff) {
|
if (handoffRole === 'reviewer') {
|
||||||
const revChName =
|
const revChName =
|
||||||
REVIEWER_AGENT_TYPE === 'claude-code' ? 'discord' : 'discord-review';
|
handoff.target_agent_type === 'claude-code'
|
||||||
|
? 'discord'
|
||||||
|
: 'discord-review';
|
||||||
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
|
handoffChannel = findChannelByName(deps.channels, revChName) || channel;
|
||||||
} else if (isArbiterHandoff) {
|
} else if (handoffRole === 'arbiter') {
|
||||||
handoffChannel =
|
handoffChannel =
|
||||||
findChannelByName(deps.channels, 'discord-review') || channel;
|
findChannelByName(deps.channels, 'discord-review') || channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runId = `handoff-${handoff.id}`;
|
const runId = `handoff-${handoff.id}`;
|
||||||
try {
|
try {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
chatJid: handoff.chat_jid,
|
||||||
|
handoffId: handoff.id,
|
||||||
|
runId,
|
||||||
|
handoffRole,
|
||||||
|
targetServiceId: handoff.target_service_id,
|
||||||
|
targetAgentType: handoff.target_agent_type,
|
||||||
|
reason: handoff.reason,
|
||||||
|
intendedRole: handoff.intended_role ?? null,
|
||||||
|
channelName: handoffChannel.name,
|
||||||
|
},
|
||||||
|
'Dispatching claimed service handoff',
|
||||||
|
);
|
||||||
const result = await executeTurn({
|
const result = await executeTurn({
|
||||||
group,
|
group,
|
||||||
prompt: handoff.prompt,
|
prompt: handoff.prompt,
|
||||||
@@ -596,6 +642,8 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): {
|
|||||||
channel: handoffChannel,
|
channel: handoffChannel,
|
||||||
startSeq: handoff.start_seq,
|
startSeq: handoff.start_seq,
|
||||||
endSeq: handoff.end_seq,
|
endSeq: handoff.end_seq,
|
||||||
|
forcedRole: handoffRole,
|
||||||
|
forcedAgentType: handoff.target_agent_type,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.deliverySucceeded) {
|
if (!result.deliverySucceeded) {
|
||||||
|
|||||||
@@ -445,6 +445,51 @@ describe('paired execution context', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('requests arbiter instead of re-reviewing when repeated DONE finalize loops exceed the threshold', () => {
|
||||||
|
vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true);
|
||||||
|
|
||||||
|
const repoDir = createCanonicalRepoWithCommit('reviewed');
|
||||||
|
const approvedSourceRef = resolveTreeRef(repoDir);
|
||||||
|
fs.writeFileSync(path.join(repoDir, 'README.md'), 'changed again\n');
|
||||||
|
execFileSync('git', ['add', 'README.md'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
execFileSync('git', ['commit', '-m', 'code change'], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(db.getPairedTaskById).mockReturnValue(
|
||||||
|
buildPairedTask({
|
||||||
|
status: 'merge_ready',
|
||||||
|
source_ref: approvedSourceRef,
|
||||||
|
round_trip_count: config.ARBITER_DEADLOCK_THRESHOLD,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(db.getPairedWorkspace).mockImplementation((_taskId, role) =>
|
||||||
|
role === 'owner' ? buildWorkspace('owner', repoDir) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
completePairedExecutionContext({
|
||||||
|
taskId: 'task-1',
|
||||||
|
role: 'owner',
|
||||||
|
status: 'succeeded',
|
||||||
|
summary: 'DONE',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(db.updatePairedTask).toHaveBeenCalledWith(
|
||||||
|
'task-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'arbiter_requested',
|
||||||
|
arbiter_requested_at: expect.any(String),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
pairedWorkspaceManager.markPairedTaskReviewReady,
|
||||||
|
).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['BLOCKED', 'NEEDS_CONTEXT'])(
|
it.each(['BLOCKED', 'NEEDS_CONTEXT'])(
|
||||||
'escalates immediately when owner reports %s during finalize without arbiter',
|
'escalates immediately when owner reports %s during finalize without arbiter',
|
||||||
(summary) => {
|
(summary) => {
|
||||||
|
|||||||
@@ -525,6 +525,30 @@ export function completePairedExecutionContext(args: {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (hasNewChanges === true) {
|
if (hasNewChanges === true) {
|
||||||
|
if (task.round_trip_count >= ARBITER_DEADLOCK_THRESHOLD) {
|
||||||
|
if (isArbiterEnabled()) {
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'arbiter_requested',
|
||||||
|
arbiter_requested_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ taskId, roundTrips: task.round_trip_count, hasNewChanges },
|
||||||
|
'Owner finalize DONE loop detected — requesting arbiter',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
updatePairedTask(taskId, {
|
||||||
|
status: 'completed',
|
||||||
|
completion_reason: 'escalated',
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
logger.info(
|
||||||
|
{ taskId, roundTrips: task.round_trip_count, hasNewChanges },
|
||||||
|
'Owner finalize DONE loop detected — escalating to user',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Owner made changes after approval → needs re-review
|
// Owner made changes after approval → needs re-review
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
|
|||||||
103
src/sqlite-memory-store.test.ts
Normal file
103
src/sqlite-memory-store.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { _initTestDatabase, rememberMemory } from './db.js';
|
||||||
|
import {
|
||||||
|
buildRoomMemoryBriefing,
|
||||||
|
buildRoomMemoryKey,
|
||||||
|
formatRoomMemoryBriefing,
|
||||||
|
} from './sqlite-memory-store.js';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_initTestDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sqlite-memory-store helpers', () => {
|
||||||
|
it('builds a stable room memory key from the group folder', () => {
|
||||||
|
expect(buildRoomMemoryKey('ejclaw')).toBe('room:ejclaw');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats recalled memories into a compact session briefing', () => {
|
||||||
|
const briefing = formatRoomMemoryBriefing('room:ejclaw', [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ejclaw',
|
||||||
|
content: '사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.',
|
||||||
|
keywords: ['room:ejclaw'],
|
||||||
|
memoryKind: 'decision',
|
||||||
|
sourceKind: 'explicit',
|
||||||
|
sourceRef: 'msg:1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ejclaw',
|
||||||
|
content: '자동 recall/compact persist를 호스트가 책임지는 방향으로 합의함.',
|
||||||
|
keywords: ['room:ejclaw'],
|
||||||
|
memoryKind: null,
|
||||||
|
sourceKind: 'compact',
|
||||||
|
sourceRef: 'compact:1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(briefing).toContain('## Shared Room Memory');
|
||||||
|
expect(briefing).toContain('room:ejclaw');
|
||||||
|
expect(briefing).toContain(
|
||||||
|
'[decision] 사용자는 세션 리셋 후에도 방 맥락이 이어지길 원함.',
|
||||||
|
);
|
||||||
|
expect(briefing).toContain(
|
||||||
|
'- 자동 recall/compact persist를 호스트가 책임지는 방향으로 합의함.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a room briefing from stored SQLite memories', async () => {
|
||||||
|
rememberMemory({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ejclaw',
|
||||||
|
content: '방 메모리는 새 세션 시작 시에만 주입한다.',
|
||||||
|
keywords: ['room:ejclaw', 'session-start'],
|
||||||
|
sourceKind: 'compact',
|
||||||
|
sourceRef: 'compact:test',
|
||||||
|
});
|
||||||
|
|
||||||
|
const briefing = await buildRoomMemoryBriefing({
|
||||||
|
groupFolder: 'ejclaw',
|
||||||
|
groupName: 'EJClaw',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(briefing).toContain('## Shared Room Memory');
|
||||||
|
expect(briefing).toContain('방 메모리는 새 세션 시작 시에만 주입한다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims overly long briefings to the configured max length', () => {
|
||||||
|
const briefing = formatRoomMemoryBriefing(
|
||||||
|
'room:ejclaw',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: 'room:ejclaw',
|
||||||
|
content: 'a'.repeat(300),
|
||||||
|
keywords: [],
|
||||||
|
memoryKind: null,
|
||||||
|
sourceKind: 'compact',
|
||||||
|
sourceRef: 'compact:1',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(briefing).toBeDefined();
|
||||||
|
expect(briefing!.length).toBeLessThanOrEqual(120);
|
||||||
|
expect(briefing!.endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/sqlite-memory-store.ts
Normal file
62
src/sqlite-memory-store.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { type MemoryRecord, recallMemories } from './db.js';
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 6;
|
||||||
|
const DEFAULT_MAX_BRIEFING_CHARS = 2_000;
|
||||||
|
|
||||||
|
function trimToMaxChars(text: string, maxChars: number): string {
|
||||||
|
if (text.length <= maxChars) return text;
|
||||||
|
return text.slice(0, Math.max(0, maxChars - 1)).trimEnd() + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoomMemoryKey(groupFolder: string): string {
|
||||||
|
return `room:${groupFolder}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRoomMemoryBriefing(
|
||||||
|
roomKey: string,
|
||||||
|
memories: MemoryRecord[],
|
||||||
|
maxChars = DEFAULT_MAX_BRIEFING_CHARS,
|
||||||
|
): string | undefined {
|
||||||
|
if (memories.length === 0) return undefined;
|
||||||
|
|
||||||
|
const lines = memories
|
||||||
|
.map((memory) => {
|
||||||
|
const content = memory.content.trim();
|
||||||
|
if (!content) return null;
|
||||||
|
const prefix = memory.memoryKind ? `- [${memory.memoryKind}] ` : '- ';
|
||||||
|
return `${prefix}${content}`;
|
||||||
|
})
|
||||||
|
.filter((line): line is string => Boolean(line));
|
||||||
|
|
||||||
|
if (lines.length === 0) return undefined;
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
'## Shared Room Memory',
|
||||||
|
`Room key: \`${roomKey}\``,
|
||||||
|
'Treat this as background context for a fresh session start. The current conversation always takes precedence.',
|
||||||
|
...lines,
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return trimToMaxChars(text, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildRoomMemoryBriefing(args: {
|
||||||
|
groupFolder: string;
|
||||||
|
groupName?: string;
|
||||||
|
maxChars?: number;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<string | undefined> {
|
||||||
|
void args.groupName;
|
||||||
|
const roomKey = buildRoomMemoryKey(args.groupFolder);
|
||||||
|
const memories = recallMemories({
|
||||||
|
scopeKind: 'room',
|
||||||
|
scopeKey: roomKey,
|
||||||
|
limit: args.limit ?? DEFAULT_PAGE_SIZE,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatRoomMemoryBriefing(
|
||||||
|
roomKey,
|
||||||
|
memories,
|
||||||
|
args.maxChars ?? DEFAULT_MAX_BRIEFING_CHARS,
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user