Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/server/src/modules/agent/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { getProfileSessionPreferences } from './profile-session-preferences.js';
import { getProfile as getLegacyProfile } from './profile-store.js';
import { buildReachbackEnv } from './reachback-env.js';
import { renderExternalAgentSystemPreamble } from '../../../prompt/external-agent/system-preamble.js';
import { canvasAcpNamespace } from '../../workspace/disk/paths.js';
import { canvasAcpNamespace } from '../../workspace/paths.js';
import {
agenetes,
EXTERNAL_DRIVER_KIND,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ vi.mock('./reachback-env.js', () => ({
buildReachbackEnv: () => ({ REACHBACK: '1' }),
}));

vi.mock('../../workspace/disk/paths.js', () => ({
vi.mock('../../workspace/paths.js', () => ({
canvasAcpNamespace: (canvasId: string) => `/canvases/${canvasId}/acp`,
}));

Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/modules/agent/acp/threads.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import { buildReachbackEnv } from './reachback-env.js';
import { getExternalAgentRuntimeConfig } from './runtime-config.js';
import { resolveBindingRecipe } from './service.js';
import { renderExternalAgentSystemPreamble } from '../../../prompt/external-agent/system-preamble.js';
import { canvasAcpNamespace } from '../../storage/paths.js';
import { canvasAcpNamespace } from '../../workspace/paths.js';
import {
agenetes,
EXTERNAL_DRIVER_KIND,
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/modules/agent/agent-thread.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { readWorkspaceMemory } from './memory/index.js';
import { planSkillDispatch } from './skill-model-routing.js';
import { acquireAgentTurn, waitForAgentTurnRelease } from './turn-lease.js';
import { loadAgent } from '../../prompt/index.js';
import { canvasAcpNamespace } from '../workspace/disk/paths.js';
import { canvasAcpNamespace } from '../workspace/paths.js';

import type { HuabuSubmission } from './agenetes/handle.js';
import type { ChatEnvelope } from './conversation/envelope.js';
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/modules/agent/agent.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
import { buildChatEnvelope } from '../agent/conversation/envelope.js';
import { buildHistoryFromTurns } from '../agent/conversation/transcript/history.js';
import { getLLMModel } from '../agent/llm.js';
import { canvasAcpNamespace } from '../storage/paths.js';
import { canvasAcpNamespace } from '../workspace/paths.js';

import type { ControlMsg, Namespace } from '@agenetes/protocol';
import type {
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/modules/agent/agent.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
* themselves and pull the relevant `tool_result` payload.
*/

import { loadAgent, type AgentId } from '../../prompt/index.js';
import { canvasAcpNamespace } from '../storage/paths.js';
import {
agenetes,
INTERNAL_DRIVER_KIND,
Expand All @@ -28,6 +26,8 @@ import {
} from './agenetes/drivers.js';
import { createChatSubmission } from './agenetes/handle.js';
import { buildHuabuPiWorkloadSpec } from './agenetes/pi-driver.js';
import { loadAgent, type AgentId } from '../../prompt/index.js';
import { canvasAcpNamespace } from '../workspace/paths.js';
import { renderInternalAgentInputs } from './conversation/prompt/build-prompt.js';
import { dumpAssembledPrompt } from './conversation/prompt/debug-prompt.js';
import { type ToolScope } from './tools/index.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
*/

import { appendFileSync } from 'node:fs';
import path from 'node:path';

import { mkdirp } from '../../../../utils/fs.js';
import { chatDir, chatPromptLogPath } from '../../../storage/paths.js';
import { chatPromptLogPath } from '../../../workspace/paths.js';

import type { Context } from '@earendil-works/pi-ai';
import type { FastifyBaseLogger } from 'fastify';
Expand Down Expand Up @@ -166,12 +167,9 @@ export function dumpAssembledPrompt(params: DumpPromptParams): void {
});
out.push('', '');

mkdirp(chatDir(canvasId));
appendFileSync(
chatPromptLogPath(canvasId, params.threadId),
out.join('\n'),
'utf-8',
);
const logPath = chatPromptLogPath(canvasId, params.threadId);
mkdirp(path.dirname(logPath));
appendFileSync(logPath, out.join('\n'), 'utf-8');
} catch (err) {
params.logger.warn(
{ err: String(err) },
Expand Down
13 changes: 1 addition & 12 deletions apps/server/src/modules/agent/memory/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const physicalState = vi.hoisted(() => ({ root: '' }));

vi.mock('../agent.service.js', () => ({ runAgent: vi.fn() }));
vi.mock('./trigger.js', () => ({ readMemoryState: vi.fn() }));
vi.mock('../../../prompt/index.js', () => ({
loadAgent: vi.fn(),
listSkills: vi.fn(),
}));
vi.mock('../../storage/index.js', () => ({ getStructuredStore: vi.fn() }));
vi.mock('../../workspace/disk/paths.js', () => ({
vi.mock('../../workspace/paths.js', () => ({
canvasMemoryPath: (canvasId: string) =>
`${physicalState.root}/${canvasId}/.memory/space.md`,
chatDir: (canvasId: string) =>
`${physicalState.root}/${canvasId}/.history/chat`,
workspaceMemoryPath: () => `${physicalState.root}/setting/user.md`,
}));

import { runAgent } from '../agent.service.js';
import { runAnalysisPass } from './analyzer.js';
import { readMemoryState } from './trigger.js';
import { loadAgent, listSkills } from '../../../prompt/index.js';
import { getStructuredStore } from '../../storage/index.js';

Expand Down Expand Up @@ -100,11 +96,6 @@ beforeEach(() => {
.mockReturnValue(
emptyAgentStream() as unknown as ReturnType<typeof runAgent>,
);
vi.mocked(readMemoryState).mockReset().mockReturnValue({
counter: 0,
lastAnalyzedAt: null,
lastSeenThreadCursor: null,
});
vi.mocked(loadAgent)
.mockReset()
.mockReturnValue({
Expand Down Expand Up @@ -134,7 +125,6 @@ describe('runAnalysisPass repository sources', () => {
expect(space).toHaveBeenCalledWith('canvas-a');
expect(recordRead).toHaveBeenCalledTimes(1);
expect(eventsRead).not.toHaveBeenCalled();
expect(readMemoryState).not.toHaveBeenCalled();
expect(loadAgent).not.toHaveBeenCalled();
expect(runAgent).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -164,7 +154,6 @@ describe('runAnalysisPass repository sources', () => {
await expect(runAnalysisPass('canvas-a')).resolves.toEqual({
status: 'completed',
results: [],
latestChatTs: null,
});

expect(space).toHaveBeenCalledTimes(1);
Expand Down
162 changes: 5 additions & 157 deletions apps/server/src/modules/agent/memory/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
* The worker calls {@link runAnalysisPass}. We:
*
* 1. Build the system prompt from `prompt/agents/memory/AGENT.md`.
* 2. Assemble a compact context bundle from backend-owned Space records and
* logs plus the remaining Disk-owned chat and memory surfaces.
* 2. Assemble a compact context bundle from backend-owned Space records
* and logs, plus the memory surfaces.
* 3. Run the sub-agent against that context. The agent's only way
* to affect the world is via the `fs_write` tool, whose handler
* routes by virtual path into the writers in `./writers.ts`.
Expand All @@ -24,11 +24,8 @@
* mutations on the same disk targets apply in declared order.
*/

import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { existsSync, readFileSync } from 'node:fs';

import { runAgent } from '../agent.service.js';
import { readMemoryState } from './trigger.js';
import { loadAgent, listSkills } from '../../../prompt/index.js';
import {
getStructuredStore,
Expand All @@ -38,9 +35,9 @@ import {
} from '../../storage/index.js';
import {
canvasMemoryPath,
chatDir,
workspaceMemoryPath,
} from '../../workspace/disk/paths.js';
} from '../../workspace/paths.js';
import { runAgent } from '../agent.service.js';

import type { MemoryLogger } from './index.js';
import type { WriteResult } from './writers.js';
Expand All @@ -54,8 +51,6 @@ import type { Context, Message } from '@earendil-works/pi-ai';
*/
const MAX_NODES_IN_SNAPSHOT = 60;
const MAX_EVENTS_IN_DIGEST = 100;
const MAX_CHAT_TURNS_IN_DIGEST = 12;
const MAX_THREAD_SCAN = 6;

/**
* Run one memory analysis pass.
Expand All @@ -64,19 +59,11 @@ const MAX_THREAD_SCAN = 6;
* does NOT call `markAnalyzed` (so the next trigger retries). Writer
* rejections are *not* errors — they come back as `ok:false` tool
* results which we surface in the returned summary.
*
* The returned `latestChatTs` is the maximum message timestamp the
* pass scanned (independent of which were summarised into the
* prompt). The worker persists it as `lastSeenThreadCursor` via
* {@link markAnalyzed} so subsequent passes only look at strictly
* newer turns — without it the chat digest would re-include the
* same messages every threshold crossing.
*/
export type AnalysisPassResult =
| {
status: 'completed';
results: WriteResult[];
latestChatTs: number | null;
}
| { status: 'skipped'; reason: 'space-not-found' };

Expand Down Expand Up @@ -136,7 +123,6 @@ export async function runAnalysisPass(
return {
status: 'completed',
results: writeResults,
latestChatTs: bundle.latestChatTs,
};
}

Expand Down Expand Up @@ -173,13 +159,6 @@ function parseWriteResult(raw: string): WriteResult | null {
interface ContextBundle {
messages: Message[];
summary: string;
/**
* Max message timestamp scanned by the chat digest, or `null` when
* no new turns were seen. Carries to the worker so it can persist
* `lastSeenThreadCursor` and the next pass only looks at strictly
* newer turns.
*/
latestChatTs: number | null;
}

/**
Expand Down Expand Up @@ -207,18 +186,6 @@ async function assembleContext(
});
parts.push(`${snapshot.nodeCount} nodes`);

const state = readMemoryState(canvasId);
const chat = readChatDigest(canvasId, state.lastSeenThreadCursor);
if (chat) {
messages.push({
role: 'user',
content: `[SYSTEM Chat digest since ${
state.lastSeenThreadCursor ?? 'start'
}]\n${chat.text}`,
timestamp: Date.now(),
});
parts.push(`${chat.turns} chat turns`);
}
const eventRows = await handle.events.read(MAX_EVENTS_IN_DIGEST);
const events = readEventsDigest(eventRows);
if (events) {
Expand Down Expand Up @@ -247,7 +214,6 @@ async function assembleContext(
return {
messages,
summary: parts.join(', ') || '(empty)',
latestChatTs: chat?.latestTs ?? null,
};
}

Expand Down Expand Up @@ -293,116 +259,6 @@ function summariseNode(node: unknown): string {
return `- [${type}] ${id} "${label.slice(0, 60)}"${pos}`;
}

interface ChatDigest {
text: string;
turns: number;
/**
* Max `timestamp` seen across every message that passed the `since`
* filter, regardless of whether it landed in the digest body. The
* worker persists this as the next pass's `lastSeenThreadCursor`
* so the chat digest monotonically advances.
*/
latestTs: number | null;
}

/**
* Pull a digest of recent chat turns from `<canvas>/.history/chat/`.
*
* Strategy:
* - List every thread file, sorted by `mtime` descending.
* - Walk up to {@link MAX_THREAD_SCAN} threads, scanning each
* message in turn. For each message:
* - drop turns older than `since` (the bookkeeping's
* `lastSeenThreadCursor`);
* - track `latestTs` = max(`timestamp`) of every survivor,
* so the caller can advance the cursor even when the
* digest body itself was capped;
* - skip system / non-user / non-assistant rows;
* - emit up to {@link MAX_CHAT_TURNS_IN_DIGEST} into the body.
* - For each emitted turn, render the role + the first ~200 chars
* of the content (or `[tool: name]` for assistant turns that
* only carried tool calls).
*/
function readChatDigest(
canvasId: string,
since: number | null,
): ChatDigest | null {
const dir = chatDir(canvasId);
if (!existsSync(dir)) return null;
let files: string[];
try {
files = readdirSync(dir);
} catch {
return null;
}
const threads = files
.filter((f) => f.endsWith('.json'))
.map((f) => path.join(dir, f))
.map((p) => ({ path: p, mtime: safeMtime(p) }))
.filter((t) => t.mtime !== null)
.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0))
.slice(0, MAX_THREAD_SCAN);

const lines: string[] = [];
let turns = 0;
let latestTs: number | null = null;
for (const thread of threads) {
let ctx: { messages?: unknown[] } | null;
try {
ctx = JSON.parse(readFileSync(thread.path, 'utf8')) as {
messages?: unknown[];
};
} catch {
continue;
}
if (!ctx?.messages || !Array.isArray(ctx.messages)) continue;
for (const m of ctx.messages) {
if (!m || typeof m !== 'object') continue;
const msg = m as {
role?: string;
content?: unknown;
timestamp?: number;
};
const ts = typeof msg.timestamp === 'number' ? msg.timestamp : null;
if (since !== null && ts !== null && ts <= since) continue;

// Advance latestTs for every message that survived the `since`
// filter — not just the ones we end up emitting. That way the
// cursor still advances when MAX_CHAT_TURNS_IN_DIGEST has been
// reached, and we don't re-scan the same prefix next pass.
if (ts !== null && (latestTs === null || ts > latestTs)) {
latestTs = ts;
}

const role = msg.role;
if (role !== 'user' && role !== 'assistant') continue;
if (turns >= MAX_CHAT_TURNS_IN_DIGEST) continue;
const text = digestMessageContent(msg.content);
if (text.startsWith('[SYSTEM')) continue;
lines.push(`${role}: ${text}`);
turns++;
}
}
if (turns === 0 && latestTs === null) return null;
return { text: lines.join('\n'), turns, latestTs };
}

function digestMessageContent(content: unknown): string {
if (typeof content === 'string') return content.slice(0, 200);
if (!Array.isArray(content)) return '';
const parts: string[] = [];
for (const block of content) {
if (!block || typeof block !== 'object') continue;
const b = block as { type?: string; text?: string; name?: string };
if (b.type === 'text' && typeof b.text === 'string') {
parts.push(b.text);
} else if (b.type === 'toolCall' && typeof b.name === 'string') {
parts.push(`[tool: ${b.name}]`);
}
}
return parts.join(' ').slice(0, 200);
}

interface EventsDigest {
text: string;
count: number;
Expand Down Expand Up @@ -462,11 +318,3 @@ function readFileSafe(file: string): string {
return '';
}
}

function safeMtime(file: string): number | null {
try {
return statSync(file).mtimeMs;
} catch {
return null;
}
}
5 changes: 4 additions & 1 deletion apps/server/src/modules/agent/memory/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@

import { existsSync, readFileSync } from 'node:fs';

import { workspaceMemoryPath, canvasMemoryPath } from '../../storage/paths.js';
import {
workspaceMemoryPath,
canvasMemoryPath,
} from '../../workspace/paths.js';

/**
* Read the user memory body.
Expand Down
Loading
Loading