Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a5d14ee
feat(server): derive memory and drive store paths from stateDir
JTBroad Aug 1, 2026
aa8b4c3
feat(contracts): add memory and drive root settings
JTBroad Aug 1, 2026
f4ad586
feat(server): add memory and drive path resolution with containment
JTBroad Aug 1, 2026
49c876f
feat(server): add migration 036 for memory and drive index tables
JTBroad Aug 1, 2026
1d75d8e
feat(server): add write-path secret redaction for memory capture
JTBroad Aug 1, 2026
3ce1ddd
feat(server): add daily capture store with append-safe writes
JTBroad Aug 1, 2026
1693cd2
feat(server): add note store with self-healing index
JTBroad Aug 1, 2026
c8832b2
feat(server): add drive artifact store with bidirectional provenance
JTBroad Aug 1, 2026
3fc440b
feat(mcp): add a memory capability with a generalized denial error
JTBroad Aug 1, 2026
8a3ff33
feat(mcp): add the memory toolkit
JTBroad Aug 1, 2026
2323865
feat(server): add consolidation with single-writer lock
JTBroad Aug 1, 2026
7916f68
feat(server): add the ContinuityBrief builder (injection deferred)
JTBroad Aug 1, 2026
2f00dd7
test(mcp): assert the memory toolkit reaches the served layer
JTBroad Aug 1, 2026
e56a18b
feat(server): inject the ContinuityBrief on a thread's opening turn
JTBroad Aug 1, 2026
e9728bd
refactor(server): name the consolidation summary directory for what i…
JTBroad Aug 1, 2026
09b4bdb
feat(contracts,server): add memory consolidate and read RPCs
JTBroad Aug 1, 2026
6c5f993
feat(web): add memory and drive root settings rows
JTBroad Aug 1, 2026
01a75ed
feat(web): add a consolidate-memory command to the palette
JTBroad Aug 1, 2026
f4ddbac
feat(web,server): add the Memory workspace and brief activities
JTBroad Aug 1, 2026
a4cbaf4
fix(web): uncover the workspace rail and refresh detail panes
JTBroad Aug 1, 2026
ebf8e8f
fix(web): keep window chrome from landing on the workspace rail
JTBroad Aug 1, 2026
3dee3ca
fix(web): return to the open thread when leaving the Memory workspace
JTBroad Aug 1, 2026
ecacef1
feat(web,server): add a Daily tab for the capture buffer
JTBroad Aug 1, 2026
61c9404
fix(web): clear the window controls in the Memory workspace
JTBroad Aug 1, 2026
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
8 changes: 8 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
[WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
[WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
// Consolidation mutates the store (it promotes notes and clears the buffer),
// so it operates; the four read endpoints only read.
[WS_METHODS.memoryConsolidate]: AuthOrchestrationOperateScope,
[WS_METHODS.memoryReadDaily]: AuthOrchestrationReadScope,
[WS_METHODS.memoryListNotes]: AuthOrchestrationReadScope,
[WS_METHODS.memoryGetNote]: AuthOrchestrationReadScope,
[WS_METHODS.memoryListArtifacts]: AuthOrchestrationReadScope,
[WS_METHODS.memoryGetArtifact]: AuthOrchestrationReadScope,
[WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
[WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
[WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Path from "effect/Path";

import * as NodeServices from "@effect/platform-node/NodeServices";

import { deriveServerPaths } from "./config.ts";

const BASE_DIR = "/tmp/t3-derive-paths";
const DEV_URL = new URL("http://localhost:5173");

it.layer(NodeServices.layer)("deriveServerPaths", (it) => {
it.effect("places the memory and drive stores under the state directory", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const derived = yield* deriveServerPaths(BASE_DIR, undefined);

expect(derived.memoryDir).toBe(path.join(derived.stateDir, "memory"));
expect(derived.driveDir).toBe(path.join(derived.stateDir, "drive"));
}),
);

// The memory store is shared across projects on purpose, but it must never be
// shared across a dev/test server and a real one: consolidation clears the
// capture buffer, so a leaked path would let a test run destroy real notes.
it.effect("keeps dev and production memory and drive stores separate", () =>
Effect.gen(function* () {
const production = yield* deriveServerPaths(BASE_DIR, undefined);
const dev = yield* deriveServerPaths(BASE_DIR, DEV_URL);

expect(dev.stateDir).not.toBe(production.stateDir);
expect(dev.memoryDir).not.toBe(production.memoryDir);
expect(dev.driveDir).not.toBe(production.driveDir);
}),
);

// An explicit --home-dir opts out of the dev split, matching how every other
// path in this module behaves.
it.effect("honors an explicit base directory for both stores", () =>
Effect.gen(function* () {
const path = yield* Path.Path;
const derived = yield* deriveServerPaths(BASE_DIR, DEV_URL, { baseDirIsExplicit: true });

expect(derived.stateDir).toBe(path.join(BASE_DIR, "userdata"));
expect(derived.memoryDir).toBe(path.join(BASE_DIR, "userdata", "memory"));
expect(derived.driveDir).toBe(path.join(BASE_DIR, "userdata", "drive"));
}),
);
});
12 changes: 12 additions & 0 deletions apps/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface ServerDerivedPaths {
readonly providerStatusCacheDir: string;
readonly worktreesDir: string;
readonly attachmentsDir: string;
readonly memoryDir: string;
readonly driveDir: string;
readonly logsDir: string;
readonly serverLogPath: string;
readonly serverTracePath: string;
Expand Down Expand Up @@ -108,6 +110,12 @@ export const deriveServerPaths = Effect.fn(function* (
);
const dbPath = join(stateDir, "state.sqlite");
const attachmentsDir = join(stateDir, "attachments");
// Memory and drive are user-level stores shared across every project, but
// they are still derived from `stateDir` so a dev server or a test run gets
// its own copy. A home-directory default would let `pnpm test` read and
// clear the real capture buffer.
const memoryDir = join(stateDir, "memory");
const driveDir = join(stateDir, "drive");
const logsDir = join(stateDir, "logs");
const providerLogsDir = join(logsDir, "provider");
const providerStatusCacheDir = join(baseDir, "caches");
Expand All @@ -119,6 +127,8 @@ export const deriveServerPaths = Effect.fn(function* (
providerStatusCacheDir,
worktreesDir: join(baseDir, "worktrees"),
attachmentsDir,
memoryDir,
driveDir,
logsDir,
serverLogPath: join(logsDir, "server.log"),
serverTracePath: join(logsDir, "server.trace.ndjson"),
Expand All @@ -143,6 +153,8 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server
fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }),
fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }),
fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }),
fs.makeDirectory(derivedPaths.memoryDir, { recursive: true }),
fs.makeDirectory(derivedPaths.driveDir, { recursive: true }),
fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }),
fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
Expand Down
53 changes: 53 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,3 +269,56 @@ it.effect("registers annotated tools and preserves authenticated request context
}),
).pipe(Effect.provide(TestLayer)),
);

