Skip to content
6 changes: 6 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import {
originGuardPlugin,
resolveAllowedHostnames,
} from './modules/security/index.js';
import { closeStorage } from './modules/storage/index.js';
import webRoutes from './modules/web/web.route.js';
import {
initWorkspaceFromEnv,
Expand Down Expand Up @@ -366,6 +367,11 @@ if (bundledAgentTeamsPath) {
// after the process is gone. Closing them here lets `app.close()` (driven
// by the SIGTERM/SIGINT handlers in server.ts) tear them down gracefully.
app.addHook('onClose', async () => resetExternalNoteSessions());
// Close the storage connections on graceful shutdown. Disk holds nothing a
// process exit would not release, so this earns its place by being the seat
// a connection-holding backend will need — a pool nobody closes leaks on
// every restart, and the lifecycle is where that is visible.
app.addHook('onClose', async () => closeStorage());
// Capture the bound TCP port for L1-owned reachback (RFS): the
// canvas-scoped `HUABU_RFS_URL` base is built from this. RFS is
// canvas-coupled and therefore a pure L1 concern, so the port lives in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ export async function buildAttachmentParts(
if (resolvedCanvasId && resolvedFilename) {
try {
const bytes =
await space(resolvedCanvasId).blobs.read(resolvedFilename);
await space(resolvedCanvasId).artifacts.read(resolvedFilename);
// Attachments are inlined as text; binary bytes simply
// decode to mojibake and the URL-only branch is used instead.
if (bytes) fileContent = bytes.toString('utf-8');
Expand Down
50 changes: 42 additions & 8 deletions apps/server/src/modules/agent/conversation/prompt/debug-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
import { appendFileSync } from 'node:fs';
import path from 'node:path';

import { mkdirp } from '../../../../utils/fs.js';
import { chatPromptLogPath } from '../../../workspace/paths.js';
import { sanitizeId } from '../../../../utils/fs.js';
import { space } from '../../../storage/index.js';

import type { SpaceSubstrate } from '../../../storage/index.js';
import type { Context } from '@earendil-works/pi-ai';
import type { FastifyBaseLogger } from 'fastify';

Expand Down Expand Up @@ -133,10 +134,31 @@ export interface DumpPromptParams {
logger: FastifyBaseLogger;
}

/** This module's namespace on the storage extension substrate. */
const PROMPT_LOG_NAMESPACE = 'huabu.prompt.log';

/**
* Appends chained so turns land in the log in the order they were dumped.
*
* Resolving a substrate is asynchronous while the call site is a synchronous
* driver callback, so the write cannot happen inline any more. Each turn's
* text is still rendered synchronously — a snapshot of the messages as they
* were — and only the append is deferred.
*/
let appendTail: Promise<void> = Promise.resolve();

/** Where this module keeps one log per thread on a Disk substrate. */
function diskLogPath(substrate: SpaceSubstrate, threadId: string): string {
return path.join(
substrate.directory,
`${sanitizeId(threadId, 'threadId')}.prompt.log`,
);
}

/**
* Append a readable dump of the assembled prompt for one turn. No-op
* unless `HUABU_DEBUG_PROMPT` is set or `canvasId` is missing (the log
* lives under the canvas chat dir). Never throws.
* unless `HUABU_DEBUG_PROMPT` is set, or `canvasId` is missing (the log is
* per-Space state). Never throws, and never blocks the turn.
*/
export function dumpAssembledPrompt(params: DumpPromptParams): void {
if (!isPromptDebugEnabled()) return;
Expand Down Expand Up @@ -167,13 +189,25 @@ export function dumpAssembledPrompt(params: DumpPromptParams): void {
});
out.push('', '');

const logPath = chatPromptLogPath(canvasId, params.threadId);
mkdirp(path.dirname(logPath));
appendFileSync(logPath, out.join('\n'), 'utf-8');
const block = out.join('\n');
appendTail = appendTail
.then(async () => {
// A Space deleted mid-turn has no substrate, and this is the last
// thing that should recreate one for a debug file.
const substrate = await space(canvasId).extension(PROMPT_LOG_NAMESPACE);
if (!substrate) return;
appendFileSync(diskLogPath(substrate, params.threadId), block, 'utf-8');
})
.catch((err: unknown) => {
params.logger.warn(
{ err: String(err) },
'dumpAssembledPrompt: failed to write prompt debug log',
);
});
} catch (err) {
params.logger.warn(
{ err: String(err) },
'dumpAssembledPrompt: failed to write prompt debug log',
'dumpAssembledPrompt: failed to render prompt debug log',
);
}
}
23 changes: 19 additions & 4 deletions apps/server/src/modules/agent/memory/analyzer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,32 @@ import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const physicalState = vi.hoisted(() => ({ root: '' }));
const physicalState = vi.hoisted(() => ({
root: '',
canvasMemory: null as string | null,
}));

vi.mock('../agent.service.js', () => ({ runAgent: vi.fn() }));
vi.mock('../../../prompt/index.js', () => ({
loadAgent: vi.fn(),
listSkills: vi.fn(),
}));
vi.mock('../../storage/index.js', () => ({ getStructuredStore: vi.fn() }));
vi.mock('../../storage/index.js', () => ({
getStructuredStore: vi.fn(),
// The memory body is a blob under the Space's own scope now, so the
// snapshot reads it through here rather than off a path.
space: (canvasId: string) => ({
memory: {
read: async () =>
physicalState.canvasMemory === null
? null
: Buffer.from(physicalState.canvasMemory, 'utf8'),
canvasId,
},
}),
SPACE_MEMORY_BLOB_NAME: 'space.md',
}));
vi.mock('../../workspace/paths.js', () => ({
canvasMemoryPath: (canvasId: string) =>
`${physicalState.root}/${canvasId}/.memory/space.md`,
workspaceMemoryPath: () => `${physicalState.root}/setting/user.md`,
}));

Expand Down
12 changes: 5 additions & 7 deletions apps/server/src/modules/agent/memory/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,9 @@ import {
type CanvasFile,
type SpaceHandle,
} from '../../storage/index.js';
import {
canvasMemoryPath,
workspaceMemoryPath,
} from '../../workspace/paths.js';
import { workspaceMemoryPath } from '../../workspace/paths.js';
import { runAgent } from '../agent.service.js';
import { readCanvasMemory } from './read.js';

import type { MemoryLogger } from './index.js';
import type { WriteResult } from './writers.js';
Expand Down Expand Up @@ -197,7 +195,7 @@ async function assembleContext(
parts.push(`${events.count} ops`);
}

const memorySnapshot = readMemorySnapshot(canvasId);
const memorySnapshot = await readMemorySnapshot(canvasId);
messages.push({
role: 'user',
content: `[SYSTEM Current memory]\n${memorySnapshot}`,
Expand Down Expand Up @@ -279,14 +277,14 @@ function readEventsDigest(events: readonly CanvasEvent[]): EventsDigest | null {
return { text: summaries.join('\n'), count: events.length };
}

function readMemorySnapshot(canvasId: string): string {
async function readMemorySnapshot(canvasId: string): Promise<string> {
const parts: string[] = [];

const longTerm = readFileSafe(workspaceMemoryPath());
parts.push('## Long-term memory');
parts.push(longTerm.trim().length > 0 ? longTerm.trim() : '(empty)');

const canvas = readFileSafe(canvasMemoryPath(canvasId));
const canvas = (await readCanvasMemory(canvasId)) ?? '';
parts.push('');
parts.push('## Canvas memory');
parts.push(canvas.trim().length > 0 ? canvas.trim() : '(empty)');
Expand Down
21 changes: 13 additions & 8 deletions apps/server/src/modules/agent/memory/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@

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

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

/**
* Read the user memory body.
Expand All @@ -34,12 +32,19 @@ export function readWorkspaceMemory(): string | null {
}

/**
* Read the per-canvas canvas memory body.
* Read the per-Space memory body.
*
* Same null-on-empty contract as {@link readWorkspaceMemory}.
* Same null-on-empty contract as {@link readWorkspaceMemory}. A blob under the
* Space's own memory scope (proposal §6.4.3, disposition D) — unlike the
* Workspace memory above, which is not scoped to a Space and stays a file.
*/
export function readCanvasMemory(canvasId: string): string | null {
return readNonEmpty(canvasMemoryPath(canvasId));
export async function readCanvasMemory(
canvasId: string,
): Promise<string | null> {
const bytes = await space(canvasId).memory.read(SPACE_MEMORY_BLOB_NAME);
if (bytes === null) return null;
const raw = bytes.toString('utf8');
return raw.trim().length === 0 ? null : raw;
}

function readNonEmpty(file: string): string | null {
Expand Down
17 changes: 1 addition & 16 deletions apps/server/src/modules/agent/memory/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*
* Everything else is rejected. Each writer goes through
* {@link resolveLongTermPath} / {@link resolveUserSkillPath} /
* {@link resolveWorkingMemoryPath} so the security model lives in one
* one resolver per tier so the security model lives in one
* place — same posture as the chat sandbox at
* `modules/agent/tools/handlers/fs-sandbox.ts`.
*
Expand All @@ -29,8 +29,6 @@ import {
workspaceMemoryPath,
settingDir,
userSkillsDir,
canvasMemoryDir,
canvasMemoryPath,
} from '../../workspace/paths.js';

/** Thrown by every resolver below on out-of-sandbox attempts. */
Expand Down Expand Up @@ -97,16 +95,3 @@ export function resolveUserSkillPath(id: string): string {
ensureUnderRoot(root, target, 'user skills');
return target;
}

/**
* Resolve the absolute Space memory file path. Throws if the resolved path
* escapes the canvas's `.memory/` root (a defensive check — the path
* computation in `workspace/paths.ts` already constrains the result,
* but going through `ensureUnderRoot` keeps the invariant explicit).
*/
export function resolveWorkingMemoryPath(canvasId: string): string {
const root = canvasMemoryDir(canvasId);
const target = canvasMemoryPath(canvasId);
ensureUnderRoot(root, target, 'canvas memory');
return target;
}
117 changes: 117 additions & 0 deletions apps/server/src/modules/agent/memory/trigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

/**
* Memory-worker bookkeeping over the storage extension substrate.
*
* This is the first owner to build a store on a substrate, so it doubles as
* evidence that the substrate is usable without the port growing a data API:
* everything here is this module's own format, in a place storage handed it
* and never reads (proposal §6.4.4).
*/

import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';

import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
bumpOpCounter,
markAnalyzed,
OP_THRESHOLD,
readMemoryState,
} from './trigger.js';
import { createSpace, deleteSpace, space } from '../../storage/index.js';
import { setWorkspacePath } from '../../workspace.js';

const CANVAS = 'canvas-memory-trigger';
let tmp: string;

beforeEach(async () => {
tmp = mkdtempSync(path.join(tmpdir(), 'huabu-memory-trigger-'));
setWorkspacePath(tmp);
const created = await createSpace(CANVAS, 'Trigger');
if (!created.ok) throw new Error('Expected to create the Space');
});

afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});

describe('memory trigger state', () => {
it('starts from zero and persists what it counts', async () => {
await expect(readMemoryState(CANVAS)).resolves.toEqual({
counter: 0,
lastAnalyzedAt: null,
lastSeenThreadCursor: null,
});

await expect(bumpOpCounter(CANVAS, 3)).resolves.toBe(false);
await expect(bumpOpCounter(CANVAS, 4)).resolves.toBe(false);

await expect(readMemoryState(CANVAS)).resolves.toMatchObject({
counter: 7,
});
});

it('signals once at the threshold and resets in the same write', async () => {
await expect(bumpOpCounter(CANVAS, OP_THRESHOLD)).resolves.toBe(true);

// Reset in the same write, so the very next op batch cannot double-fire.
await expect(readMemoryState(CANVAS)).resolves.toMatchObject({
counter: 0,
});
await expect(bumpOpCounter(CANVAS, 1)).resolves.toBe(false);
});

it('keeps concurrent bumps from losing increments', async () => {
await Promise.all(
Array.from({ length: 10 }, () => bumpOpCounter(CANVAS, 1)),
);

await expect(readMemoryState(CANVAS)).resolves.toMatchObject({
counter: 10,
});
});

it('records an analysis pass without touching the counter', async () => {
await bumpOpCounter(CANVAS, 5);
await markAnalyzed(CANVAS, { lastSeenThreadCursor: 42 });

const state = await readMemoryState(CANVAS);
expect(state.counter).toBe(5);
expect(state.lastSeenThreadCursor).toBe(42);
expect(state.lastAnalyzedAt).toEqual(expect.any(Number));
});

it('keeps each Space to its own bookkeeping', async () => {
const other = 'canvas-memory-trigger-other';
const created = await createSpace(other, 'Other');
if (!created.ok) throw new Error('Expected to create the second Space');

await bumpOpCounter(CANVAS, 4);
await bumpOpCounter(other, 9);

await expect(readMemoryState(CANVAS)).resolves.toMatchObject({
counter: 4,
});
await expect(readMemoryState(other)).resolves.toMatchObject({ counter: 9 });
});

it('drops a write for a Space that was deleted mid-flight', async () => {
await bumpOpCounter(CANVAS, 1);
const spaceDir = space(CANVAS).diskTree?.directory();
if (!spaceDir) throw new Error('Expected the Disk backend in this test');

await deleteSpace(CANVAS);

// The op-counter hook fires after the delete has already removed the
// Space. This used to need a guard here — a bare write would recreate the
// directory as a stub holding nothing but bookkeeping. The port refuses a
// substrate for a Space that is gone, so the write is a silent no-op and
// nothing is resurrected.
await expect(bumpOpCounter(CANVAS, 1)).resolves.toBe(false);
expect(existsSync(spaceDir)).toBe(false);
});
});
Loading
Loading