fix: restore regressions from discord-only refactor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Claude Platform Rules
|
# Claude Platform Rules
|
||||||
|
|
||||||
You are Andy, a personal assistant.
|
You are 클코, a personal assistant powered by Claude Code.
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
|
|
||||||
|
|||||||
@@ -511,6 +511,18 @@ async function runQuery(
|
|||||||
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(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)] }],
|
||||||
@@ -539,7 +551,22 @@ async function runQuery(
|
|||||||
if (message.type === 'result') {
|
if (message.type === 'result') {
|
||||||
resultCount++;
|
resultCount++;
|
||||||
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
const textResult = 'result' in message ? (message as { result?: string }).result : null;
|
||||||
|
const isError = message.subtype?.startsWith('error');
|
||||||
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
|
log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
|
||||||
|
if (isError) {
|
||||||
|
// Log full error details for debugging
|
||||||
|
const msg = message as Record<string, unknown>;
|
||||||
|
const errorDetail = JSON.stringify({
|
||||||
|
subtype: message.subtype,
|
||||||
|
result: textResult?.slice(0, 500),
|
||||||
|
errors: msg.errors,
|
||||||
|
stop_reason: msg.stop_reason,
|
||||||
|
duration_ms: msg.duration_ms,
|
||||||
|
duration_api_ms: msg.duration_api_ms,
|
||||||
|
session_id: msg.session_id,
|
||||||
|
});
|
||||||
|
log(`Error result detail: ${errorDetail}`);
|
||||||
|
}
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: textResult || null,
|
result: textResult || null,
|
||||||
@@ -747,7 +774,11 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
const errorStack = err instanceof Error ? err.stack : undefined;
|
||||||
|
const errorCause = err instanceof Error && err.cause ? String(err.cause) : undefined;
|
||||||
log(`Agent error: ${errorMessage}`);
|
log(`Agent error: ${errorMessage}`);
|
||||||
|
if (errorStack) log(`Stack: ${errorStack}`);
|
||||||
|
if (errorCause) log(`Cause: ${errorCause}`);
|
||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
result: null,
|
result: null,
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import { z } from 'zod';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
|
import {
|
||||||
|
buildCiWatchPrompt,
|
||||||
|
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
|
normalizeWatchCiIntervalSeconds,
|
||||||
|
} from './watch-ci.js';
|
||||||
|
|
||||||
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
||||||
const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
|
const MESSAGES_DIR = path.join(IPC_DIR, 'messages');
|
||||||
@@ -152,6 +157,90 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
'watch_ci',
|
||||||
|
'Schedule a background CI watcher that checks until a run or check reaches a terminal state, then sends one message and cancels itself.',
|
||||||
|
{
|
||||||
|
target: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'What to watch, for example "PR #123 checks" or "GitHub Actions run 987654321".',
|
||||||
|
),
|
||||||
|
check_instructions: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'Exact steps or commands to check status and what details matter when it finishes.',
|
||||||
|
),
|
||||||
|
poll_interval_seconds: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(30)
|
||||||
|
.max(3600)
|
||||||
|
.default(60)
|
||||||
|
.describe('How often to poll in seconds. Default 60, minimum 30.'),
|
||||||
|
context_mode: z
|
||||||
|
.enum(['group', 'isolated'])
|
||||||
|
.default(DEFAULT_WATCH_CI_CONTEXT_MODE)
|
||||||
|
.describe(
|
||||||
|
'group=runs with chat history and memory, isolated=fresh session (include all context in check_instructions). Default: isolated.',
|
||||||
|
),
|
||||||
|
target_group_jid: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'(Main group only) JID of the group to schedule the watcher for. Defaults to the current group.',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
let pollSeconds: number;
|
||||||
|
try {
|
||||||
|
pollSeconds = normalizeWatchCiIntervalSeconds(args.poll_interval_seconds);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: error instanceof Error ? error.message : String(error),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetJid =
|
||||||
|
isMain && args.target_group_jid ? args.target_group_jid : chatJid;
|
||||||
|
const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const prompt = buildCiWatchPrompt({
|
||||||
|
taskId,
|
||||||
|
target: args.target,
|
||||||
|
checkInstructions: args.check_instructions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
type: 'schedule_task',
|
||||||
|
taskId,
|
||||||
|
prompt,
|
||||||
|
schedule_type: 'interval' as const,
|
||||||
|
schedule_value: String(pollSeconds * 1000),
|
||||||
|
context_mode: args.context_mode || DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
|
targetJid,
|
||||||
|
createdBy: groupFolder,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
writeIpcFile(TASKS_DIR, data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text' as const,
|
||||||
|
text: `CI watcher scheduled: ${taskId} (${pollSeconds}s)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
'list_tasks',
|
'list_tasks',
|
||||||
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
"List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.",
|
||||||
|
|||||||
72
runners/agent-runner/src/watch-ci.ts
Normal file
72
runners/agent-runner/src/watch-ci.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export const DEFAULT_WATCH_CI_INTERVAL_SECONDS = 60;
|
||||||
|
export const MIN_WATCH_CI_INTERVAL_SECONDS = 30;
|
||||||
|
export const MAX_WATCH_CI_INTERVAL_SECONDS = 3600;
|
||||||
|
export const DEFAULT_WATCH_CI_CONTEXT_MODE = 'isolated';
|
||||||
|
|
||||||
|
export interface BuildCiWatchPromptArgs {
|
||||||
|
taskId: string;
|
||||||
|
target: string;
|
||||||
|
checkInstructions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWatchCiIntervalSeconds(seconds?: number): number {
|
||||||
|
if (seconds === undefined) {
|
||||||
|
return DEFAULT_WATCH_CI_INTERVAL_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(seconds)) {
|
||||||
|
throw new Error('poll_interval_seconds must be an integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
seconds < MIN_WATCH_CI_INTERVAL_SECONDS ||
|
||||||
|
seconds > MAX_WATCH_CI_INTERVAL_SECONDS
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`poll_interval_seconds must be between ${MIN_WATCH_CI_INTERVAL_SECONDS} and ${MAX_WATCH_CI_INTERVAL_SECONDS}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCiWatchPrompt({
|
||||||
|
taskId,
|
||||||
|
target,
|
||||||
|
checkInstructions,
|
||||||
|
}: BuildCiWatchPromptArgs): string {
|
||||||
|
return `
|
||||||
|
[BACKGROUND CI WATCH]
|
||||||
|
|
||||||
|
You are running as an EJClaw background CI watcher.
|
||||||
|
|
||||||
|
Watch target:
|
||||||
|
${target}
|
||||||
|
|
||||||
|
Task ID:
|
||||||
|
${taskId}
|
||||||
|
|
||||||
|
Check instructions:
|
||||||
|
${checkInstructions}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use the watch target and check instructions in this prompt as the source of truth for what to inspect.
|
||||||
|
- On each run, check whether the target is still queued, pending, running, in progress, or otherwise non-terminal.
|
||||||
|
- If it is still not finished, send no visible message and end this run quietly.
|
||||||
|
- If it reached a terminal state such as success, failure, cancelled, timed out, neutral, skipped, or action required:
|
||||||
|
1. Send exactly one concise completion message with \`send_message\`.
|
||||||
|
2. Format it as a short multiline summary when possible, not one long paragraph.
|
||||||
|
3. Preferred shape:
|
||||||
|
- First line: \`CI 완료: <target>\`
|
||||||
|
- Second line: \`판정: <one-line conclusion>\`
|
||||||
|
- Then 2-4 flat bullet points with only the most important metrics, errors, or comparisons.
|
||||||
|
- Optional final line: \`다음: <next action>\` if a concrete follow-up is needed.
|
||||||
|
4. Adapt the content to the specific CI. Do not invent fixed fields when they do not fit.
|
||||||
|
5. Avoid tables unless they are clearly the shortest readable format.
|
||||||
|
6. Keep the message compact and easy for other agents to parse.
|
||||||
|
7. Call \`cancel_task\` with task_id "${taskId}" so this watcher stops itself.
|
||||||
|
- If you hit a transient problem such as a rate limit, network issue, or temporary auth failure, send no visible message and leave the task active for the next retry.
|
||||||
|
- Prefer no normal final response. Use \`send_message\` for the completion message, and keep any non-user-facing notes inside \`<internal>\` tags if needed.
|
||||||
|
- Do not claim continued monitoring after you cancel the task.
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
49
runners/agent-runner/test/watch-ci.test.ts
Normal file
49
runners/agent-runner/test/watch-ci.test.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCiWatchPrompt,
|
||||||
|
DEFAULT_WATCH_CI_CONTEXT_MODE,
|
||||||
|
normalizeWatchCiIntervalSeconds,
|
||||||
|
} from '../src/watch-ci.js';
|
||||||
|
|
||||||
|
describe('watch-ci helpers', () => {
|
||||||
|
it('builds a self-cancelling CI watch prompt', () => {
|
||||||
|
const prompt = buildCiWatchPrompt({
|
||||||
|
taskId: 'task-123',
|
||||||
|
target: 'PR #42 checks',
|
||||||
|
checkInstructions:
|
||||||
|
'Use gh pr checks 42 and summarize only terminal results.',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain('PR #42 checks');
|
||||||
|
expect(prompt).toContain('task-123');
|
||||||
|
expect(prompt).toContain('cancel_task');
|
||||||
|
expect(prompt).toContain('send_message');
|
||||||
|
expect(prompt).toContain('gh pr checks 42');
|
||||||
|
expect(prompt).toContain('CI 완료: <target>');
|
||||||
|
expect(prompt).toContain('판정: <one-line conclusion>');
|
||||||
|
expect(prompt).toContain(
|
||||||
|
'Use the watch target and check instructions in this prompt as the source of truth',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults CI watchers to isolated context', () => {
|
||||||
|
expect(DEFAULT_WATCH_CI_CONTEXT_MODE).toBe('isolated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes valid poll intervals', () => {
|
||||||
|
expect(normalizeWatchCiIntervalSeconds()).toBe(60);
|
||||||
|
expect(normalizeWatchCiIntervalSeconds(30)).toBe(30);
|
||||||
|
expect(normalizeWatchCiIntervalSeconds(600)).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid poll intervals', () => {
|
||||||
|
expect(() => normalizeWatchCiIntervalSeconds(29)).toThrow(
|
||||||
|
/between 30 and 3600/i,
|
||||||
|
);
|
||||||
|
expect(() => normalizeWatchCiIntervalSeconds(3601)).toThrow(
|
||||||
|
/between 30 and 3600/i,
|
||||||
|
);
|
||||||
|
expect(() => normalizeWatchCiIntervalSeconds(30.5)).toThrow(/integer/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
runners/codex-runner/package-lock.json
generated
14
runners/codex-runner/package-lock.json
generated
@@ -8,7 +8,7 @@
|
|||||||
"name": "nanoclaw-codex-runner",
|
"name": "nanoclaw-codex-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openai/codex-sdk": "^0.115.0"
|
"@openai/codex": "^0.115.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
@@ -103,18 +103,6 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@openai/codex-sdk": {
|
|
||||||
"version": "0.115.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.115.0.tgz",
|
|
||||||
"integrity": "sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@openai/codex": "0.115.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@openai/codex-win32-arm64": {
|
"node_modules/@openai/codex-win32-arm64": {
|
||||||
"name": "@openai/codex",
|
"name": "@openai/codex",
|
||||||
"version": "0.115.0-win32-arm64",
|
"version": "0.115.0-win32-arm64",
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
"name": "nanoclaw-codex-runner",
|
"name": "nanoclaw-codex-runner",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Container-side Codex CLI runner for NanoClaw",
|
"description": "Codex app-server runner for NanoClaw",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openai/codex-sdk": "^0.115.0"
|
"@openai/codex": "^0.115.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface CodexAppServerTurnOptions {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodexAppServerTurnResult {
|
export interface CodexAppServerTurnResult {
|
||||||
@@ -69,6 +70,7 @@ interface PendingRequest {
|
|||||||
interface ActiveTurn {
|
interface ActiveTurn {
|
||||||
threadId: string;
|
threadId: string;
|
||||||
state: AppServerTurnState;
|
state: AppServerTurnState;
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
resolve: (value: CodexAppServerTurnResult) => void;
|
resolve: (value: CodexAppServerTurnResult) => void;
|
||||||
reject: (reason?: unknown) => void;
|
reject: (reason?: unknown) => void;
|
||||||
}
|
}
|
||||||
@@ -215,6 +217,7 @@ export class CodexAppServerClient {
|
|||||||
this.activeTurn = {
|
this.activeTurn = {
|
||||||
threadId,
|
threadId,
|
||||||
state: createInitialAppServerTurnState(),
|
state: createInitialAppServerTurnState(),
|
||||||
|
onProgress: options.onProgress,
|
||||||
resolve,
|
resolve,
|
||||||
reject,
|
reject,
|
||||||
};
|
};
|
||||||
@@ -366,6 +369,20 @@ export class CodexAppServerClient {
|
|||||||
private handleNotification(message: JsonRpcNotification): void {
|
private handleNotification(message: JsonRpcNotification): void {
|
||||||
if (!this.activeTurn) return;
|
if (!this.activeTurn) return;
|
||||||
|
|
||||||
|
if (message.method === 'item/completed') {
|
||||||
|
const item =
|
||||||
|
(message.params?.item as Record<string, unknown> | undefined) ||
|
||||||
|
undefined;
|
||||||
|
if (
|
||||||
|
item?.type === 'agentMessage' &&
|
||||||
|
item.phase !== 'final_answer' &&
|
||||||
|
typeof item.text === 'string' &&
|
||||||
|
item.text.trim().length > 0
|
||||||
|
) {
|
||||||
|
this.activeTurn.onProgress?.(item.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.activeTurn.state = reduceAppServerTurnState(
|
this.activeTurn.state = reduceAppServerTurnState(
|
||||||
this.activeTurn.state,
|
this.activeTurn.state,
|
||||||
message as AppServerTurnEvent,
|
message as AppServerTurnEvent,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* NanoClaw Codex Runner
|
* NanoClaw Codex Runner
|
||||||
*
|
*
|
||||||
* Default runtime is Codex app-server, with SDK fallback available via
|
* App-server only runtime.
|
||||||
* CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails.
|
|
||||||
*
|
*
|
||||||
* Input protocol:
|
* Input protocol:
|
||||||
* Stdin: Full ContainerInput JSON (read until EOF)
|
* Stdin: Full ContainerInput JSON (read until EOF)
|
||||||
@@ -13,7 +12,6 @@
|
|||||||
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Codex, type Thread, type ThreadOptions, type UserInput } from '@openai/codex-sdk';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -38,6 +36,7 @@ interface ContainerInput {
|
|||||||
interface ContainerOutput {
|
interface ContainerOutput {
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
result: string | null;
|
result: string | null;
|
||||||
|
phase?: 'progress' | 'final';
|
||||||
newSessionId?: string;
|
newSessionId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -51,7 +50,6 @@ 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 MAX_TURNS = 100;
|
const MAX_TURNS = 100;
|
||||||
const CODEX_RUNTIME = (process.env.CODEX_RUNTIME || 'app-server').toLowerCase();
|
|
||||||
|
|
||||||
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
||||||
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
||||||
@@ -168,25 +166,6 @@ function extractImagePaths(text: string): { cleanText: string; imagePaths: strin
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSdkInput(text: string): string | UserInput[] {
|
|
||||||
const { cleanText, imagePaths } = extractImagePaths(text);
|
|
||||||
if (imagePaths.length === 0) return text;
|
|
||||||
|
|
||||||
const input: UserInput[] = [];
|
|
||||||
if (cleanText) {
|
|
||||||
input.push({ type: 'text', text: cleanText });
|
|
||||||
}
|
|
||||||
for (const imgPath of imagePaths) {
|
|
||||||
if (fs.existsSync(imgPath)) {
|
|
||||||
input.push({ type: 'local_image', path: imgPath });
|
|
||||||
log(`Adding image input: ${imgPath}`);
|
|
||||||
} else {
|
|
||||||
log(`Image not found, skipping: ${imgPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return input.length > 0 ? input : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAppServerInput(text: string): AppServerInputItem[] {
|
function parseAppServerInput(text: string): AppServerInputItem[] {
|
||||||
const { cleanText, imagePaths } = extractImagePaths(text);
|
const { cleanText, imagePaths } = extractImagePaths(text);
|
||||||
const input: AppServerInputItem[] = [];
|
const input: AppServerInputItem[] = [];
|
||||||
@@ -211,66 +190,43 @@ function parseAppServerInput(text: string): AppServerInputItem[] {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThreadOptions(): ThreadOptions {
|
function formatProgressElapsed(ms: number): string {
|
||||||
const threadOptions: ThreadOptions = {
|
const elapsedSeconds = Math.floor(ms / 10_000) * 10;
|
||||||
workingDirectory: EFFECTIVE_CWD,
|
const hours = Math.floor(elapsedSeconds / 3600);
|
||||||
approvalPolicy: 'never',
|
const minutes = Math.floor((elapsedSeconds % 3600) / 60);
|
||||||
sandboxMode: 'danger-full-access',
|
const seconds = elapsedSeconds % 60;
|
||||||
networkAccessEnabled: true,
|
const parts: string[] = [];
|
||||||
webSearchMode: 'live',
|
|
||||||
};
|
|
||||||
if (CODEX_MODEL) threadOptions.model = CODEX_MODEL;
|
|
||||||
if (CODEX_EFFORT) {
|
|
||||||
threadOptions.modelReasoningEffort =
|
|
||||||
CODEX_EFFORT as ThreadOptions['modelReasoningEffort'];
|
|
||||||
}
|
|
||||||
return threadOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeSdkTurn(
|
if (hours > 0) parts.push(`${hours}시간`);
|
||||||
thread: Thread,
|
if (minutes > 0) parts.push(`${minutes}분`);
|
||||||
input: string | UserInput[],
|
parts.push(`${seconds}초`);
|
||||||
): Promise<{ result: string; error?: string }> {
|
|
||||||
const ac = new AbortController();
|
|
||||||
|
|
||||||
let turnSeconds = 0;
|
return parts.join(' ');
|
||||||
const sentinel = setInterval(() => {
|
|
||||||
if (consumeCloseSentinel()) {
|
|
||||||
log('Close sentinel detected during SDK turn, aborting');
|
|
||||||
ac.abort();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
turnSeconds += 5;
|
|
||||||
if (turnSeconds % 60 === 0) {
|
|
||||||
log(`Turn in progress... (${Math.round(turnSeconds / 60)}min)`);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const turn = await thread.run(input, { signal: ac.signal });
|
|
||||||
return { result: turn.finalResponse };
|
|
||||||
} catch (err) {
|
|
||||||
if (ac.signal.aborted) {
|
|
||||||
return { result: '' };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
result: '',
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
clearInterval(sentinel);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeAppServerTurn(
|
async function executeAppServerTurn(
|
||||||
client: CodexAppServerClient,
|
client: CodexAppServerClient,
|
||||||
threadId: string,
|
threadId: string,
|
||||||
prompt: string,
|
prompt: string,
|
||||||
): Promise<{ result: string; error?: string }> {
|
): Promise<{ result: string | null; error?: string }> {
|
||||||
|
let lastProgressMessage: string | null = null;
|
||||||
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), {
|
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), {
|
||||||
cwd: EFFECTIVE_CWD,
|
cwd: EFFECTIVE_CWD,
|
||||||
model: CODEX_MODEL || undefined,
|
model: CODEX_MODEL || undefined,
|
||||||
effort: CODEX_EFFORT || undefined,
|
effort: CODEX_EFFORT || undefined,
|
||||||
|
onProgress: (message) => {
|
||||||
|
const trimmed = message.trim();
|
||||||
|
if (!trimmed || trimmed === lastProgressMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastProgressMessage = trimmed;
|
||||||
|
writeOutput({
|
||||||
|
status: 'success',
|
||||||
|
phase: 'progress',
|
||||||
|
result: trimmed,
|
||||||
|
newSessionId: threadId,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let elapsedMs = 0;
|
let elapsedMs = 0;
|
||||||
@@ -308,7 +264,7 @@ async function executeAppServerTurn(
|
|||||||
|
|
||||||
elapsedMs += IPC_POLL_MS;
|
elapsedMs += IPC_POLL_MS;
|
||||||
if (elapsedMs > 0 && elapsedMs % 60000 === 0) {
|
if (elapsedMs > 0 && elapsedMs % 60000 === 0) {
|
||||||
log(`Turn in progress... (${Math.round(elapsedMs / 60000)}min)`);
|
log(`Turn in progress... (${formatProgressElapsed(elapsedMs)})`);
|
||||||
}
|
}
|
||||||
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
|
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
|
||||||
};
|
};
|
||||||
@@ -318,13 +274,13 @@ async function executeAppServerTurn(
|
|||||||
try {
|
try {
|
||||||
const { state, result } = await activeTurn.wait();
|
const { state, result } = await activeTurn.wait();
|
||||||
if (state.status === 'completed') {
|
if (state.status === 'completed') {
|
||||||
return { result: result || '' };
|
return { result };
|
||||||
}
|
}
|
||||||
if (state.status === 'interrupted' && consumeCloseSentinel()) {
|
if (state.status === 'interrupted' && consumeCloseSentinel()) {
|
||||||
return { result: result || '' };
|
return { result };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
result: result || '',
|
result,
|
||||||
error: state.errorMessage || `Codex turn finished with status ${state.status}`,
|
error: state.errorMessage || `Codex turn finished with status ${state.status}`,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -332,87 +288,6 @@ async function executeAppServerTurn(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSdkSession(
|
|
||||||
containerInput: ContainerInput,
|
|
||||||
prompt: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const threadOptions = getThreadOptions();
|
|
||||||
const codex = new Codex();
|
|
||||||
|
|
||||||
let thread: Thread;
|
|
||||||
if (containerInput.sessionId) {
|
|
||||||
thread = codex.resumeThread(containerInput.sessionId, threadOptions);
|
|
||||||
log(`Thread resuming (session: ${containerInput.sessionId})`);
|
|
||||||
} else {
|
|
||||||
thread = codex.startThread(threadOptions);
|
|
||||||
log('Thread started (new session)');
|
|
||||||
}
|
|
||||||
|
|
||||||
let turnCount = 0;
|
|
||||||
while (true) {
|
|
||||||
turnCount++;
|
|
||||||
if (turnCount > MAX_TURNS) {
|
|
||||||
log(`Turn limit reached (${MAX_TURNS}), exiting`);
|
|
||||||
writeOutput({
|
|
||||||
status: 'success',
|
|
||||||
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
|
|
||||||
newSessionId: thread.id || undefined,
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = parseSdkInput(prompt);
|
|
||||||
log(`Starting SDK turn ${turnCount}/${MAX_TURNS}...`);
|
|
||||||
|
|
||||||
let { result, error } = await executeSdkTurn(thread, input);
|
|
||||||
|
|
||||||
if (error && turnCount === 1 && containerInput.sessionId) {
|
|
||||||
log(`Resume may have failed, retrying with new thread: ${error}`);
|
|
||||||
thread = codex.startThread(threadOptions);
|
|
||||||
({ result, error } = await executeSdkTurn(thread, input));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (consumeCloseSentinel()) {
|
|
||||||
if (result) {
|
|
||||||
writeOutput({
|
|
||||||
status: 'success',
|
|
||||||
result,
|
|
||||||
newSessionId: thread.id || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
log('Close sentinel detected, exiting SDK runtime');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
log(`SDK turn error: ${error}`);
|
|
||||||
writeOutput({
|
|
||||||
status: 'error',
|
|
||||||
result: result || null,
|
|
||||||
newSessionId: thread.id || undefined,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
writeOutput({
|
|
||||||
status: 'success',
|
|
||||||
result: result || null,
|
|
||||||
newSessionId: thread.id || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
log('SDK turn done, waiting for next IPC message...');
|
|
||||||
|
|
||||||
const nextMessage = await waitForIpcMessage();
|
|
||||||
if (nextMessage === null) {
|
|
||||||
log('Close sentinel received, exiting SDK runtime');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`Got new SDK message (${nextMessage.length} chars)`);
|
|
||||||
prompt = nextMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runAppServerCompact(
|
async function runAppServerCompact(
|
||||||
client: CodexAppServerClient,
|
client: CodexAppServerClient,
|
||||||
threadId: string | undefined,
|
threadId: string | undefined,
|
||||||
@@ -529,6 +404,7 @@ async function runAppServerSession(
|
|||||||
writeOutput({
|
writeOutput({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
result: result || null,
|
result: result || null,
|
||||||
|
...(result ? { phase: 'final' as const } : {}),
|
||||||
newSessionId: threadId,
|
newSessionId: threadId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -549,10 +425,6 @@ async function runAppServerSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldUseAppServer(): boolean {
|
|
||||||
return CODEX_RUNTIME !== 'sdk';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main ──────────────────────────────────────────────────────────
|
// ── Main ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
@@ -595,28 +467,9 @@ async function main(): Promise<void> {
|
|||||||
prompt += '\n' + pending.join('\n');
|
prompt += '\n' + pending.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferAppServer = shouldUseAppServer();
|
|
||||||
try {
|
try {
|
||||||
if (preferAppServer) {
|
log('Runtime selected: app-server');
|
||||||
try {
|
await runAppServerSession(containerInput, prompt);
|
||||||
log(`Runtime selected: app-server (${CODEX_RUNTIME})`);
|
|
||||||
await runAppServerSession(containerInput, prompt);
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
if (CODEX_RUNTIME === 'app-server') {
|
|
||||||
log(
|
|
||||||
`App-server runtime failed, falling back to SDK: ${
|
|
||||||
err instanceof Error ? err.message : String(err)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log('Runtime selected: sdk');
|
|
||||||
await runSdkSession(containerInput, prompt);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
log(`Runner error: ${errorMessage}`);
|
log(`Runner error: ${errorMessage}`);
|
||||||
|
|||||||
220
scripts/migrate-unify.cjs
Normal file
220
scripts/migrate-unify.cjs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Data Unification Migration Script
|
||||||
|
* Merges codex data into the primary (claude) directories.
|
||||||
|
*
|
||||||
|
* Run AFTER stopping both services.
|
||||||
|
*/
|
||||||
|
const Database = require('/home/clone-ej/nanoclaw/node_modules/better-sqlite3');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const BASE = '/home/clone-ej/nanoclaw';
|
||||||
|
const CLAUDE_DB = path.join(BASE, 'store/messages.db');
|
||||||
|
const CODEX_DB = path.join(BASE, 'store-codex/messages.db');
|
||||||
|
|
||||||
|
console.log('=== NanoClaw Data Unification ===\n');
|
||||||
|
|
||||||
|
// 1. Open both DBs
|
||||||
|
const primary = new Database(CLAUDE_DB);
|
||||||
|
const codex = new Database(CODEX_DB);
|
||||||
|
|
||||||
|
primary.pragma('journal_mode = WAL');
|
||||||
|
primary.pragma('busy_timeout = 5000');
|
||||||
|
|
||||||
|
// 2. Merge registered_groups (codex → primary, skip JID conflicts)
|
||||||
|
console.log('--- Merging registered_groups ---');
|
||||||
|
const codexGroups = codex.prepare('SELECT * FROM registered_groups').all();
|
||||||
|
const insertGroup = primary.prepare(`
|
||||||
|
INSERT OR IGNORE INTO registered_groups
|
||||||
|
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main, agent_type, work_dir)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
let groupsAdded = 0;
|
||||||
|
let groupsSkipped = 0;
|
||||||
|
for (const g of codexGroups) {
|
||||||
|
// Check if JID already exists with different agent_type
|
||||||
|
const existing = primary.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?').get(g.jid);
|
||||||
|
if (existing) {
|
||||||
|
// Same JID, different bots — both need to be registered
|
||||||
|
// Since JID is PK, we can't have duplicates. The group with claude-code type stays,
|
||||||
|
// and the codex one is already differentiated by agent_type in the same row.
|
||||||
|
// But wait — in the unified DB, the same JID can only appear once.
|
||||||
|
// For shared channels (both bots respond), we keep the claude registration
|
||||||
|
// and the codex service will also load it IF we adjust the filter.
|
||||||
|
// Actually, the codex service filters by agent_type='codex', so it won't see claude-code groups.
|
||||||
|
// We need BOTH registrations for shared channels.
|
||||||
|
// Solution: codex groups with different folder names get inserted.
|
||||||
|
// Same JID + same folder = skip (duplicate)
|
||||||
|
// Same JID + different folder = need a unique folder for codex
|
||||||
|
if (existing.agent_type !== g.agent_type) {
|
||||||
|
// Same JID but different agent types — need both
|
||||||
|
// Change JID to make it unique: append agent type suffix
|
||||||
|
const codexJid = g.jid + ':codex';
|
||||||
|
const existsCodex = primary.prepare('SELECT 1 FROM registered_groups WHERE jid = ?').get(codexJid);
|
||||||
|
if (!existsCodex) {
|
||||||
|
insertGroup.run(codexJid, g.name, g.folder, g.trigger_pattern, g.added_at,
|
||||||
|
g.container_config, g.requires_trigger, g.is_main, g.agent_type, g.work_dir);
|
||||||
|
groupsAdded++;
|
||||||
|
console.log(` + ${g.folder} (codex, jid=${codexJid})`);
|
||||||
|
} else {
|
||||||
|
groupsSkipped++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
groupsSkipped++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
insertGroup.run(g.jid, g.name, g.folder, g.trigger_pattern, g.added_at,
|
||||||
|
g.container_config, g.requires_trigger, g.is_main, g.agent_type, g.work_dir);
|
||||||
|
groupsAdded++;
|
||||||
|
console.log(` + ${g.folder} (${g.agent_type})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(` Added: ${groupsAdded}, Skipped: ${groupsSkipped}\n`);
|
||||||
|
|
||||||
|
// 3. Merge sessions (codex → primary, agent_type='codex')
|
||||||
|
console.log('--- Merging sessions ---');
|
||||||
|
const codexSessions = codex.prepare('SELECT * FROM sessions').all();
|
||||||
|
const insertSession = primary.prepare(
|
||||||
|
'INSERT OR IGNORE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
let sessionsAdded = 0;
|
||||||
|
for (const s of codexSessions) {
|
||||||
|
insertSession.run(s.group_folder, s.agent_type, s.session_id);
|
||||||
|
sessionsAdded++;
|
||||||
|
console.log(` + ${s.group_folder} [${s.agent_type}]`);
|
||||||
|
}
|
||||||
|
console.log(` Added: ${sessionsAdded}\n`);
|
||||||
|
|
||||||
|
// 4. Merge router_state (codex → primary, already prefixed)
|
||||||
|
console.log('--- Merging router_state ---');
|
||||||
|
const codexState = codex.prepare('SELECT * FROM router_state').all();
|
||||||
|
const insertState = primary.prepare(
|
||||||
|
'INSERT OR IGNORE INTO router_state (key, value) VALUES (?, ?)'
|
||||||
|
);
|
||||||
|
for (const s of codexState) {
|
||||||
|
insertState.run(s.key, s.value);
|
||||||
|
console.log(` + ${s.key}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 5. Merge messages (codex → primary, skip duplicates by PK)
|
||||||
|
console.log('--- Merging messages ---');
|
||||||
|
const codexMsgs = codex.prepare('SELECT * FROM messages').all();
|
||||||
|
const insertMsg = primary.prepare(`
|
||||||
|
INSERT OR IGNORE INTO messages
|
||||||
|
(id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
let msgsAdded = 0;
|
||||||
|
let msgsSkipped = 0;
|
||||||
|
const batchInsert = primary.transaction((msgs) => {
|
||||||
|
for (const m of msgs) {
|
||||||
|
const result = insertMsg.run(m.id, m.chat_jid, m.sender, m.sender_name,
|
||||||
|
m.content, m.timestamp, m.is_from_me, m.is_bot_message);
|
||||||
|
if (result.changes > 0) msgsAdded++;
|
||||||
|
else msgsSkipped++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
batchInsert(codexMsgs);
|
||||||
|
console.log(` Added: ${msgsAdded}, Skipped (duplicates): ${msgsSkipped}\n`);
|
||||||
|
|
||||||
|
// 6. Merge chats (codex → primary, UPSERT with newer timestamp)
|
||||||
|
console.log('--- Merging chats ---');
|
||||||
|
const codexChats = codex.prepare('SELECT * FROM chats').all();
|
||||||
|
const upsertChat = primary.prepare(`
|
||||||
|
INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(jid) DO UPDATE SET
|
||||||
|
name = COALESCE(excluded.name, name),
|
||||||
|
last_message_time = MAX(last_message_time, excluded.last_message_time),
|
||||||
|
channel = COALESCE(excluded.channel, channel),
|
||||||
|
is_group = COALESCE(excluded.is_group, is_group)
|
||||||
|
`);
|
||||||
|
let chatsUpserted = 0;
|
||||||
|
for (const c of codexChats) {
|
||||||
|
upsertChat.run(c.jid, c.name, c.last_message_time, c.channel, c.is_group);
|
||||||
|
chatsUpserted++;
|
||||||
|
}
|
||||||
|
console.log(` Upserted: ${chatsUpserted}\n`);
|
||||||
|
|
||||||
|
// 7. Merge scheduled_tasks (codex → primary, skip duplicates)
|
||||||
|
const codexTasks = codex.prepare('SELECT * FROM scheduled_tasks').all();
|
||||||
|
if (codexTasks.length > 0) {
|
||||||
|
console.log('--- Merging scheduled_tasks ---');
|
||||||
|
const insertTask = primary.prepare(`
|
||||||
|
INSERT OR IGNORE INTO scheduled_tasks
|
||||||
|
(id, group_folder, chat_jid, prompt, schedule_type, schedule_value, next_run, last_run, last_result, status, created_at, context_mode)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const t of codexTasks) {
|
||||||
|
insertTask.run(t.id, t.group_folder, t.chat_jid, t.prompt, t.schedule_type,
|
||||||
|
t.schedule_value, t.next_run, t.last_run, t.last_result, t.status, t.created_at, t.context_mode);
|
||||||
|
console.log(` + ${t.id}`);
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close DBs
|
||||||
|
primary.close();
|
||||||
|
codex.close();
|
||||||
|
|
||||||
|
// 8. Merge groups directories
|
||||||
|
console.log('--- Merging groups-codex/ → groups/ ---');
|
||||||
|
const codexGroupsDir = path.join(BASE, 'groups-codex');
|
||||||
|
const claudeGroupsDir = path.join(BASE, 'groups');
|
||||||
|
if (fs.existsSync(codexGroupsDir)) {
|
||||||
|
const entries = fs.readdirSync(codexGroupsDir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const src = path.join(codexGroupsDir, entry);
|
||||||
|
const dst = path.join(claudeGroupsDir, entry);
|
||||||
|
if (fs.existsSync(dst)) {
|
||||||
|
// Merge: copy files that don't exist in dst
|
||||||
|
if (fs.statSync(src).isDirectory()) {
|
||||||
|
const files = fs.readdirSync(src);
|
||||||
|
for (const f of files) {
|
||||||
|
const srcFile = path.join(src, f);
|
||||||
|
const dstFile = path.join(dst, f);
|
||||||
|
if (!fs.existsSync(dstFile)) {
|
||||||
|
fs.cpSync(srcFile, dstFile, { recursive: true });
|
||||||
|
console.log(` cp ${entry}/${f}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs.cpSync(src, dst, { recursive: true });
|
||||||
|
console.log(` + ${entry}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 9. Merge data-codex/sessions/ → data/sessions/
|
||||||
|
console.log('--- Merging data-codex/sessions/ → data/sessions/ ---');
|
||||||
|
const codexDataSessions = path.join(BASE, 'data-codex/sessions');
|
||||||
|
const claudeDataSessions = path.join(BASE, 'data/sessions');
|
||||||
|
if (fs.existsSync(codexDataSessions)) {
|
||||||
|
const entries = fs.readdirSync(codexDataSessions);
|
||||||
|
for (const entry of entries) {
|
||||||
|
const src = path.join(codexDataSessions, entry);
|
||||||
|
const dst = path.join(claudeDataSessions, entry);
|
||||||
|
if (fs.existsSync(dst)) {
|
||||||
|
// Both exist — merge .codex/ subdirectory
|
||||||
|
const codexSubdir = path.join(src, '.codex');
|
||||||
|
const dstCodexSubdir = path.join(dst, '.codex');
|
||||||
|
if (fs.existsSync(codexSubdir) && !fs.existsSync(dstCodexSubdir)) {
|
||||||
|
fs.cpSync(codexSubdir, dstCodexSubdir, { recursive: true });
|
||||||
|
console.log(` cp ${entry}/.codex/`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fs.cpSync(src, dst, { recursive: true });
|
||||||
|
console.log(` + ${entry}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
console.log('=== Migration complete ===');
|
||||||
|
console.log('Next steps:');
|
||||||
|
console.log('1. Update .env.codex to remove NANOCLAW_STORE_DIR, NANOCLAW_DATA_DIR, NANOCLAW_GROUPS_DIR');
|
||||||
|
console.log('2. Restart both services');
|
||||||
|
console.log('3. Verify, then rename old dirs to .bak');
|
||||||
@@ -210,4 +210,46 @@ describe('agent-runner timeout behavior', () => {
|
|||||||
expect(result.status).toBe('success');
|
expect(result.status).toBe('success');
|
||||||
expect(result.newSessionId).toBe('session-456');
|
expect(result.newSessionId).toBe('session-456');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves streamed progress phase metadata', async () => {
|
||||||
|
const onOutput = vi.fn(async () => {});
|
||||||
|
const resultPromise = runAgentProcess(
|
||||||
|
testGroup,
|
||||||
|
testInput,
|
||||||
|
() => {},
|
||||||
|
onOutput,
|
||||||
|
);
|
||||||
|
|
||||||
|
emitOutputMarker(fakeProc, {
|
||||||
|
status: 'success',
|
||||||
|
result: '생각 중...',
|
||||||
|
phase: 'progress',
|
||||||
|
newSessionId: 'session-progress',
|
||||||
|
});
|
||||||
|
emitOutputMarker(fakeProc, {
|
||||||
|
status: 'success',
|
||||||
|
result: '최종 답변',
|
||||||
|
phase: 'final',
|
||||||
|
newSessionId: 'session-progress',
|
||||||
|
});
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
fakeProc.emit('close', 0);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(onOutput).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
result: '생각 중...',
|
||||||
|
phase: 'progress',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onOutput).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
result: '최종 답변',
|
||||||
|
phase: 'final',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface AgentInput {
|
|||||||
export interface AgentOutput {
|
export interface AgentOutput {
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
result: string | null;
|
result: string | null;
|
||||||
|
phase?: 'progress' | 'final';
|
||||||
newSessionId?: string;
|
newSessionId?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -164,10 +165,17 @@ function prepareGroupEnvironment(
|
|||||||
'CODEX_OPENAI_API_KEY',
|
'CODEX_OPENAI_API_KEY',
|
||||||
'CODEX_MODEL',
|
'CODEX_MODEL',
|
||||||
'CODEX_EFFORT',
|
'CODEX_EFFORT',
|
||||||
|
'MEMENTO_MCP_SSE_URL',
|
||||||
|
'MEMENTO_ACCESS_KEY',
|
||||||
|
'MEMENTO_MCP_REMOTE_PATH',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Build a clean env without Claude Code nesting detection variables
|
// Build a clean env without Claude Code nesting detection variables
|
||||||
const cleanEnv = { ...(process.env as Record<string, string>) };
|
const cleanEnv = { ...(process.env as Record<string, string>) };
|
||||||
|
// Merge .env file values (readEnvFile only reads file, doesn't set process.env)
|
||||||
|
for (const [k, v] of Object.entries(envVars)) {
|
||||||
|
if (v && !cleanEnv[k]) cleanEnv[k] = v;
|
||||||
|
}
|
||||||
delete cleanEnv.CLAUDECODE;
|
delete cleanEnv.CLAUDECODE;
|
||||||
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
delete cleanEnv.CLAUDE_CODE_ENTRYPOINT;
|
||||||
|
|
||||||
@@ -290,8 +298,9 @@ function prepareGroupEnvironment(
|
|||||||
let toml = fs.existsSync(configTomlPath)
|
let toml = fs.existsSync(configTomlPath)
|
||||||
? fs.readFileSync(configTomlPath, 'utf-8')
|
? fs.readFileSync(configTomlPath, 'utf-8')
|
||||||
: '';
|
: '';
|
||||||
// Remove existing nanoclaw MCP section if present (to refresh env vars)
|
// Remove existing nanoclaw/memento MCP sections if present (to refresh env vars)
|
||||||
toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, '');
|
toml = toml.replace(/\n?\[mcp_servers\.nanoclaw\][\s\S]*?(?=\n\[|$)/, '');
|
||||||
|
toml = toml.replace(/\n?\[mcp_servers\.memento-mcp\][\s\S]*?(?=\n\[|$)/, '');
|
||||||
const mcpSection = `
|
const mcpSection = `
|
||||||
[mcp_servers.nanoclaw]
|
[mcp_servers.nanoclaw]
|
||||||
command = "node"
|
command = "node"
|
||||||
@@ -303,7 +312,24 @@ NANOCLAW_CHAT_JID = ${JSON.stringify(group.folder)}
|
|||||||
NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)}
|
NANOCLAW_GROUP_FOLDER = ${JSON.stringify(group.folder)}
|
||||||
NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')}
|
NANOCLAW_IS_MAIN = ${JSON.stringify(isMain ? '1' : '0')}
|
||||||
`;
|
`;
|
||||||
toml = toml.trimEnd() + '\n' + mcpSection;
|
// Inject memento-mcp if MEMENTO_MCP_SSE_URL is set
|
||||||
|
const mementoSseUrl =
|
||||||
|
envVars.MEMENTO_MCP_SSE_URL || process.env.MEMENTO_MCP_SSE_URL;
|
||||||
|
const mementoAccessKey =
|
||||||
|
envVars.MEMENTO_ACCESS_KEY || process.env.MEMENTO_ACCESS_KEY || '';
|
||||||
|
const mementoRemotePath =
|
||||||
|
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}`)}]
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
toml = toml.trimEnd() + '\n' + mcpSection + mementoSection;
|
||||||
fs.writeFileSync(configTomlPath, toml);
|
fs.writeFileSync(configTomlPath, toml);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +487,18 @@ export async function runAgentProcess(
|
|||||||
}
|
}
|
||||||
hadStreamingOutput = true;
|
hadStreamingOutput = true;
|
||||||
resetTimeout();
|
resetTimeout();
|
||||||
|
if (parsed.status === 'error') {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
error: parsed.error,
|
||||||
|
newSessionId: parsed.newSessionId,
|
||||||
|
},
|
||||||
|
'Streamed agent error output',
|
||||||
|
);
|
||||||
|
}
|
||||||
outputChain = outputChain.then(() => onOutput(parsed));
|
outputChain = outputChain.then(() => onOutput(parsed));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|||||||
141
src/bot-message-filter.test.ts
Normal file
141
src/bot-message-filter.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { filterProcessableMessages } from './bot-message-filter.js';
|
||||||
|
import { NewMessage } from './types.js';
|
||||||
|
|
||||||
|
function makeMsg(overrides: Partial<NewMessage> = {}): NewMessage {
|
||||||
|
return {
|
||||||
|
id: '1',
|
||||||
|
chat_jid: 'dc:1',
|
||||||
|
sender: 'user-1',
|
||||||
|
sender_name: 'User',
|
||||||
|
content: 'hello',
|
||||||
|
timestamp: '2026-03-20T00:00:00.000Z',
|
||||||
|
is_from_me: false,
|
||||||
|
is_bot_message: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const OWN_BOT_ID = 'my-bot-123';
|
||||||
|
|
||||||
|
describe('filterProcessableMessages', () => {
|
||||||
|
it('filters bot-authored messages in normal rooms', () => {
|
||||||
|
const result = filterProcessableMessages(
|
||||||
|
[
|
||||||
|
makeMsg({ id: 'human-1', content: 'human' }),
|
||||||
|
makeMsg({
|
||||||
|
id: 'bot-1',
|
||||||
|
sender: 'bot-1',
|
||||||
|
sender_name: 'Bot',
|
||||||
|
content: 'status report',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe('human-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps other bot messages in paired rooms', () => {
|
||||||
|
const isOwn = (m: NewMessage) =>
|
||||||
|
m.is_bot_message === true && m.sender === OWN_BOT_ID;
|
||||||
|
const result = filterProcessableMessages(
|
||||||
|
[
|
||||||
|
makeMsg({ id: 'human-1', content: 'human' }),
|
||||||
|
makeMsg({
|
||||||
|
id: 'bot-1',
|
||||||
|
sender: 'other-bot-456',
|
||||||
|
sender_name: 'OtherBot',
|
||||||
|
content: 'status report',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
isOwn,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters own bot messages in paired rooms', () => {
|
||||||
|
const isOwn = (m: NewMessage) =>
|
||||||
|
m.is_bot_message === true && m.sender === OWN_BOT_ID;
|
||||||
|
const result = filterProcessableMessages(
|
||||||
|
[
|
||||||
|
makeMsg({ id: 'human-1', content: 'human' }),
|
||||||
|
makeMsg({
|
||||||
|
id: 'own-1',
|
||||||
|
sender: OWN_BOT_ID,
|
||||||
|
sender_name: 'MyBot',
|
||||||
|
content: 'my own output',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
makeMsg({
|
||||||
|
id: 'other-1',
|
||||||
|
sender: 'other-bot-456',
|
||||||
|
sender_name: 'OtherBot',
|
||||||
|
content: 'partner response',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
isOwn,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe('human-1');
|
||||||
|
expect(result[1].id).toBe('other-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps all bot messages in paired rooms without isOwnMessage', () => {
|
||||||
|
const result = filterProcessableMessages(
|
||||||
|
[
|
||||||
|
makeMsg({ id: 'human-1', content: 'human' }),
|
||||||
|
makeMsg({
|
||||||
|
id: 'bot-1',
|
||||||
|
sender: 'bot-1',
|
||||||
|
sender_name: 'Bot',
|
||||||
|
content: 'status report',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters session command control bot messages in paired rooms', () => {
|
||||||
|
const isOwn = (m: NewMessage) =>
|
||||||
|
m.is_bot_message === true && m.sender === OWN_BOT_ID;
|
||||||
|
const result = filterProcessableMessages(
|
||||||
|
[
|
||||||
|
makeMsg({ id: 'human-1', content: 'human' }),
|
||||||
|
makeMsg({
|
||||||
|
id: 'control-1',
|
||||||
|
sender: 'other-bot-456',
|
||||||
|
sender_name: 'OtherBot',
|
||||||
|
content:
|
||||||
|
'Current session cleared. The next message will start a new conversation.',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
makeMsg({
|
||||||
|
id: 'other-1',
|
||||||
|
sender: 'other-bot-456',
|
||||||
|
sender_name: 'OtherBot',
|
||||||
|
content: 'partner response',
|
||||||
|
is_bot_message: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
isOwn,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe('human-1');
|
||||||
|
expect(result[1].id).toBe('other-1');
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/bot-message-filter.ts
Normal file
31
src/bot-message-filter.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { isSessionCommandControlMessage } from './session-commands.js';
|
||||||
|
import { NewMessage } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter messages before processing.
|
||||||
|
* - Normal rooms: drop all bot messages.
|
||||||
|
* - Paired rooms (allowBotMessages=true): keep other bot's messages,
|
||||||
|
* but drop messages authored by this service's own bot (via isOwnMessage).
|
||||||
|
*/
|
||||||
|
export function filterProcessableMessages(
|
||||||
|
messages: NewMessage[],
|
||||||
|
allowBotMessages: boolean,
|
||||||
|
isOwnMessage?: (msg: NewMessage) => boolean,
|
||||||
|
): NewMessage[] {
|
||||||
|
const withoutControlMessages = messages.filter(
|
||||||
|
(message) =>
|
||||||
|
!(
|
||||||
|
message.is_bot_message &&
|
||||||
|
isSessionCommandControlMessage(message.content)
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allowBotMessages) {
|
||||||
|
// In paired rooms, allow other bot messages but filter own bot's output
|
||||||
|
if (isOwnMessage) {
|
||||||
|
return withoutControlMessages.filter((m) => !isOwnMessage(m));
|
||||||
|
}
|
||||||
|
return withoutControlMessages;
|
||||||
|
}
|
||||||
|
return withoutControlMessages.filter((message) => !message.is_bot_message);
|
||||||
|
}
|
||||||
@@ -26,6 +26,12 @@ vi.mock('../logger.js', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const isPairedRoomJidMock = vi.hoisted(() => vi.fn(() => false));
|
||||||
|
|
||||||
|
vi.mock('../db.js', () => ({
|
||||||
|
isPairedRoomJid: isPairedRoomJidMock,
|
||||||
|
}));
|
||||||
|
|
||||||
// --- discord.js mock ---
|
// --- discord.js mock ---
|
||||||
|
|
||||||
type Handler = (...args: any[]) => any;
|
type Handler = (...args: any[]) => any;
|
||||||
@@ -196,6 +202,7 @@ async function triggerMessage(message: any) {
|
|||||||
describe('DiscordChannel', () => {
|
describe('DiscordChannel', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
isPairedRoomJidMock.mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -301,18 +308,54 @@ describe('DiscordChannel', () => {
|
|||||||
expect(opts.onMessage).not.toHaveBeenCalled();
|
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delivers bot messages with is_bot_message flag', async () => {
|
it('ignores its own bot messages', async () => {
|
||||||
const opts = createTestOpts();
|
const opts = createTestOpts();
|
||||||
const channel = new DiscordChannel('test-token', opts);
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
await channel.connect();
|
await channel.connect();
|
||||||
|
|
||||||
const msg = createMessage({ isBot: true, content: 'I am a bot' });
|
const msg = createMessage({
|
||||||
|
authorId: '999888777',
|
||||||
|
isBot: true,
|
||||||
|
content: 'I am the connected bot',
|
||||||
|
});
|
||||||
|
await triggerMessage(msg);
|
||||||
|
|
||||||
|
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores other bot messages in normal rooms', async () => {
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const msg = createMessage({
|
||||||
|
authorId: '111222333',
|
||||||
|
isBot: true,
|
||||||
|
content: 'I am another bot',
|
||||||
|
});
|
||||||
|
await triggerMessage(msg);
|
||||||
|
|
||||||
|
expect(opts.onMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delivers other bot messages in paired rooms', async () => {
|
||||||
|
isPairedRoomJidMock.mockReturnValue(true);
|
||||||
|
|
||||||
|
const opts = createTestOpts();
|
||||||
|
const channel = new DiscordChannel('test-token', opts);
|
||||||
|
await channel.connect();
|
||||||
|
|
||||||
|
const msg = createMessage({
|
||||||
|
authorId: '111222333',
|
||||||
|
isBot: true,
|
||||||
|
content: 'I am another bot',
|
||||||
|
});
|
||||||
await triggerMessage(msg);
|
await triggerMessage(msg);
|
||||||
|
|
||||||
expect(opts.onMessage).toHaveBeenCalledWith(
|
expect(opts.onMessage).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
'dc:1234567890123456',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
content: 'I am a bot',
|
content: 'I am another bot',
|
||||||
is_bot_message: true,
|
is_bot_message: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
TRIGGER_PATTERN,
|
TRIGGER_PATTERN,
|
||||||
} from '../config.js';
|
} from '../config.js';
|
||||||
|
import { isPairedRoomJid } from '../db.js';
|
||||||
import { readEnvFile } from '../env.js';
|
import { readEnvFile } from '../env.js';
|
||||||
import { logger } from '../logger.js';
|
import { logger } from '../logger.js';
|
||||||
|
|
||||||
@@ -158,6 +159,7 @@ import {
|
|||||||
AgentType,
|
AgentType,
|
||||||
Channel,
|
Channel,
|
||||||
ChannelMeta,
|
ChannelMeta,
|
||||||
|
NewMessage,
|
||||||
OnChatMetadata,
|
OnChatMetadata,
|
||||||
OnInboundMessage,
|
OnInboundMessage,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
@@ -202,11 +204,12 @@ export class DiscordChannel implements Channel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.client.on(Events.MessageCreate, async (message: Message) => {
|
this.client.on(Events.MessageCreate, async (message: Message) => {
|
||||||
// Ignore own messages only
|
|
||||||
if (message.author.id === this.client?.user?.id) return;
|
|
||||||
|
|
||||||
const channelId = message.channelId;
|
const channelId = message.channelId;
|
||||||
const chatJid = `dc:${channelId}`;
|
const chatJid = `dc:${channelId}`;
|
||||||
|
const isOwnBotMessage = message.author.id === this.client?.user?.id;
|
||||||
|
if (isOwnBotMessage) return;
|
||||||
|
if (message.author.bot && !isPairedRoomJid(chatJid)) return;
|
||||||
|
|
||||||
let content = message.content;
|
let content = message.content;
|
||||||
const timestamp = message.createdAt.toISOString();
|
const timestamp = message.createdAt.toISOString();
|
||||||
const senderName =
|
const senderName =
|
||||||
@@ -481,6 +484,10 @@ export class DiscordChannel implements Channel {
|
|||||||
return this.client !== null && this.client.isReady();
|
return this.client !== null && this.client.isReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isOwnMessage(msg: NewMessage): boolean {
|
||||||
|
return !!msg.is_bot_message && msg.sender === this.client?.user?.id;
|
||||||
|
}
|
||||||
|
|
||||||
ownsJid(jid: string): boolean {
|
ownsJid(jid: string): boolean {
|
||||||
if (!jid.startsWith('dc:')) return false;
|
if (!jid.startsWith('dc:')) return false;
|
||||||
if (!this.agentTypeFilter) return true;
|
if (!this.agentTypeFilter) return true;
|
||||||
|
|||||||
183
src/claude-usage.ts
Normal file
183
src/claude-usage.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
export interface ClaudeUsageData {
|
||||||
|
five_hour?: { utilization: number; resets_at: string };
|
||||||
|
seven_day?: { utilization: number; resets_at: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLAUDE_EXPECT_TIMEOUT_MS = 25000;
|
||||||
|
const ANSI_RE = /\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
||||||
|
|
||||||
|
const EXPECT_PROGRAM = `
|
||||||
|
set timeout 20
|
||||||
|
log_user 1
|
||||||
|
match_max 200000
|
||||||
|
set binary $env(CLAUDE_BINARY)
|
||||||
|
spawn -noecho -- $binary --setting-sources user --allowed-tools ""
|
||||||
|
expect {
|
||||||
|
-re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue }
|
||||||
|
-re "Quick safety check:" { send "\\r"; exp_continue }
|
||||||
|
-re "Yes, I trust this folder" { send "\\r"; exp_continue }
|
||||||
|
-re "Ready to code here\\\\?" { send "\\r"; exp_continue }
|
||||||
|
-re "Press Enter to continue" { send "\\r"; exp_continue }
|
||||||
|
timeout {}
|
||||||
|
}
|
||||||
|
send "/usage\\r"
|
||||||
|
set deadline [expr {[clock seconds] + 20}]
|
||||||
|
while {[clock seconds] < $deadline} {
|
||||||
|
expect {
|
||||||
|
-re "Do you trust the files in this folder\\\\?" { send "y\\r"; exp_continue }
|
||||||
|
-re "Quick safety check:" { send "\\r"; exp_continue }
|
||||||
|
-re "Yes, I trust this folder" { send "\\r"; exp_continue }
|
||||||
|
-re "Ready to code here\\\\?" { send "\\r"; exp_continue }
|
||||||
|
-re "Press Enter to continue" { send "\\r"; exp_continue }
|
||||||
|
-re "Current session" { after 2000; exit 0 }
|
||||||
|
-re "Failed to load usage data" { after 500; exit 2 }
|
||||||
|
eof { exit 3 }
|
||||||
|
timeout { send "\\r" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exit 4
|
||||||
|
`;
|
||||||
|
|
||||||
|
function normalizeLines(rawText: string): string[] {
|
||||||
|
return rawText
|
||||||
|
.replace(ANSI_RE, '')
|
||||||
|
.replace(/\r/g, '\n')
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.replace(/\s+/g, ' ').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePercent(windowText: string): number | null {
|
||||||
|
const match = windowText.match(/(\d{1,3})%\s*(used|left)\b/i);
|
||||||
|
if (!match) return null;
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
if (Number.isNaN(value)) return null;
|
||||||
|
return match[2].toLowerCase() === 'left' ? 100 - value : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWindow(
|
||||||
|
lines: string[],
|
||||||
|
labels: string[],
|
||||||
|
): { utilization: number; resets_at: string } | null {
|
||||||
|
const normalizedLabels = labels.map((label) => label.toLowerCase());
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].toLowerCase();
|
||||||
|
if (!normalizedLabels.some((label) => line.includes(label))) continue;
|
||||||
|
|
||||||
|
const windowLines = lines.slice(i, i + 6);
|
||||||
|
const windowText = windowLines.join('\n');
|
||||||
|
const utilization = parsePercent(windowText);
|
||||||
|
if (utilization === null) continue;
|
||||||
|
|
||||||
|
const resetLine = windowLines.find((candidate) =>
|
||||||
|
candidate.toLowerCase().startsWith('resets'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
utilization,
|
||||||
|
resets_at: resetLine || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseClaudeUsagePanel(rawText: string): ClaudeUsageData | null {
|
||||||
|
const lines = normalizeLines(rawText);
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
if (
|
||||||
|
lines.some((line) =>
|
||||||
|
line.toLowerCase().includes('failed to load usage data'),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fiveHour = parseWindow(lines, ['Current session']);
|
||||||
|
if (!fiveHour) return null;
|
||||||
|
|
||||||
|
const sevenDay =
|
||||||
|
parseWindow(lines, ['Current week (all models)']) ||
|
||||||
|
parseWindow(lines, [
|
||||||
|
'Current week (Sonnet only)',
|
||||||
|
'Current week (Sonnet)',
|
||||||
|
]) ||
|
||||||
|
parseWindow(lines, ['Current week (Opus)']);
|
||||||
|
|
||||||
|
return {
|
||||||
|
five_hour: fiveHour,
|
||||||
|
...(sevenDay ? { seven_day: sevenDay } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchClaudeUsageViaCli(
|
||||||
|
binary = 'claude',
|
||||||
|
): Promise<ClaudeUsageData | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let output = '';
|
||||||
|
let finished = false;
|
||||||
|
|
||||||
|
const finish = (value: ClaudeUsageData | null) => {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
let proc: ReturnType<typeof spawn> | null = null;
|
||||||
|
try {
|
||||||
|
proc = spawn('expect', ['-c', EXPECT_PROGRAM], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...(process.env as Record<string, string>),
|
||||||
|
CLAUDE_BINARY: binary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug({ err }, 'Claude CLI PTY probe unavailable');
|
||||||
|
finish(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
proc?.kill('SIGTERM');
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
finish(null);
|
||||||
|
}, CLAUDE_EXPECT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
if (!proc.stdout || !proc.stderr) {
|
||||||
|
finish(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
proc.stdout.setEncoding('utf8');
|
||||||
|
proc.stderr.setEncoding('utf8');
|
||||||
|
proc.stdout.on('data', (chunk: string) => {
|
||||||
|
output += chunk;
|
||||||
|
});
|
||||||
|
proc.stderr.on('data', (chunk: string) => {
|
||||||
|
output += chunk;
|
||||||
|
});
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
logger.debug({ err }, 'Claude CLI PTY probe failed to start');
|
||||||
|
finish(null);
|
||||||
|
});
|
||||||
|
proc.on('close', () => {
|
||||||
|
const parsed = parseClaudeUsagePanel(output);
|
||||||
|
if (!parsed && output.trim()) {
|
||||||
|
logger.debug(
|
||||||
|
{ tail: output.slice(-400) },
|
||||||
|
'Claude CLI PTY probe produced unparsable output',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finish(parsed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ const envConfig = readEnvFile([
|
|||||||
'SERVICE_ID',
|
'SERVICE_ID',
|
||||||
'SERVICE_AGENT_TYPE',
|
'SERVICE_AGENT_TYPE',
|
||||||
'SESSION_COMMAND_ALLOWED_SENDERS',
|
'SESSION_COMMAND_ALLOWED_SENDERS',
|
||||||
|
'SESSION_COMMAND_USER_IDS',
|
||||||
'USAGE_DASHBOARD',
|
'USAGE_DASHBOARD',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -89,12 +90,15 @@ export const USAGE_DASHBOARD_ENABLED =
|
|||||||
export const TIMEZONE =
|
export const TIMEZONE =
|
||||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
|
||||||
|
const rawSessionCommandAllowedSenders =
|
||||||
|
process.env.SESSION_COMMAND_ALLOWED_SENDERS ||
|
||||||
|
process.env.SESSION_COMMAND_USER_IDS ||
|
||||||
|
envConfig.SESSION_COMMAND_ALLOWED_SENDERS ||
|
||||||
|
envConfig.SESSION_COMMAND_USER_IDS ||
|
||||||
|
'';
|
||||||
|
|
||||||
const SESSION_COMMAND_ALLOWED_SENDERS = new Set(
|
const SESSION_COMMAND_ALLOWED_SENDERS = new Set(
|
||||||
(
|
rawSessionCommandAllowedSenders
|
||||||
process.env.SESSION_COMMAND_ALLOWED_SENDERS ||
|
|
||||||
envConfig.SESSION_COMMAND_ALLOWED_SENDERS ||
|
|
||||||
''
|
|
||||||
)
|
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ describe('getMessagesSince', () => {
|
|||||||
);
|
);
|
||||||
const botMsgs = msgs.filter((m) => m.content === 'bot reply');
|
const botMsgs = msgs.filter((m) => m.content === 'bot reply');
|
||||||
expect(botMsgs).toHaveLength(1);
|
expect(botMsgs).toHaveLength(1);
|
||||||
|
expect(botMsgs[0].is_bot_message).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns all messages including bot when sinceTimestamp is empty', () => {
|
it('returns all messages including bot when sinceTimestamp is empty', () => {
|
||||||
@@ -483,4 +484,29 @@ describe('registered group isMain', () => {
|
|||||||
expect(group).toBeDefined();
|
expect(group).toBeDefined();
|
||||||
expect(group.isMain).toBeUndefined();
|
expect(group.isMain).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters duplicate jid registrations by agent type', () => {
|
||||||
|
setRegisteredGroup('dc:shared', {
|
||||||
|
name: 'Shared Room Claude',
|
||||||
|
folder: 'shared-room',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
agentType: 'claude-code',
|
||||||
|
});
|
||||||
|
setRegisteredGroup('dc:shared', {
|
||||||
|
name: 'Shared Room Codex',
|
||||||
|
folder: 'shared-room',
|
||||||
|
trigger: '@Andy',
|
||||||
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
|
agentType: 'codex',
|
||||||
|
});
|
||||||
|
|
||||||
|
const claudeGroups = getAllRegisteredGroups('claude-code');
|
||||||
|
const codexGroups = getAllRegisteredGroups('codex');
|
||||||
|
|
||||||
|
expect(claudeGroups['dc:shared']?.agentType).toBe('claude-code');
|
||||||
|
expect(claudeGroups['dc:shared']?.name).toBe('Shared Room Claude');
|
||||||
|
expect(codexGroups['dc:shared']?.agentType).toBe('codex');
|
||||||
|
expect(codexGroups['dc:shared']?.name).toBe('Shared Room Codex');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
43
src/db.ts
43
src/db.ts
@@ -378,6 +378,16 @@ export function storeMessage(msg: NewMessage): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMessageRow(
|
||||||
|
row: NewMessage & { is_from_me?: boolean | number; is_bot_message?: boolean | number },
|
||||||
|
): NewMessage {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
is_from_me: !!row.is_from_me,
|
||||||
|
is_bot_message: !!row.is_bot_message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getNewMessages(
|
export function getNewMessages(
|
||||||
jids: string[],
|
jids: string[],
|
||||||
lastTimestamp: string,
|
lastTimestamp: string,
|
||||||
@@ -404,14 +414,16 @@ export function getNewMessages(
|
|||||||
|
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare(sql)
|
.prepare(sql)
|
||||||
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
|
.all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as Array<
|
||||||
|
NewMessage & { is_from_me?: boolean | number; is_bot_message?: boolean | number }
|
||||||
|
>;
|
||||||
|
|
||||||
let newTimestamp = lastTimestamp;
|
let newTimestamp = lastTimestamp;
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.timestamp > newTimestamp) newTimestamp = row.timestamp;
|
if (row.timestamp > newTimestamp) newTimestamp = row.timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { messages: rows, newTimestamp };
|
return { messages: rows.map(normalizeMessageRow), newTimestamp };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessagesSince(
|
export function getMessagesSince(
|
||||||
@@ -434,9 +446,12 @@ export function getMessagesSince(
|
|||||||
LIMIT ?
|
LIMIT ?
|
||||||
) ORDER BY timestamp
|
) ORDER BY timestamp
|
||||||
`;
|
`;
|
||||||
return db
|
const rows = db
|
||||||
.prepare(sql)
|
.prepare(sql)
|
||||||
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
|
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as Array<
|
||||||
|
NewMessage & { is_from_me?: boolean | number; is_bot_message?: boolean | number }
|
||||||
|
>;
|
||||||
|
return rows.map(normalizeMessageRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
export function getLastHumanMessageTimestamp(chatJid: string): string | null {
|
||||||
@@ -657,10 +672,17 @@ export function getAllSessions(): Record<string, string> {
|
|||||||
|
|
||||||
export function getRegisteredGroup(
|
export function getRegisteredGroup(
|
||||||
jid: string,
|
jid: string,
|
||||||
|
agentType?: string,
|
||||||
): (RegisteredGroup & { jid: string }) | undefined {
|
): (RegisteredGroup & { jid: string }) | undefined {
|
||||||
const row = db
|
const row = (
|
||||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
agentType
|
||||||
.get(jid) as
|
? db
|
||||||
|
.prepare(
|
||||||
|
'SELECT * FROM registered_groups WHERE jid = ? AND agent_type = ?',
|
||||||
|
)
|
||||||
|
.get(jid, agentType)
|
||||||
|
: db.prepare('SELECT * FROM registered_groups WHERE jid = ?').get(jid)
|
||||||
|
) as
|
||||||
| {
|
| {
|
||||||
jid: string;
|
jid: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -718,6 +740,13 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateRegisteredGroupName(jid: string, name: string): void {
|
||||||
|
db.prepare('UPDATE registered_groups SET name = ? WHERE jid = ?').run(
|
||||||
|
name,
|
||||||
|
jid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function getAllRegisteredGroups(
|
export function getAllRegisteredGroups(
|
||||||
agentTypeFilter?: string,
|
agentTypeFilter?: string,
|
||||||
): Record<string, RegisteredGroup> {
|
): Record<string, RegisteredGroup> {
|
||||||
|
|||||||
751
src/index.ts
751
src/index.ts
File diff suppressed because it is too large
Load Diff
34
src/session-recovery.test.ts
Normal file
34
src/session-recovery.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { shouldResetSessionOnAgentFailure } from './session-recovery.js';
|
||||||
|
|
||||||
|
describe('shouldResetSessionOnAgentFailure', () => {
|
||||||
|
it('matches many-image dimension limit errors', () => {
|
||||||
|
expect(
|
||||||
|
shouldResetSessionOnAgentFailure({
|
||||||
|
result:
|
||||||
|
'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the error field too', () => {
|
||||||
|
expect(
|
||||||
|
shouldResetSessionOnAgentFailure({
|
||||||
|
result: null,
|
||||||
|
error:
|
||||||
|
'fatal: An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match unrelated agent failures', () => {
|
||||||
|
expect(
|
||||||
|
shouldResetSessionOnAgentFailure({
|
||||||
|
result: null,
|
||||||
|
error: 'Claude Code process exited with code 1',
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
26
src/session-recovery.ts
Normal file
26
src/session-recovery.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { AgentOutput } from './agent-runner.js';
|
||||||
|
|
||||||
|
const SESSION_RESET_PATTERNS = [
|
||||||
|
/An image in the conversation exceeds the dimension limit for many-image requests \(2000px\)\./i,
|
||||||
|
/Start a new session with fewer images\./i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function toText(value: string | object | null | undefined): string[] {
|
||||||
|
if (!value) return [];
|
||||||
|
if (typeof value === 'string') return [value];
|
||||||
|
|
||||||
|
try {
|
||||||
|
return [JSON.stringify(value)];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldResetSessionOnAgentFailure(
|
||||||
|
output: Pick<AgentOutput, 'result' | 'error'>,
|
||||||
|
): boolean {
|
||||||
|
const texts = [...toText(output.result), ...toText(output.error)];
|
||||||
|
return texts.some((text) =>
|
||||||
|
SESSION_RESET_PATTERNS.some((pattern) => pattern.test(text)),
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/status-dashboard.ts
Normal file
65
src/status-dashboard.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { CACHE_DIR } from './config.js';
|
||||||
|
import type { GroupStatus } from './group-queue.js';
|
||||||
|
import type { AgentType } from './types.js';
|
||||||
|
|
||||||
|
export interface StatusSnapshotEntry {
|
||||||
|
jid: string;
|
||||||
|
name: string;
|
||||||
|
folder: string;
|
||||||
|
agentType: AgentType;
|
||||||
|
status: GroupStatus['status'];
|
||||||
|
elapsedMs: number | null;
|
||||||
|
pendingMessages: boolean;
|
||||||
|
pendingTasks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusSnapshot {
|
||||||
|
agentType: AgentType;
|
||||||
|
assistantName: string;
|
||||||
|
updatedAt: string;
|
||||||
|
entries: StatusSnapshotEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_SNAPSHOT_DIR = path.join(CACHE_DIR, 'status-dashboard');
|
||||||
|
|
||||||
|
export function writeStatusSnapshot(snapshot: StatusSnapshot): void {
|
||||||
|
fs.mkdirSync(STATUS_SNAPSHOT_DIR, { recursive: true });
|
||||||
|
const targetPath = path.join(
|
||||||
|
STATUS_SNAPSHOT_DIR,
|
||||||
|
`${snapshot.agentType}.json`,
|
||||||
|
);
|
||||||
|
const tempPath = `${targetPath}.tmp`;
|
||||||
|
fs.writeFileSync(tempPath, JSON.stringify(snapshot, null, 2));
|
||||||
|
fs.renameSync(tempPath, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStatusSnapshots(maxAgeMs: number): StatusSnapshot[] {
|
||||||
|
if (!fs.existsSync(STATUS_SNAPSHOT_DIR)) return [];
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const snapshots: StatusSnapshot[] = [];
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(STATUS_SNAPSHOT_DIR)) {
|
||||||
|
if (!entry.endsWith('.json')) continue;
|
||||||
|
const snapshotPath = path.join(STATUS_SNAPSHOT_DIR, entry);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(snapshotPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw) as StatusSnapshot;
|
||||||
|
if (!parsed.updatedAt || !parsed.agentType || !Array.isArray(parsed.entries))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const ageMs = now - new Date(parsed.updatedAt).getTime();
|
||||||
|
if (Number.isNaN(ageMs) || ageMs > maxAgeMs) continue;
|
||||||
|
|
||||||
|
snapshots.push(parsed);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshots;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user