// Registration wiring is easy to get wrong in a way no unit test catches: a
// toolkit can be built correctly and still never reach the served layer. This
// lists tools through the same registration layer the server actually mounts.
it.effect("serves the memory toolkit alongside preview", () =>
Effect.scoped(
Effect.gen(function* () {
const servedLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
Layer.provide(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))),
Layer.provideMerge(
McpServer.layerHttp({
name: "MCP toolkit listing test",
version: "1.0.0",
path: "/mcp",
}),
),
);
yield* HttpRouter.serve(servedLayer, {
disableListenLog: true,
disableLogger: true,
}).pipe(Layer.build);
const httpClient = yield* HttpClient.HttpClient;

const initialize = yield* httpClient.post("/mcp", {
headers: { accept: "application/json, text/event-stream" },
body: HttpBody.text(
`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`,
"application/json",
),
});
expect(initialize.status).toBe(200);
const sessionId = initialize.headers["mcp-session-id"];

const listed = yield* httpClient.post("/mcp", {
headers: {
accept: "application/json, text/event-stream",
"mcp-session-id": sessionId!,
},
body: HttpBody.text(
`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`,
"application/json",
),
});
const body = yield* listed.text;

for (const tool of ["memory_append_daily", "memory_read_daily", "memory_search"]) {
expect(body).toContain(tool);
}
// Preview must still be served: the memory toolkit is additive.
expect(body).toContain("preview_status");
}),
).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
7 changes: 7 additions & 0 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
PreviewSnapshotToolkitHandlersLive,
PreviewStandardToolkitHandlersLive,
} from "./toolkits/preview/handlers.ts";
import { MemoryToolkitHandlersLive } from "./toolkits/memory/handlers.ts";
import { MemoryToolkit } from "./toolkits/memory/tools.ts";
import {
PreviewSnapshotTool,
PreviewSnapshotToolkit,
Expand Down Expand Up @@ -211,9 +213,14 @@ const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnaps
Layer.provide(PreviewSnapshotToolkitHandlersLive),
);

const MemoryToolkitRegistrationLive = McpServer.toolkit(MemoryToolkit).pipe(
Layer.provide(MemoryToolkitHandlersLive),
);

export const PreviewToolkitRegistrationLive = Layer.mergeAll(
PreviewStandardToolkitRegistrationLive,
PreviewSnapshotRegistrationLive,
MemoryToolkitRegistrationLive,
);

const McpTransportLive = McpServer.layerHttp({
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/mcp/McpInvocationContext.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, it } from "@effect/vitest";
import {
EnvironmentId,
McpCapabilityUnavailableError,
PreviewAutomationUnavailableError,
ProviderInstanceId,
ThreadId,
Expand Down Expand Up @@ -36,3 +37,40 @@ it.effect("reports the scoped credential context when preview capability is unav
expect(error.message).toBe("MCP credential does not grant the preview capability.");
});
});

const scope = (
capabilities: ReadonlySet<McpInvocationContext.McpCapability>,
): McpInvocationContext.McpInvocationScope => ({
environmentId: EnvironmentId.make("environment-1"),
threadId: ThreadId.make("thread-1"),
providerSessionId: "provider-session-1",
providerInstanceId: ProviderInstanceId.make("codex"),
capabilities,
issuedAt: 1,
});

// A memory denial must not surface as a preview error: the toolkit names are
// user-visible in logs and the two have nothing to do with each other.
it.effect("reports a generalized error when the memory capability is unavailable", () =>
Effect.gen(function* () {
const error = yield* McpInvocationContext.requireMcpCapability("memory").pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, scope(new Set(["preview"]))),
Effect.flip,
);

expect(error).toBeInstanceOf(McpCapabilityUnavailableError);
expect(error).toMatchObject({ capability: "memory", threadId: ThreadId.make("thread-1") });
expect(error.message).toBe("MCP credential does not grant the memory capability.");
}),
);

it.effect("returns the invocation when the capability is granted", () =>
Effect.gen(function* () {
const granted = scope(new Set(["preview", "memory"]));
const invocation = yield* McpInvocationContext.requireMcpCapability("memory").pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, granted),
);

expect(invocation.threadId).toBe(granted.threadId);
}),
);
37 changes: 32 additions & 5 deletions apps/server/src/mcp/McpInvocationContext.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import {
type EnvironmentId,
McpCapabilityUnavailableError,
PreviewAutomationUnavailableError,
type ProviderInstanceId,
type ThreadId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";

export type McpCapability = "preview";
/**
* Capabilities an MCP session can be granted. Mirrors `McpCapabilityName` in
* contracts, which is the shape that crosses the wire in errors.
*/
export type McpCapability = "preview" | "memory";

export interface McpInvocationScope {
readonly environmentId: EnvironmentId;
Expand All @@ -23,18 +28,40 @@ export class McpInvocationContext extends Context.Service<
McpInvocationScope
>()("t3/mcp/McpInvocationContext") {}

export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* (
const requireMcpCapabilityImpl = Effect.fn("mcp.requireCapability")(function* (
capability: McpCapability,
) {
const invocation = yield* McpInvocationContext;
if (!invocation.capabilities.has(capability)) {
return yield* new PreviewAutomationUnavailableError({
capability,
const denial = {
environmentId: invocation.environmentId,
threadId: invocation.threadId,
providerSessionId: invocation.providerSessionId,
providerInstanceId: invocation.providerInstanceId,
});
};
// Preview keeps its original error so existing clients decoding
// PreviewAutomationUnavailableError are unaffected; anything else gets the
// generalized one rather than an error named after a toolkit it never used.
return yield* capability === "preview"
? new PreviewAutomationUnavailableError({ capability, ...denial })
: new McpCapabilityUnavailableError({ capability, ...denial });
}
return invocation;
});

/**
* Require a capability, failing with the error that belongs to it.
*
* The implementation handles every capability, so its inferred failure type is
* the union of both errors. Callers only ever pass one literal, and a preview
* handler should not have to declare a memory error it can never receive --
* hence the overloads narrowing the failure per capability.
*/
export const requireMcpCapability = requireMcpCapabilityImpl as {
(
capability: "preview",
): Effect.Effect<McpInvocationScope, PreviewAutomationUnavailableError, McpInvocationContext>;
(
capability: Exclude<McpCapability, "preview">,
): Effect.Effect<McpInvocationScope, McpCapabilityUnavailableError, McpInvocationContext>;
};
2 changes: 1 addition & 1 deletion apps/server/src/mcp/McpSessionRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* (
threadId: ThreadId.make(request.threadId),
providerSessionId,
providerInstanceId: ProviderInstanceId.make(request.providerInstanceId),
capabilities: new Set(["preview"]),
capabilities: new Set(["preview", "memory"]),
issuedAt,
};
yield* SynchronizedRef.update(state, ({ records }) => {
Expand Down
71 changes: 71 additions & 0 deletions apps/server/src/mcp/toolkits/memory/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vite-plus/test";
import * as Context from "effect/Context";
import { Tool } from "effect/unstable/ai";

import {
DriveWriteArtifactTool,
MemoryAppendDailyTool,
MemoryReadDailyTool,
MemorySearchTool,
MemoryToolkit,
} from "./tools.ts";

const parameterKeys = (tool: { readonly parametersSchema: unknown }): ReadonlyArray<string> =>
Object.keys((tool.parametersSchema as { fields?: Record<string, unknown> }).fields ?? {});

describe("memory toolkit surface", () => {
it("exposes exactly the memory and drive tools", () => {
expect(Object.keys(MemoryToolkit.tools).sort()).toEqual([
"drive_write_artifact",
"memory_append_daily",
"memory_read_daily",
"memory_search",
]);
});

/**
* The anti-spoofing guarantee is structural rather than validated at runtime:
* provenance is taken from the MCP invocation scope, which the server issues.
* If these ever become tool parameters, a model could attribute an
* observation to another project and quietly poison recall there -- so the
* absence of the parameters is the thing worth asserting.
*/
it("takes no provenance parameters a model could set", () => {
expect(parameterKeys(MemoryAppendDailyTool)).toEqual(["body"]);
for (const forbidden of ["projectSegment", "threadId", "capturedAt", "repositoryPath"]) {
expect(parameterKeys(MemoryAppendDailyTool)).not.toContain(forbidden);
}
});

it("marks the read-only tools readonly and idempotent, and capture neither", () => {
// Annotations drive how clients present and retry a tool. Capture is the
// only one of the three that mutates anything.
for (const tool of [MemoryReadDailyTool, MemorySearchTool]) {
expect(Context.get(tool.annotations, Tool.Readonly)).toBe(true);
expect(Context.get(tool.annotations, Tool.Idempotent)).toBe(true);
}
expect(Context.get(MemoryAppendDailyTool.annotations, Tool.Readonly)).toBe(false);
});

it("keeps search filters optional so an unfiltered search is valid", () => {
expect([...parameterKeys(MemorySearchTool)].sort()).toEqual(["limit", "scope", "tag"]);
});

it("lets no tool choose the project bucket it writes into", () => {
// Same anti-spoofing property as capture: a model that picks the folder can
// write into another project's drive.
expect([...parameterKeys(DriveWriteArtifactTool)].sort()).toEqual([
"contents",
"kind",
"relativePath",
]);
expect(parameterKeys(DriveWriteArtifactTool)).not.toContain("projectSegment");
});

it("marks the artifact write as mutating but not destructive", () => {
// It creates a new file; it does not overwrite a live path, because the
// partial unique index rejects that until the old row is archived.
expect(Context.get(DriveWriteArtifactTool.annotations, Tool.Readonly)).toBe(false);
expect(Context.get(DriveWriteArtifactTool.annotations, Tool.Destructive)).toBe(false);
});
});
Loading
Loading