From 8010c22fb962f315c847b692ab14d26d32a1d85b Mon Sep 17 00:00:00 2001 From: DJJones66 Date: Fri, 1 May 2026 11:03:36 -0400 Subject: [PATCH] Add automatic memory update prompting --- .../client_web/src/api/gateway-adapter.ts | 32 +- builds/typescript/client_web/src/api/types.ts | 11 + .../src/components/layout/AppShell.tsx | 80 +- builds/typescript/config.ts | 7 +- .../gateway/auth-routes.integration.test.ts | 89 +- builds/typescript/gateway/server.ts | 226 +++- builds/typescript/memory/init.ts | 6 +- .../memory/update-prompting.test.ts | 205 +++ builds/typescript/memory/update-prompting.ts | 1191 +++++++++++++++++ 9 files changed, 1790 insertions(+), 57 deletions(-) create mode 100644 builds/typescript/memory/update-prompting.test.ts create mode 100644 builds/typescript/memory/update-prompting.ts diff --git a/builds/typescript/client_web/src/api/gateway-adapter.ts b/builds/typescript/client_web/src/api/gateway-adapter.ts index a188438..8334a07 100644 --- a/builds/typescript/client_web/src/api/gateway-adapter.ts +++ b/builds/typescript/client_web/src/api/gateway-adapter.ts @@ -17,6 +17,7 @@ import { type GatewayMemoryBackupRunResponse, type GatewayMemoryBackupSettingsUpdateRequest, type GatewayMigrationImportResult, + type GatewayMemoryUpdateStatus, type GatewayModelCatalog, type GatewayOnboardingStatus, type GatewaySkillBinding, @@ -626,8 +627,35 @@ export async function restoreMemoryBackup( return (await response.json()) as GatewayMemoryBackupRestoreResponse; } - -export async function getOwnerProfile(): Promise { + +export async function getMemoryUpdateStatus(): Promise { + const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/updates/memory/status`, { + headers: withLocalOwnerHeaders(), + }); + + if (!response.ok) { + throw await toGatewayError(response); + } + + return (await response.json()) as GatewayMemoryUpdateStatus; +} + +export async function getMemoryUpdateReport(migrationId: string): Promise { + const response = await authenticatedFetch( + `${GATEWAY_BASE_URL}/updates/memory/reports/${encodeURIComponent(migrationId)}`, + { + headers: withLocalOwnerHeaders(), + } + ); + + if (!response.ok) { + throw await toGatewayError(response); + } + + return response.text(); +} + +export async function getOwnerProfile(): Promise { const response = await authenticatedFetch(`${GATEWAY_BASE_URL}/profile`, { headers: withLocalOwnerHeaders(), }); diff --git a/builds/typescript/client_web/src/api/types.ts b/builds/typescript/client_web/src/api/types.ts index 060d149..6adef3b 100644 --- a/builds/typescript/client_web/src/api/types.ts +++ b/builds/typescript/client_web/src/api/types.ts @@ -291,6 +291,17 @@ export type GatewayMigrationImportResult = { logout_required?: boolean; }; +export type GatewayMemoryUpdateStatus = { + current_app_version: string; + memory_pack_version: string; + target_memory_pack_version: string; + pending: boolean; + migration_id: string; + report_path: string | null; + applied_paths: string[]; + deferred_paths: string[]; +}; + export class GatewayError extends Error { readonly status: number; readonly code?: string; diff --git a/builds/typescript/client_web/src/components/layout/AppShell.tsx b/builds/typescript/client_web/src/components/layout/AppShell.tsx index dc64763..5cd4a8a 100644 --- a/builds/typescript/client_web/src/components/layout/AppShell.tsx +++ b/builds/typescript/client_web/src/components/layout/AppShell.tsx @@ -1,8 +1,8 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; -import { Menu } from "lucide-react"; +import { CheckCircle2, Menu, X } from "lucide-react"; import { createPortal } from "react-dom"; -import { getOnboardingStatus } from "@/api/gateway-adapter"; +import { getMemoryUpdateReport, getMemoryUpdateStatus, getOnboardingStatus } from "@/api/gateway-adapter"; import ChatPanel from "@/components/chat/ChatPanel"; import DocumentView from "@/components/document/DocumentView"; import SettingsModal from "@/components/settings/SettingsModal"; @@ -32,6 +32,12 @@ export default function AppShell({ const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [activeFile, setActiveFile] = useState(null); + const [memoryUpdateNotice, setMemoryUpdateNotice] = useState<{ + migrationId: string; + report: string; + hasDeferred: boolean; + } | null>(null); + const [isMemoryUpdateReportOpen, setIsMemoryUpdateReportOpen] = useState(false); const [mobileHeaderHeight, setMobileHeaderHeight] = useState(0); const stableAppHeightRef = useRef(0); const mobileHeaderRef = useRef(null); @@ -96,6 +102,33 @@ export default function AppShell({ setActiveFile(null); }, [selectedProjectId]); + useEffect(() => { + let cancelled = false; + void getMemoryUpdateStatus() + .then(async (status) => { + if (!status.report_path || cancelled) { + return; + } + const storageKey = `braindrive.memoryUpdateReportSeen.${status.migration_id}`; + if (window.localStorage.getItem(storageKey) === "1") { + return; + } + const report = await getMemoryUpdateReport(status.migration_id); + if (cancelled) { + return; + } + setMemoryUpdateNotice({ + migrationId: status.migration_id, + report, + hasDeferred: status.deferred_paths.length > 0, + }); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { if (!mobileHeaderRef.current) { return; @@ -179,6 +212,14 @@ export default function AppShell({ setActiveFile(null); } + function dismissMemoryUpdateNotice() { + if (memoryUpdateNotice) { + window.localStorage.setItem(`braindrive.memoryUpdateReportSeen.${memoryUpdateNotice.migrationId}`, "1"); + } + setMemoryUpdateNotice(null); + setIsMemoryUpdateReportOpen(false); + } + const documentContent = activeFile && selectedProject ? ( + {memoryUpdateNotice ? ( +
+
+ +
+

BrainDrive is up to date.

+

+ {memoryUpdateNotice.hasDeferred + ? "Safe memory updates were applied. One item was left unchanged because it has custom content." + : "Memory instructions were updated so the latest features work correctly."} +

+ + {isMemoryUpdateReportOpen ? ( +
+                    {memoryUpdateNotice.report}
+                  
+ ) : null} +
+ +
+
+ ) : null}
diff --git a/builds/typescript/config.ts b/builds/typescript/config.ts index 5497c69..5ccd2f4 100644 --- a/builds/typescript/config.ts +++ b/builds/typescript/config.ts @@ -353,9 +353,10 @@ function applyAdapterEnvironmentOverrides(config: AdapterConfig): AdapterConfig } export async function ensureMemoryLayout(rootDir: string, memoryRoot: string): Promise { - const summary = await initializeMemoryLayout(rootDir, memoryRoot, { - seedDefaultProjects: true, - }); + const summary = await initializeMemoryLayout(rootDir, memoryRoot, { + seedDefaultProjects: true, + seedStarterSkills: false, + }); auditLog("memory.init", { memory_root: memoryRoot, profile: summary.profile, diff --git a/builds/typescript/gateway/auth-routes.integration.test.ts b/builds/typescript/gateway/auth-routes.integration.test.ts index 2eae5b9..0434715 100644 --- a/builds/typescript/gateway/auth-routes.integration.test.ts +++ b/builds/typescript/gateway/auth-routes.integration.test.ts @@ -90,9 +90,14 @@ vi.mock("../tools.js", () => ({ discoverTools: vi.fn(async () => []), })); -vi.mock("../git.js", () => ({ - ensureGitReady: vi.fn(async () => {}), -})); +vi.mock("../git.js", () => ({ + ensureGitReady: vi.fn(async () => {}), + commitMemoryChange: vi.fn(async () => {}), + exportMemoryArchive: vi.fn(async (_memoryRoot: string, destinationPath: string) => { + await mkdir(path.dirname(destinationPath), { recursive: true }); + await writeFile(destinationPath, "backup", "utf8"); + }), +})); vi.mock("../secrets/resolver.js", () => ({ resolveProviderCredentialForStartup: vi.fn(async () => null), @@ -126,6 +131,7 @@ async function createTestServer( deploymentMode?: "managed" | "local"; managedApiBase?: string; allowManagedPublicAccountProxyRoutes?: boolean; + starterPack?: boolean; } = {} ): Promise { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "paa-auth-int-")); @@ -133,8 +139,11 @@ async function createTestServer( const preferencesRoot = path.join(memoryRoot, "preferences"); const secretsRoot = path.join(tempRoot, "secrets"); - await mkdir(preferencesRoot, { recursive: true }); - await mkdir(secretsRoot, { recursive: true }); + await mkdir(preferencesRoot, { recursive: true }); + await mkdir(secretsRoot, { recursive: true }); + if (options.starterPack) { + await writeTestStarterPack(tempRoot); + } mockRuntimeConfig = { memory_root: memoryRoot, @@ -160,8 +169,12 @@ async function createTestServer( const previousDeploymentMode = process.env.BD_DEPLOYMENT_MODE; const previousManagedApiBase = process.env.BD_MANAGED_API_BASE; const previousManagedPublicAccountProxyRoutes = process.env.PAA_MANAGED_PUBLIC_ACCOUNT_PROXY_ROUTES; + const previousMemoryAutoUpdateEnabled = process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED; + const previousAppVersion = process.env.BRAINDRIVE_APP_VERSION; process.env.PAA_SECRETS_HOME = secretsRoot; + process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED = "false"; + process.env.BRAINDRIVE_APP_VERSION = "26.4.20"; if (typeof options.bootstrapToken === "string") { process.env.PAA_AUTH_BOOTSTRAP_TOKEN = options.bootstrapToken; } else { @@ -217,6 +230,16 @@ async function createTestServer( } else { delete process.env.PAA_MANAGED_PUBLIC_ACCOUNT_PROXY_ROUTES; } + if (typeof previousMemoryAutoUpdateEnabled === "string") { + process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED = previousMemoryAutoUpdateEnabled; + } else { + delete process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED; + } + if (typeof previousAppVersion === "string") { + process.env.BRAINDRIVE_APP_VERSION = previousAppVersion; + } else { + delete process.env.BRAINDRIVE_APP_VERSION; + } }, }; } @@ -250,6 +273,13 @@ function localOwnerAdminHeaders(): Record { }), }; } + +async function writeTestStarterPack(rootDir: string): Promise { + const starterRoot = path.join(rootDir, "memory", "starter-pack"); + await mkdir(path.join(starterRoot, "base", "me"), { recursive: true }); + await writeFile(path.join(starterRoot, "base", "AGENT.md"), "# BrainDrive Agent\n\nUse current guidance.\n", "utf8"); + await writeFile(path.join(starterRoot, "base", "me", "todo.md"), "# My Todos\n\n## Active\n", "utf8"); +} describe.sequential("gateway auth route integration", () => { let context: TestServerContext | null = null; @@ -507,6 +537,55 @@ describe.sequential("gateway auth route integration", () => { expect(parseJson<{ error: string }>(response.body).error).toBe("support_bundle_requires_local_jwt_auth"); }); + it("exposes memory update status, apply, and report endpoints", async () => { + context = await createTestServer({ authMode: "local-owner", starterPack: true }); + const memoryRoot = path.join(context.tempRoot, "memory"); + await mkdir(memoryRoot, { recursive: true }); + await writeFile(path.join(memoryRoot, "AGENT.md"), "# Custom Agent\n\nKeep this.\n", "utf8"); + + const statusResponse = await context.app.inject({ + method: "GET", + url: "/updates/memory/status", + headers: localOwnerAdminHeaders(), + }); + expect(statusResponse.statusCode).toBe(200); + const status = parseJson<{ pending: boolean; migration_id: string }>(statusResponse.body); + expect(status.pending).toBe(true); + expect(status.migration_id).toBe("starter-pack-26.4.20"); + + const planResponse = await context.app.inject({ + method: "POST", + url: "/updates/memory/plan", + headers: localOwnerAdminHeaders(), + payload: {}, + }); + expect(planResponse.statusCode).toBe(200); + const plan = parseJson<{ items: Array<{ path: string; action: string }> }>(planResponse.body); + expect(plan.items.some((item) => item.path === "AGENT.md" && item.action === "defer")).toBe(true); + + const applyResponse = await context.app.inject({ + method: "POST", + url: "/updates/memory/apply", + headers: localOwnerAdminHeaders(), + payload: {}, + }); + expect(applyResponse.statusCode).toBe(201); + const applied = parseJson<{ status: string; applied_paths: string[]; deferred_paths: string[]; report_path: string }>( + applyResponse.body + ); + expect(applied.status).toBe("partially_applied"); + expect(applied.applied_paths).toContain("me/todo.md"); + expect(applied.deferred_paths).toContain("AGENT.md"); + + const reportResponse = await context.app.inject({ + method: "GET", + url: `/updates/memory/reports/${status.migration_id}`, + headers: localOwnerAdminHeaders(), + }); + expect(reportResponse.statusCode).toBe(200); + expect(reportResponse.body).toContain("BrainDrive Memory Update 26.4.20"); + }); + it("rejects unauthenticated memory backup settings updates", async () => { context = await createTestServer(); diff --git a/builds/typescript/gateway/server.ts b/builds/typescript/gateway/server.ts index 1c0bec1..d05e0cc 100644 --- a/builds/typescript/gateway/server.ts +++ b/builds/typescript/gateway/server.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { createGatewayAdapter } from "../adapters/gateway.js"; import { createModelAdapter, resolveAdapterConfigForPreferences } from "../adapters/index.js"; -import type { ProviderModel } from "../adapters/base.js"; +import type { ModelAdapter, ProviderModel } from "../adapters/base.js"; import { authorize, authorizeApprovalDecision } from "../auth/authorize.js"; import { authMiddleware } from "../auth/middleware.js"; import { @@ -51,7 +51,14 @@ import type { ConversationRepository } from "../memory/conversation-repository.j import { MarkdownConversationStore } from "../memory/conversation-store-markdown.js"; import { exportMemory } from "../memory/export.js"; import { restoreMemoryBackup } from "../memory/backup-restore.js"; -import { importMigrationArchive } from "../memory/migration.js"; +import { importMigrationArchive } from "../memory/migration.js"; +import { + applyMemoryUpdatePlan, + generateMemoryUpdatePlan, + getMemoryUpdateStatus, + readMemoryUpdateReport, + runAutomaticMemoryUpdate, +} from "../memory/update-prompting.js"; import { createSupportBundle, listSupportBundles, @@ -327,23 +334,24 @@ export async function buildServer(rootDir = process.cwd()) { livePreferencesCache = nextPreferences; }; let authState = await ensureAuthState(runtimeConfig.memory_root, { mode: runtimeConfig.auth_mode }); - const systemPrompt = await readBootstrapPrompt(runtimeConfig.memory_root); - auditLog("startup.phase", { phase: "secrets" }); - const startupAdapterConfig = resolveAdapterConfigForPreferences(adapterConfig, preferences); - try { - const resolvedProviderCredential = await resolveProviderCredentialForStartup( - runtimeConfig.provider_adapter, - startupAdapterConfig, - preferences - ); - if (resolvedProviderCredential) { - auditLog("secret.resolve", { - provider_id: resolvedProviderCredential.providerId, - provider_profile: preferences.active_provider_profile ?? adapterConfig.default_provider_profile, - source: resolvedProviderCredential.source, - secret_ref: resolvedProviderCredential.secretRef, - }); - } + let systemPrompt = await readBootstrapPrompt(runtimeConfig.memory_root); + auditLog("startup.phase", { phase: "secrets" }); + const startupAdapterConfig = resolveAdapterConfigForPreferences(adapterConfig, preferences); + let startupResolvedProviderCredential: Awaited> | undefined; + try { + startupResolvedProviderCredential = await resolveProviderCredentialForStartup( + runtimeConfig.provider_adapter, + startupAdapterConfig, + preferences + ); + if (startupResolvedProviderCredential) { + auditLog("secret.resolve", { + provider_id: startupResolvedProviderCredential.providerId, + provider_profile: preferences.active_provider_profile ?? adapterConfig.default_provider_profile, + source: startupResolvedProviderCredential.source, + secret_ref: startupResolvedProviderCredential.secretRef, + }); + } } catch (error) { auditLog("secret.resolve_deferred", { provider_id: startupAdapterConfig.provider_id ?? "unknown", @@ -372,7 +380,7 @@ export async function buildServer(rootDir = process.cwd()) { const conversations = new GatewayConversationService(createConversationRepository(runtimeConfig)); const projects = new GatewayProjectService(runtimeConfig.memory_root, { rootDir }); const skills = new GatewaySkillService(runtimeConfig.memory_root); - const signupRateLimiter = new FixedWindowRateLimiter(5, 5 * 60 * 1000); + const signupRateLimiter = new FixedWindowRateLimiter(5, 5 * 60 * 1000); const loginRateLimiter = new FixedWindowRateLimiter(10, 5 * 60 * 1000); const refreshRateLimiter = new FixedWindowRateLimiter(30, 5 * 60 * 1000); const signupBootstrapToken = process.env.PAA_AUTH_BOOTSTRAP_TOKEN?.trim(); @@ -388,8 +396,41 @@ export async function buildServer(rootDir = process.cwd()) { persistAuthState, }) : null; - let migrationInProgress = false; - const memoryBackupScheduler = createMemoryBackupScheduler({ + let migrationInProgress = false; + const memoryUpdateAutoEnabled = readBooleanEnv(process.env.PAA_MEMORY_AUTO_UPDATE_ENABLED, true); + if (memoryUpdateAutoEnabled) { + migrationInProgress = true; + try { + const memoryUpdateAdapter = createMemoryUpdateAdapter( + runtimeConfig, + adapterConfig, + preferences, + startupAdapterConfig, + startupResolvedProviderCredential?.apiKey + ); + const memoryUpdateResult = await runAutomaticMemoryUpdate(rootDir, runtimeConfig.memory_root, appVersion, { + adapter: memoryUpdateAdapter, + }); + if (memoryUpdateResult?.applied_paths.includes("AGENT.md")) { + systemPrompt = await readBootstrapPrompt(runtimeConfig.memory_root); + } + if (memoryUpdateResult) { + auditLog("memory_update.startup_completed", { + migration_id: memoryUpdateResult.migration_id, + status: memoryUpdateResult.status, + applied_count: memoryUpdateResult.applied_paths.length, + deferred_count: memoryUpdateResult.deferred_paths.length, + }); + } + } catch (error) { + auditLog("memory_update.startup_failed", { + message: error instanceof Error ? error.message : String(error), + }); + } finally { + migrationInProgress = false; + } + } + const memoryBackupScheduler = createMemoryBackupScheduler({ memoryRoot: runtimeConfig.memory_root, isMigrationInProgress: () => migrationInProgress, }); @@ -588,12 +629,16 @@ export async function buildServer(rootDir = process.cwd()) { return; } - if (requestPath.startsWith("/migration")) { - return; - } - - reply.code(423).send({ error: "migration_in_progress" }); - }); + if (requestPath.startsWith("/migration")) { + return; + } + + if (requestPath.startsWith("/updates/memory")) { + return; + } + + reply.code(423).send({ error: "migration_in_progress" }); + }); app.post("/message", async (request, reply) => { const normalizedRequest = gatewayAdapter.normalizeMessageRequest(request.body, request.headers["x-conversation-id"]); @@ -905,13 +950,81 @@ export async function buildServer(rootDir = process.cwd()) { features: { approvals: true, projects: true, - export: true, - import: true, - migration: true, - }, - })); - - app.get("/session", async (request) => ({ + export: true, + import: true, + migration: true, + memory_updates: true, + }, + })); + + app.get("/updates/memory/status", async (request) => { + authorize(request.authContext, "administration"); + authorize(request.authContext, "memory_access"); + return getMemoryUpdateStatus(rootDir, runtimeConfig.memory_root, appVersion); + }); + + app.post("/updates/memory/plan", async (request) => { + authorize(request.authContext, "administration"); + authorize(request.authContext, "memory_access"); + const currentPreferences = await loadLivePreferences(); + const selectedAdapterConfig = resolveAdapterConfigForPreferences(adapterConfig, currentPreferences); + let resolvedCredential: Awaited> | undefined; + try { + resolvedCredential = await resolveProviderCredentialForStartup( + runtimeConfig.provider_adapter, + selectedAdapterConfig, + currentPreferences + ); + } catch { + resolvedCredential = undefined; + } + const memoryUpdateAdapter = createMemoryUpdateAdapter( + runtimeConfig, + adapterConfig, + currentPreferences, + selectedAdapterConfig, + resolvedCredential?.apiKey + ); + return generateMemoryUpdatePlan(rootDir, runtimeConfig.memory_root, appVersion, { + adapter: memoryUpdateAdapter, + }); + }); + + app.post("/updates/memory/apply", async (request, reply) => { + authorize(request.authContext, "administration"); + authorize(request.authContext, "memory_access"); + + if (migrationInProgress) { + reply.code(409).send({ error: "migration_in_progress" }); + return; + } + + migrationInProgress = true; + try { + let result = await applyMemoryUpdatePlan(rootDir, runtimeConfig.memory_root, appVersion); + if (result.applied_paths.includes("AGENT.md")) { + systemPrompt = await readBootstrapPrompt(runtimeConfig.memory_root); + } + reply.code(201).send(result); + } finally { + migrationInProgress = false; + } + }); + + app.get("/updates/memory/reports/:migrationId", async (request, reply) => { + authorize(request.authContext, "administration"); + authorize(request.authContext, "memory_access"); + const params = request.params as { migrationId: string }; + const report = await readMemoryUpdateReport(runtimeConfig.memory_root, params.migrationId); + if (!report) { + reply.code(404).send({ error: "Report not found" }); + return; + } + reply.header("content-type", "text/markdown; charset=utf-8"); + reply.send(report); + }); + + app.get("/session", async (request) => ({ mode: isManaged ? "managed" : "local", user: { id: request.authContext.actorId, @@ -2588,10 +2701,10 @@ function listProviderProfiles(adapterConfig: AdapterConfig): Array<{ ]; } -function resolveAdapterProfile( - adapterConfig: AdapterConfig, - profileId: string -): { +function resolveAdapterProfile( + adapterConfig: AdapterConfig, + profileId: string +): { base_url: string; model: string; api_key_env: string; @@ -2606,11 +2719,36 @@ function resolveAdapterProfile( base_url: adapterConfig.base_url, model: adapterConfig.model, api_key_env: adapterConfig.api_key_env, - provider_id: adapterConfig.provider_id, - }; -} - -function sanitizeCredentialResolutionError(error: unknown): string { + provider_id: adapterConfig.provider_id, + }; +} + +function createMemoryUpdateAdapter( + runtimeConfig: RuntimeConfig, + adapterConfig: AdapterConfig, + preferences: Preferences, + selectedAdapterConfig: AdapterConfig, + apiKey?: string +): ModelAdapter | undefined { + const envApiKey = process.env[selectedAdapterConfig.api_key_env]?.trim(); + const runtimeApiKey = apiKey?.trim() || envApiKey || undefined; + if (!runtimeApiKey) { + return undefined; + } + + try { + return createModelAdapter(runtimeConfig.provider_adapter, adapterConfig, preferences, { + apiKey: runtimeApiKey, + }); + } catch (error) { + auditLog("memory_update.adapter_unavailable", { + message: error instanceof Error ? error.message : String(error), + }); + return undefined; + } +} + +function sanitizeCredentialResolutionError(error: unknown): string { const message = error instanceof Error ? error.message.toLowerCase() : ""; if (message.includes("not initialized")) { return "Secret vault key is not initialized"; diff --git a/builds/typescript/memory/init.ts b/builds/typescript/memory/init.ts index 823bb56..16b62f7 100644 --- a/builds/typescript/memory/init.ts +++ b/builds/typescript/memory/init.ts @@ -9,6 +9,7 @@ export type MemoryInitProfile = "local-dev" | "openrouter-secret-ref" | "braindr export type MemoryInitOptions = { profile?: MemoryInitProfile; seedDefaultProjects?: boolean; + seedStarterSkills?: boolean; force?: boolean; dryRun?: boolean; }; @@ -120,6 +121,7 @@ export async function initializeMemoryLayout( const force = options.force ?? false; const dryRun = options.dryRun ?? false; const seedDefaultProjects = options.seedDefaultProjects ?? true; + const seedStarterSkills = options.seedStarterSkills ?? true; const absoluteMemoryRoot = path.resolve(memoryRoot); const starterPackDir = await resolveStarterPackDir(rootDir); @@ -191,7 +193,9 @@ export async function initializeMemoryLayout( summary ); - if (dryRun) { + if (!seedStarterSkills) { + summary.skipped.push("skills/bootstrap (disabled)"); + } else if (dryRun) { summary.skipped.push("skills/bootstrap (dry-run)"); } else { const skillStore = new MemorySkillStore(absoluteMemoryRoot); diff --git a/builds/typescript/memory/update-prompting.test.ts b/builds/typescript/memory/update-prompting.test.ts new file mode 100644 index 0000000..9cd9888 --- /dev/null +++ b/builds/typescript/memory/update-prompting.test.ts @@ -0,0 +1,205 @@ +import path from "node:path"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; + +import { describe, expect, it } from "vitest"; + +import type { ModelAdapter, ModelResponse } from "../adapters/base.js"; +import type { GatewayEngineRequest, ToolDefinition } from "../contracts.js"; +import { initializeMemoryLayout } from "./init.js"; +import { + generateMemoryUpdatePlan, + getMemoryUpdateStatus, + readMemoryUpdateReport, + runAutomaticMemoryUpdate, +} from "./update-prompting.js"; + +class StaticJsonAdapter implements ModelAdapter { + constructor(private readonly payload: unknown) {} + + async complete(_request: GatewayEngineRequest, _tools: ToolDefinition[]): Promise { + return { + assistantText: JSON.stringify(this.payload), + toolCalls: [], + finishReason: "stop", + }; + } +} + +async function writeStarterPack(rootDir: string): Promise { + const starterRoot = path.join(rootDir, "memory", "starter-pack"); + await mkdir(path.join(starterRoot, "base", "me"), { recursive: true }); + await mkdir(path.join(starterRoot, "skills"), { recursive: true }); + await writeFile(path.join(starterRoot, "base", "AGENT.md"), "# BrainDrive Agent\n\nUse the latest guidance.\n", "utf8"); + await writeFile(path.join(starterRoot, "base", "me", "todo.md"), "# My Todos\n\n## Active\n", "utf8"); + await writeFile(path.join(starterRoot, "skills", "focus.md"), "# Focus\n\nHelp the owner focus.\n", "utf8"); +} + +describe("memory update prompting", () => { + it("auto-creates missing starter files and defers customized existing files without an LLM", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "memory-update-test-")); + const rootDir = path.join(tempRoot, "repo"); + const memoryRoot = path.join(tempRoot, "memory"); + + try { + await writeStarterPack(rootDir); + await mkdir(memoryRoot, { recursive: true }); + await writeFile(path.join(memoryRoot, "AGENT.md"), "# Owner Custom Agent\n\nKeep my custom guidance.\n", "utf8"); + + const result = await runAutomaticMemoryUpdate(rootDir, memoryRoot, "26.5.1"); + + expect(result).not.toBeNull(); + expect(result?.status).toBe("partially_applied"); + expect(result?.applied_paths).toContain("me/todo.md"); + expect(result?.applied_paths).toContain("skills/focus/SKILL.md"); + expect(result?.deferred_paths).toContain("AGENT.md"); + + await expect(readFile(path.join(memoryRoot, "me", "todo.md"), "utf8")).resolves.toContain("# My Todos"); + await expect(readFile(path.join(memoryRoot, "skills", "focus", "SKILL.md"), "utf8")).resolves.toContain("# Focus"); + await expect(readFile(path.join(memoryRoot, "AGENT.md"), "utf8")).resolves.toContain("Owner Custom Agent"); + + const report = await readMemoryUpdateReport(memoryRoot, "starter-pack-26.5.1"); + expect(report).toContain("BrainDrive Memory Update 26.5.1"); + expect(report).toContain("AGENT.md has custom content"); + expect(result?.backup_path).toBe("system/updates/backups/starter-pack-26.5.1.tar.gz"); + + const status = await getMemoryUpdateStatus(rootDir, memoryRoot, "26.5.1"); + expect(status.pending).toBe(false); + expect(status.memory_pack_version).toBe("26.5.1"); + expect(status.deferred_paths).toContain("AGENT.md"); + + const repeatResult = await runAutomaticMemoryUpdate(rootDir, memoryRoot, "26.5.1"); + expect(repeatResult).toBeNull(); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("owns starter skill creation when startup layout skips legacy skill bootstrap", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "memory-update-startup-seed-test-")); + const rootDir = path.join(tempRoot, "repo"); + const memoryRoot = path.join(tempRoot, "memory"); + + try { + await writeStarterPack(rootDir); + await initializeMemoryLayout(rootDir, memoryRoot, { + seedDefaultProjects: false, + seedStarterSkills: false, + }); + + await expect(readFile(path.join(memoryRoot, "skills", "focus", "SKILL.md"), "utf8")).rejects.toThrow(); + + const result = await runAutomaticMemoryUpdate(rootDir, memoryRoot, "26.5.4"); + + expect(result?.status).toBe("applied"); + expect(result?.applied_paths).toContain("skills/focus/SKILL.md"); + await expect(readFile(path.join(memoryRoot, "skills", "focus", "SKILL.md"), "utf8")).resolves.toContain("# Focus"); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("applies an LLM-generated merge for customized AGENT.md", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "memory-update-llm-test-")); + const rootDir = path.join(tempRoot, "repo"); + const memoryRoot = path.join(tempRoot, "memory"); + + try { + await writeStarterPack(rootDir); + await mkdir(memoryRoot, { recursive: true }); + await writeFile(path.join(memoryRoot, "AGENT.md"), "# Owner Custom Agent\n\nKeep my custom guidance.\n", "utf8"); + + const adapter = new StaticJsonAdapter({ + schema_version: 1, + migration_id: "starter-pack-26.5.2", + from_memory_pack_version: "unknown", + to_memory_pack_version: "26.5.2", + summary: "Merged the latest BrainDrive guidance.", + auto_apply: true, + owner_report: "BrainDrive updated your core memory instructions and preserved your custom guidance.", + items: [ + { + path: "AGENT.md", + action: "merge", + confidence: "high", + owner_summary: "Updated the main agent instructions while keeping your custom guidance.", + risk: "medium", + auto_apply: true, + replacement_content: "# Owner Custom Agent\n\nKeep my custom guidance.\n\nUse the latest guidance.\n", + }, + ], + }); + + const plan = await generateMemoryUpdatePlan(rootDir, memoryRoot, "26.5.2", { adapter }); + expect(plan.items.find((item) => item.path === "AGENT.md")?.action).toBe("merge"); + + const result = await runAutomaticMemoryUpdate(rootDir, memoryRoot, "26.5.2", { adapter }); + + expect(result?.status).toBe("applied"); + expect(result?.applied_paths).toContain("AGENT.md"); + await expect(readFile(path.join(memoryRoot, "AGENT.md"), "utf8")).resolves.toContain("Use the latest guidance."); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("normalizes incomplete LLM plans without aborting startup updates", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "memory-update-incomplete-llm-test-")); + const rootDir = path.join(tempRoot, "repo"); + const memoryRoot = path.join(tempRoot, "memory"); + + try { + await writeStarterPack(rootDir); + await mkdir(memoryRoot, { recursive: true }); + + const adapter = new StaticJsonAdapter({ + schema_version: 1, + to_memory_pack_version: "26.5.5", + summary: "Created missing starter content.", + auto_apply: true, + owner_report: "BrainDrive added new starter content.", + items: [ + { + path: "skills/focus/SKILL.md", + action: "create", + confidence: "high", + owner_summary: "Added the Focus skill.", + risk: "low", + auto_apply: true, + replacement_content: "# Focus\n\nHelp the owner focus.\n", + }, + ], + }); + + const result = await runAutomaticMemoryUpdate(rootDir, memoryRoot, "26.5.5", { adapter }); + + expect(result?.migration_id).toBe("starter-pack-26.5.5"); + expect(result?.applied_paths).toContain("skills/focus/SKILL.md"); + await expect(readFile(path.join(memoryRoot, "skills", "focus", "SKILL.md"), "utf8")).resolves.toContain("# Focus"); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + it("reports no pending update when memory already matches the starter pack", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "memory-update-current-test-")); + const rootDir = path.join(tempRoot, "repo"); + const memoryRoot = path.join(tempRoot, "memory"); + + try { + await writeStarterPack(rootDir); + await mkdir(path.join(memoryRoot, "me"), { recursive: true }); + await mkdir(path.join(memoryRoot, "skills", "focus"), { recursive: true }); + await writeFile(path.join(memoryRoot, "AGENT.md"), "# BrainDrive Agent\n\nUse the latest guidance.\n", "utf8"); + await writeFile(path.join(memoryRoot, "me", "todo.md"), "# My Todos\n\n## Active\n", "utf8"); + await writeFile(path.join(memoryRoot, "skills", "focus", "SKILL.md"), "# Focus\n\nHelp the owner focus.\n", "utf8"); + + const status = await getMemoryUpdateStatus(rootDir, memoryRoot, "26.5.3"); + + expect(status.pending).toBe(false); + expect(status.memory_pack_version).toBe("26.5.3"); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/builds/typescript/memory/update-prompting.ts b/builds/typescript/memory/update-prompting.ts new file mode 100644 index 0000000..3f81b1f --- /dev/null +++ b/builds/typescript/memory/update-prompting.ts @@ -0,0 +1,1191 @@ +import { createHash } from "node:crypto"; +import path from "node:path"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; + +import type { ModelAdapter } from "../adapters/base.js"; +import { auditLog } from "../logger.js"; +import { commitMemoryChange, exportMemoryArchive } from "../git.js"; +import { resolveMemoryPath } from "./paths.js"; +import { MemorySkillStore, slugifySkillName } from "./skills.js"; + +export type StarterPackManifestFileKind = + | "agent_prompt" + | "user_memory_template" + | "starter_skill"; + +export type StarterPackMergePolicy = + | "llm_merge" + | "create_if_missing_else_llm_merge" + | "create_if_missing_else_defer"; + +export type StarterPackManifestFile = { + path: string; + source_path: string; + kind: StarterPackManifestFileKind; + merge_policy: StarterPackMergePolicy; + sha256: string; +}; + +export type StarterPackManifest = { + schema_version: 1; + memory_pack_version: string; + app_version: string; + generated_at: string; + source: string; + files: StarterPackManifestFile[]; +}; + +export type MemoryUpdateState = { + schema_version: 1; + memory_pack_version: string; + last_checked_app_version: string; + last_completed_migration_id: string | null; + pending_migration_id: string | null; + last_reported_at: string | null; + updated_at: string; +}; + +export type MemoryUpdateMigrationStatus = + | "pending" + | "planned" + | "partially_applied" + | "applied" + | "deferred" + | "failed"; + +export type MemoryUpdateMigrationLogItem = { + migration_id: string; + from_memory_pack_version: string; + to_memory_pack_version: string; + status: MemoryUpdateMigrationStatus; + applied_paths: string[]; + deferred_paths: string[]; + report_path: string | null; + applied_at: string | null; + backup_path: string | null; + error?: string; +}; + +export type MemoryUpdateMigrationLog = { + schema_version: 1; + items: MemoryUpdateMigrationLogItem[]; +}; + +export type MemoryUpdateStatus = { + current_app_version: string; + memory_pack_version: string; + target_memory_pack_version: string; + pending: boolean; + migration_id: string; + report_path: string | null; + applied_paths: string[]; + deferred_paths: string[]; +}; + +export type MemoryUpdatePlanAction = "create" | "merge" | "replace" | "no_change" | "defer"; +export type MemoryUpdateRisk = "low" | "medium" | "high"; + +export type MemoryUpdatePlanItem = { + path: string; + action: MemoryUpdatePlanAction; + confidence: "low" | "medium" | "high"; + owner_summary: string; + risk: MemoryUpdateRisk; + auto_apply: boolean; + replacement_content?: string; + rationale?: string; +}; + +export type MemoryUpdatePlan = { + schema_version: 1; + migration_id: string; + from_memory_pack_version: string; + to_memory_pack_version: string; + summary: string; + items: MemoryUpdatePlanItem[]; + auto_apply: boolean; + owner_report: string; + generated_at: string; +}; + +export type MemoryUpdateApplyResult = { + migration_id: string; + status: MemoryUpdateMigrationStatus; + applied_paths: string[]; + deferred_paths: string[]; + report_path: string; + backup_path: string | null; +}; + +type MemoryUpdateCandidate = { + manifestFile: StarterPackManifestFile; + sourceContent: string; + currentContent: string | null; + currentSha256: string | null; + exists: boolean; +}; + +type MemoryUpdatePaths = { + updatesDir: string; + statePath: string; + migrationsPath: string; + manifestPath: string; + plansDir: string; + reportsDir: string; + backupsDir: string; +}; + +const STARTER_PACK_ENV = "PAA_STARTER_PACK_DIR"; +const STARTER_PACK_RELATIVE_PATH = "memory/starter-pack"; +const STATE_RELATIVE_PATH = "system/updates/memory-state.json"; +const MIGRATIONS_RELATIVE_PATH = "system/updates/memory-migrations.json"; +const MANIFEST_RELATIVE_PATH = "system/updates/starter-pack-manifest.json"; +const PLAN_RELATIVE_DIR = "system/updates/plans"; +const REPORT_RELATIVE_DIR = "system/updates/reports"; +const BACKUP_RELATIVE_DIR = "system/updates/backups"; +const UNKNOWN_MEMORY_PACK_VERSION = "unknown"; + +export async function getMemoryUpdateStatus( + rootDir: string, + memoryRoot: string, + appVersion: string +): Promise { + const manifest = await generateStarterPackManifest(rootDir, appVersion); + const state = await ensureMemoryUpdateState(memoryRoot, manifest, rootDir); + const migrations = await readMigrations(memoryRoot); + const latest = findLatestMigration(migrations, migrationIdFor(manifest.memory_pack_version)); + const pending = await isMemoryUpdatePending(rootDir, memoryRoot, manifest, state, latest); + + return { + current_app_version: appVersion, + memory_pack_version: state.memory_pack_version, + target_memory_pack_version: manifest.memory_pack_version, + pending, + migration_id: migrationIdFor(manifest.memory_pack_version), + report_path: latest?.report_path ?? null, + applied_paths: latest?.applied_paths ?? [], + deferred_paths: latest?.deferred_paths ?? [], + }; +} + +export async function generateMemoryUpdatePlan( + rootDir: string, + memoryRoot: string, + appVersion: string, + options: { + adapter?: ModelAdapter; + changelogPath?: string; + } = {} +): Promise { + const manifest = await writeCurrentStarterPackManifest(rootDir, memoryRoot, appVersion); + const state = await ensureMemoryUpdateState(memoryRoot, manifest, rootDir); + const candidates = await buildCandidates(rootDir, memoryRoot, manifest); + const migrationId = migrationIdFor(manifest.memory_pack_version); + const changelogExcerpt = await readChangelogExcerpt(options.changelogPath ?? path.join(rootDir, "CHANGELOG.md")); + + let plan: MemoryUpdatePlan | null = null; + if (options.adapter) { + plan = await generateLlmPlan(options.adapter, { + migrationId, + fromMemoryPackVersion: state.memory_pack_version, + targetMemoryPackVersion: manifest.memory_pack_version, + changelogExcerpt, + candidates, + }); + } + + if (!plan) { + plan = generateDeterministicPlan({ + migrationId, + fromMemoryPackVersion: state.memory_pack_version, + targetMemoryPackVersion: manifest.memory_pack_version, + candidates, + }); + } + + const normalized = normalizePlan(plan, candidates, state.memory_pack_version, manifest.memory_pack_version); + await writeJson(resolveUpdatePaths(memoryRoot).plansDir, `${migrationId}.json`, normalized); + await appendMigrationLog(memoryRoot, { + migration_id: migrationId, + from_memory_pack_version: state.memory_pack_version, + to_memory_pack_version: manifest.memory_pack_version, + status: "planned", + applied_paths: [], + deferred_paths: normalized.items + .filter((item) => item.action === "defer") + .map((item) => item.path), + report_path: null, + applied_at: null, + backup_path: null, + }); + await writeMemoryUpdateState(memoryRoot, { + ...state, + last_checked_app_version: appVersion, + pending_migration_id: migrationId, + updated_at: new Date().toISOString(), + }); + + auditLog("memory_update.plan", { + migration_id: migrationId, + item_count: normalized.items.length, + auto_apply: normalized.auto_apply, + }); + return normalized; +} + +export async function applyMemoryUpdatePlan( + rootDir: string, + memoryRoot: string, + appVersion: string, + options: { + plan?: MemoryUpdatePlan; + } = {} +): Promise { + const manifest = await writeCurrentStarterPackManifest(rootDir, memoryRoot, appVersion); + const state = await ensureMemoryUpdateState(memoryRoot, manifest, rootDir); + const migrationId = migrationIdFor(manifest.memory_pack_version); + const plan = options.plan ?? (await readPlan(memoryRoot, migrationId)); + if (!plan) { + throw new Error(`Memory update plan not found: ${migrationId}`); + } + + const candidates = await buildCandidates(rootDir, memoryRoot, manifest); + const candidateByPath = new Map(candidates.map((candidate) => [candidate.manifestFile.path, candidate])); + const applicableItems = plan.items.filter((item) => isAutoApplicable(item, candidateByPath)); + const deferredPaths = plan.items + .filter((item) => item.action === "defer" || !isAutoApplicable(item, candidateByPath)) + .map((item) => item.path); + + const backupPath = applicableItems.length > 0 ? await createMemoryUpdateBackup(memoryRoot, migrationId) : null; + const appliedPaths: string[] = []; + + try { + for (const item of applicableItems) { + const candidate = candidateByPath.get(item.path); + if (!candidate) { + continue; + } + await assertCandidateUnchanged(memoryRoot, candidate); + const content = normalizeFileContent(item.replacement_content ?? candidate.sourceContent); + await applyPlanItem(memoryRoot, candidate, item, content); + if (item.action !== "no_change") { + appliedPaths.push(item.path); + } + } + + const status: MemoryUpdateMigrationStatus = + deferredPaths.length > 0 && appliedPaths.length > 0 + ? "partially_applied" + : deferredPaths.length > 0 + ? "deferred" + : "applied"; + const reportPath = await writeUpdateReport(memoryRoot, plan, { + status, + appliedPaths, + deferredPaths, + backupPath, + }); + + await appendMigrationLog(memoryRoot, { + migration_id: migrationId, + from_memory_pack_version: state.memory_pack_version, + to_memory_pack_version: manifest.memory_pack_version, + status, + applied_paths: appliedPaths, + deferred_paths: deferredPaths, + report_path: reportPath, + applied_at: new Date().toISOString(), + backup_path: backupPath, + }); + await writeMemoryUpdateState(memoryRoot, { + schema_version: 1, + memory_pack_version: manifest.memory_pack_version, + last_checked_app_version: appVersion, + last_completed_migration_id: migrationId, + pending_migration_id: null, + last_reported_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + await commitMemoryChange(memoryRoot, `Apply memory update ${migrationId}`).catch((error) => { + auditLog("memory_update.git_commit_failed", { + migration_id: migrationId, + message: error instanceof Error ? error.message : String(error), + }); + }); + + auditLog("memory_update.apply", { + migration_id: migrationId, + status, + applied_count: appliedPaths.length, + deferred_count: deferredPaths.length, + report_path: reportPath, + }); + + return { + migration_id: migrationId, + status, + applied_paths: appliedPaths, + deferred_paths: deferredPaths, + report_path: reportPath, + backup_path: backupPath, + }; + } catch (error) { + await appendMigrationLog(memoryRoot, { + migration_id: migrationId, + from_memory_pack_version: state.memory_pack_version, + to_memory_pack_version: manifest.memory_pack_version, + status: "failed", + applied_paths: appliedPaths, + deferred_paths: deferredPaths, + report_path: null, + applied_at: null, + backup_path: backupPath, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +export async function runAutomaticMemoryUpdate( + rootDir: string, + memoryRoot: string, + appVersion: string, + options: { + adapter?: ModelAdapter; + } = {} +): Promise { + const status = await getMemoryUpdateStatus(rootDir, memoryRoot, appVersion); + if (!status.pending) { + return null; + } + const migrations = await readMigrations(memoryRoot); + const latest = findLatestMigration(migrations, status.migration_id); + if ( + latest && + (latest.status === "deferred" || latest.status === "partially_applied") && + status.memory_pack_version === status.target_memory_pack_version + ) { + return null; + } + const plan = await generateMemoryUpdatePlan(rootDir, memoryRoot, appVersion, { + adapter: options.adapter, + }); + return applyMemoryUpdatePlan(rootDir, memoryRoot, appVersion, { plan }); +} + +export async function readMemoryUpdateReport( + memoryRoot: string, + migrationId: string +): Promise { + const safeId = normalizeMigrationId(migrationId); + if (!safeId) { + return null; + } + const reportPath = resolveMemoryPath(memoryRoot, `${REPORT_RELATIVE_DIR}/${safeId}.md`); + try { + return await readFile(reportPath, "utf8"); + } catch { + return null; + } +} + +export async function writeCurrentStarterPackManifest( + rootDir: string, + memoryRoot: string, + appVersion: string +): Promise { + const manifest = await generateStarterPackManifest(rootDir, appVersion); + const paths = resolveUpdatePaths(memoryRoot); + await mkdir(paths.updatesDir, { recursive: true }); + await writeFile(paths.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + return manifest; +} + +export async function generateStarterPackManifest( + rootDir: string, + appVersion: string +): Promise { + const starterPackDir = await resolveStarterPackDir(rootDir); + const files: StarterPackManifestFile[] = []; + + if (starterPackDir) { + await addManifestFile(files, starterPackDir, { + path: "AGENT.md", + sourcePath: "base/AGENT.md", + kind: "agent_prompt", + mergePolicy: "llm_merge", + }); + await addManifestFile(files, starterPackDir, { + path: "me/todo.md", + sourcePath: "base/me/todo.md", + kind: "user_memory_template", + mergePolicy: "create_if_missing_else_llm_merge", + }); + + const skillsDir = path.join(starterPackDir, "skills"); + if (existsSync(skillsDir)) { + const entries = await readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries + .filter((item) => item.isFile() && item.name.toLowerCase().endsWith(".md")) + .sort((left, right) => left.name.localeCompare(right.name))) { + const skillId = slugifySkillName(path.parse(entry.name).name); + if (!skillId) { + continue; + } + await addManifestFile(files, starterPackDir, { + path: `skills/${skillId}/SKILL.md`, + sourcePath: `skills/${entry.name}`, + kind: "starter_skill", + mergePolicy: "create_if_missing_else_defer", + }); + } + } + } + + return { + schema_version: 1, + memory_pack_version: normalizeVersion(appVersion) ?? appVersion, + app_version: appVersion, + generated_at: new Date().toISOString(), + source: starterPackDir ?? "", + files, + }; +} + +async function ensureMemoryUpdateState( + memoryRoot: string, + manifest: StarterPackManifest, + rootDir: string +): Promise { + const current = await readMemoryUpdateState(memoryRoot); + if (current) { + return current; + } + + const candidates = await buildCandidates(rootDir, memoryRoot, manifest); + const hasDrift = candidates.some((candidate) => candidate.currentSha256 !== candidate.manifestFile.sha256); + const now = new Date().toISOString(); + const initialState: MemoryUpdateState = { + schema_version: 1, + memory_pack_version: hasDrift ? UNKNOWN_MEMORY_PACK_VERSION : manifest.memory_pack_version, + last_checked_app_version: manifest.app_version, + last_completed_migration_id: hasDrift ? null : migrationIdFor(manifest.memory_pack_version), + pending_migration_id: hasDrift ? migrationIdFor(manifest.memory_pack_version) : null, + last_reported_at: null, + updated_at: now, + }; + await writeMemoryUpdateState(memoryRoot, initialState); + return initialState; +} + +async function isMemoryUpdatePending( + rootDir: string, + memoryRoot: string, + manifest: StarterPackManifest, + state: MemoryUpdateState, + latest: MemoryUpdateMigrationLogItem | null = null +): Promise { + const migrationId = migrationIdFor(manifest.memory_pack_version); + if (latest && isHandledMigrationForVersion(latest, state, manifest)) { + return false; + } + if (state.pending_migration_id === migrationId) { + return true; + } + const versionComparison = compareVersions(manifest.memory_pack_version, state.memory_pack_version); + if (versionComparison === null || versionComparison > 0) { + return true; + } + const candidates = await buildCandidates(rootDir, memoryRoot, manifest); + return candidates.some((candidate) => candidate.currentSha256 !== candidate.manifestFile.sha256); +} + +function isHandledMigrationForVersion( + migration: MemoryUpdateMigrationLogItem, + state: MemoryUpdateState, + manifest: StarterPackManifest +): boolean { + return ( + (migration.status === "applied" || migration.status === "partially_applied" || migration.status === "deferred") && + state.memory_pack_version === manifest.memory_pack_version && + migration.to_memory_pack_version === manifest.memory_pack_version + ); +} + +async function buildCandidates( + rootDir: string, + memoryRoot: string, + manifest: StarterPackManifest +): Promise { + const starterPackDir = await resolveStarterPackDir(rootDir); + if (!starterPackDir) { + return []; + } + + const candidates: MemoryUpdateCandidate[] = []; + for (const manifestFile of manifest.files) { + const sourcePath = path.join(starterPackDir, manifestFile.source_path); + const sourceContent = normalizeFileContent(await readFile(sourcePath, "utf8")); + const targetPath = resolveMemoryPath(memoryRoot, manifestFile.path); + let currentContent: string | null = null; + let currentSha256: string | null = null; + let exists = false; + try { + currentContent = await readFile(targetPath, "utf8"); + currentSha256 = sha256(normalizeFileContent(currentContent)); + exists = true; + } catch { + // Missing files are expected for users created before newer starter-pack content. + } + candidates.push({ + manifestFile, + sourceContent, + currentContent, + currentSha256, + exists, + }); + } + return candidates; +} + +async function generateLlmPlan( + adapter: ModelAdapter, + input: { + migrationId: string; + fromMemoryPackVersion: string; + targetMemoryPackVersion: string; + changelogExcerpt: string; + candidates: MemoryUpdateCandidate[]; + } +): Promise { + try { + const response = await adapter.complete( + { + metadata: { + correlation_id: input.migrationId, + trigger: "memory_update_plan", + }, + messages: [ + { + role: "system", + content: [ + "You are BrainDrive's memory update assistant.", + "Merge starter-pack improvements into existing owner memory while preserving owner customizations.", + "Never remove owner-specific facts, goals, plans, todos, preferences, or personal context.", + "Return only valid JSON matching the requested schema.", + "Mark uncertain, destructive, or ambiguous changes as action=defer.", + ].join("\n"), + }, + { + role: "user", + content: JSON.stringify( + { + output_schema: { + schema_version: 1, + migration_id: input.migrationId, + from_memory_pack_version: input.fromMemoryPackVersion, + to_memory_pack_version: input.targetMemoryPackVersion, + summary: "string", + auto_apply: true, + owner_report: "string", + items: [ + { + path: "string", + action: "create|merge|replace|no_change|defer", + confidence: "low|medium|high", + owner_summary: "string", + risk: "low|medium|high", + auto_apply: true, + replacement_content: "required for create/merge/replace", + rationale: "string", + }, + ], + }, + changelog_excerpt: input.changelogExcerpt, + files: input.candidates.map((candidate) => ({ + path: candidate.manifestFile.path, + kind: candidate.manifestFile.kind, + merge_policy: candidate.manifestFile.merge_policy, + exists: candidate.exists, + current_sha256: candidate.currentSha256, + target_sha256: candidate.manifestFile.sha256, + current_content: candidate.currentContent, + target_content: candidate.sourceContent, + diff: buildSimpleDiff(candidate.currentContent, candidate.sourceContent), + })), + }, + null, + 2 + ), + }, + ], + }, + [] + ); + const jsonText = extractJsonObject(response.assistantText); + if (!jsonText) { + return null; + } + const parsed = JSON.parse(jsonText) as MemoryUpdatePlan; + return parsed; + } catch (error) { + auditLog("memory_update.llm_plan_failed", { + migration_id: input.migrationId, + message: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +function generateDeterministicPlan(input: { + migrationId: string; + fromMemoryPackVersion: string; + targetMemoryPackVersion: string; + candidates: MemoryUpdateCandidate[]; +}): MemoryUpdatePlan { + const items: MemoryUpdatePlanItem[] = input.candidates.map((candidate) => { + if (candidate.currentSha256 === candidate.manifestFile.sha256) { + return { + path: candidate.manifestFile.path, + action: "no_change", + confidence: "high", + owner_summary: `${candidate.manifestFile.path} is already current.`, + risk: "low", + auto_apply: true, + }; + } + if (!candidate.exists) { + return { + path: candidate.manifestFile.path, + action: "create", + confidence: "high", + owner_summary: `Added ${candidate.manifestFile.path} from the latest starter pack.`, + risk: "low", + auto_apply: true, + replacement_content: candidate.sourceContent, + }; + } + return { + path: candidate.manifestFile.path, + action: "defer", + confidence: "medium", + owner_summary: `${candidate.manifestFile.path} has custom content and was left unchanged.`, + risk: "high", + auto_apply: false, + rationale: "No LLM merge was available for an existing customized file.", + }; + }); + + return { + schema_version: 1, + migration_id: input.migrationId, + from_memory_pack_version: input.fromMemoryPackVersion, + to_memory_pack_version: input.targetMemoryPackVersion, + summary: "BrainDrive checked memory files against the latest starter pack.", + items, + auto_apply: true, + owner_report: "BrainDrive checked your memory instructions for the latest version.", + generated_at: new Date().toISOString(), + }; +} + +function normalizePlan( + plan: MemoryUpdatePlan, + candidates: MemoryUpdateCandidate[], + fromMemoryPackVersion: string, + targetMemoryPackVersion: string +): MemoryUpdatePlan { + const migrationId = normalizeMigrationId(plan.migration_id) ?? migrationIdFor(targetMemoryPackVersion); + const candidateByPath = new Map(candidates.map((candidate) => [candidate.manifestFile.path, candidate])); + const normalizedItems: MemoryUpdatePlanItem[] = []; + for (const item of Array.isArray(plan.items) ? plan.items : []) { + const candidate = candidateByPath.get(item.path); + if (!candidate) { + continue; + } + const action = normalizeAction(item.action); + const risk = normalizeRisk(item.risk); + const hasContent = typeof item.replacement_content === "string" && item.replacement_content.length > 0; + const isNoChange = action === "no_change"; + const autoApply = + isNoChange || + ((action === "create" || action === "merge" || action === "replace") && + hasContent && + (risk === "low" || risk === "medium")); + + normalizedItems.push({ + path: candidate.manifestFile.path, + action: autoApply ? action : isNoChange ? "no_change" : "defer", + confidence: normalizeConfidence(item.confidence), + owner_summary: normalizeNonEmptyString(item.owner_summary, `${candidate.manifestFile.path} checked.`), + risk: autoApply || isNoChange ? risk : "high", + auto_apply: autoApply, + ...(hasContent ? { replacement_content: normalizeFileContent(item.replacement_content ?? "") } : {}), + ...(typeof item.rationale === "string" ? { rationale: item.rationale } : {}), + }); + } + + for (const candidate of candidates) { + if (normalizedItems.some((item) => item.path === candidate.manifestFile.path)) { + continue; + } + normalizedItems.push(generateDeterministicPlan({ + migrationId, + fromMemoryPackVersion, + targetMemoryPackVersion, + candidates: [candidate], + }).items[0]); + } + + return { + schema_version: 1, + migration_id: migrationId, + from_memory_pack_version: fromMemoryPackVersion, + to_memory_pack_version: targetMemoryPackVersion, + summary: normalizeNonEmptyString(plan.summary, "BrainDrive checked memory files against the latest starter pack."), + items: normalizedItems.sort((left, right) => left.path.localeCompare(right.path)), + auto_apply: true, + owner_report: normalizeNonEmptyString(plan.owner_report, "BrainDrive checked your memory instructions for the latest version."), + generated_at: new Date().toISOString(), + }; +} + +function isAutoApplicable(item: MemoryUpdatePlanItem, candidateByPath: Map): boolean { + const candidate = candidateByPath.get(item.path); + if (!candidate) { + return false; + } + if (item.action === "no_change") { + return true; + } + if (!item.auto_apply || item.risk === "high") { + return false; + } + if (!["create", "merge", "replace"].includes(item.action)) { + return false; + } + return typeof item.replacement_content === "string" && item.replacement_content.length > 0; +} + +async function applyPlanItem( + memoryRoot: string, + candidate: MemoryUpdateCandidate, + item: MemoryUpdatePlanItem, + content: string +): Promise { + if (item.action === "no_change") { + return; + } + + if (candidate.manifestFile.kind === "starter_skill") { + await applySkillUpdate(memoryRoot, candidate, item, content); + return; + } + + const targetPath = resolveMemoryPath(memoryRoot, candidate.manifestFile.path); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, content, "utf8"); +} + +async function applySkillUpdate( + memoryRoot: string, + candidate: MemoryUpdateCandidate, + item: MemoryUpdatePlanItem, + content: string +): Promise { + const [, skillId] = candidate.manifestFile.path.split("/"); + if (!skillId) { + throw new Error(`Invalid skill update path: ${candidate.manifestFile.path}`); + } + const store = new MemorySkillStore(memoryRoot); + await store.ensureLayout(); + const existing = await store.get(skillId); + if (!existing) { + const name = extractFirstMarkdownHeading(content) ?? humanizeSkillName(skillId); + await store.create({ + id: skillId, + name, + description: `Starter skill seeded from ${candidate.manifestFile.source_path}`, + content, + tags: ["starter"], + seeded_from: candidate.manifestFile.source_path, + }); + return; + } + if (item.action === "merge" || item.action === "replace") { + await store.update(skillId, { content }); + } +} + +async function assertCandidateUnchanged(memoryRoot: string, candidate: MemoryUpdateCandidate): Promise { + if (!candidate.exists) { + const targetPath = resolveMemoryPath(memoryRoot, candidate.manifestFile.path); + if (existsSync(targetPath)) { + const current = await readFile(targetPath, "utf8"); + const currentSha = sha256(normalizeFileContent(current)); + if (currentSha !== candidate.manifestFile.sha256) { + throw new Error(`Memory file changed before update apply: ${candidate.manifestFile.path}`); + } + } + return; + } + const targetPath = resolveMemoryPath(memoryRoot, candidate.manifestFile.path); + const current = await readFile(targetPath, "utf8"); + const currentSha = sha256(normalizeFileContent(current)); + if (currentSha !== candidate.currentSha256) { + throw new Error(`Memory file changed before update apply: ${candidate.manifestFile.path}`); + } +} + +async function createMemoryUpdateBackup(memoryRoot: string, migrationId: string): Promise { + const backupPath = resolveMemoryPath(memoryRoot, `${BACKUP_RELATIVE_DIR}/${migrationId}.tar.gz`); + await mkdir(path.dirname(backupPath), { recursive: true }); + await exportMemoryArchive(memoryRoot, backupPath); + return `${BACKUP_RELATIVE_DIR}/${migrationId}.tar.gz`; +} + +async function writeUpdateReport( + memoryRoot: string, + plan: MemoryUpdatePlan, + result: { + status: MemoryUpdateMigrationStatus; + appliedPaths: string[]; + deferredPaths: string[]; + backupPath: string | null; + } +): Promise { + const reportRelativePath = `${REPORT_RELATIVE_DIR}/${plan.migration_id}.md`; + const reportPath = resolveMemoryPath(memoryRoot, reportRelativePath); + const appliedItems = plan.items.filter((item) => result.appliedPaths.includes(item.path)); + const deferredItems = plan.items.filter((item) => result.deferredPaths.includes(item.path)); + const lines = [ + `# BrainDrive Memory Update ${plan.to_memory_pack_version}`, + "", + `Status: ${result.status}`, + `Updated at: ${new Date().toISOString()}`, + "", + buildReportSummary(appliedItems.length, deferredItems.length), + "", + "## Updated", + "", + ...(appliedItems.length > 0 + ? appliedItems.map((item) => `- ${item.owner_summary} (${item.path})`) + : ["- No file changes were needed."]), + "", + "## Left Unchanged", + "", + ...(deferredItems.length > 0 + ? deferredItems.map((item) => `- ${item.owner_summary} (${item.path})`) + : ["- No update items were deferred."]), + "", + "## Backup", + "", + result.backupPath ? `- Backup created at \`${result.backupPath}\`.` : "- No backup was needed because no files changed.", + "", + ]; + await mkdir(path.dirname(reportPath), { recursive: true }); + await writeFile(reportPath, lines.join("\n"), "utf8"); + return reportRelativePath; +} + +function buildReportSummary(appliedCount: number, deferredCount: number): string { + if (appliedCount === 0 && deferredCount === 0) { + return "Your BrainDrive memory pack was checked for updates. No memory changes were needed, and your existing projects and personal memory remain unchanged."; + } + if (appliedCount > 0 && deferredCount === 0) { + return `Your BrainDrive memory pack was updated with ${formatCount(appliedCount, "item")}. Your existing projects and personal memory remain unchanged.`; + } + if (appliedCount === 0) { + return `Your BrainDrive memory pack was checked. ${formatCount(deferredCount, "item")} left unchanged for safety, and no automatic memory changes were made.`; + } + return `Your BrainDrive memory pack was updated with ${formatCount(appliedCount, "item")}. ${formatCount(deferredCount, "item")} left unchanged for safety.`; +} + +function formatCount(count: number, singular: string): string { + return `${count} ${singular}${count === 1 ? "" : "s"}`; +} + +async function readPlan(memoryRoot: string, migrationId: string): Promise { + const safeId = normalizeMigrationId(migrationId); + if (!safeId) { + return null; + } + try { + const raw = await readFile(resolveMemoryPath(memoryRoot, `${PLAN_RELATIVE_DIR}/${safeId}.json`), "utf8"); + return JSON.parse(raw) as MemoryUpdatePlan; + } catch { + return null; + } +} + +async function readMemoryUpdateState(memoryRoot: string): Promise { + try { + const raw = await readFile(resolveMemoryPath(memoryRoot, STATE_RELATIVE_PATH), "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (parsed.schema_version === 1 && typeof parsed.memory_pack_version === "string") { + return { + schema_version: 1, + memory_pack_version: parsed.memory_pack_version, + last_checked_app_version: parsed.last_checked_app_version ?? "", + last_completed_migration_id: parsed.last_completed_migration_id ?? null, + pending_migration_id: parsed.pending_migration_id ?? null, + last_reported_at: parsed.last_reported_at ?? null, + updated_at: parsed.updated_at ?? new Date().toISOString(), + }; + } + } catch { + // Missing state is normal before this subsystem has initialized. + } + return null; +} + +async function writeMemoryUpdateState(memoryRoot: string, state: MemoryUpdateState): Promise { + const targetPath = resolveMemoryPath(memoryRoot, STATE_RELATIVE_PATH); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, `${JSON.stringify(state, null, 2)}\n`, "utf8"); +} + +async function readMigrations(memoryRoot: string): Promise { + try { + const raw = await readFile(resolveMemoryPath(memoryRoot, MIGRATIONS_RELATIVE_PATH), "utf8"); + const parsed = JSON.parse(raw) as Partial; + if (parsed.schema_version === 1 && Array.isArray(parsed.items)) { + return { + schema_version: 1, + items: parsed.items.filter(isMigrationLogItem), + }; + } + } catch { + // Missing log is normal. + } + return { schema_version: 1, items: [] }; +} + +async function appendMigrationLog(memoryRoot: string, item: MemoryUpdateMigrationLogItem): Promise { + const log = await readMigrations(memoryRoot); + const nextItems = [ + ...log.items.filter((entry) => !(entry.migration_id === item.migration_id && entry.status === item.status)), + item, + ]; + const targetPath = resolveMemoryPath(memoryRoot, MIGRATIONS_RELATIVE_PATH); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, `${JSON.stringify({ schema_version: 1, items: nextItems }, null, 2)}\n`, "utf8"); +} + +async function readChangelogExcerpt(changelogPath: string): Promise { + try { + const raw = await readFile(changelogPath, "utf8"); + return raw.slice(0, 8000); + } catch { + return ""; + } +} + +async function addManifestFile( + files: StarterPackManifestFile[], + starterPackDir: string, + input: { + path: string; + sourcePath: string; + kind: StarterPackManifestFileKind; + mergePolicy: StarterPackMergePolicy; + } +): Promise { + try { + const content = normalizeFileContent(await readFile(path.join(starterPackDir, input.sourcePath), "utf8")); + files.push({ + path: input.path, + source_path: input.sourcePath, + kind: input.kind, + merge_policy: input.mergePolicy, + sha256: sha256(content), + }); + } catch { + // Starter-pack content can be absent in development fixtures. + } +} + +async function writeJson(directory: string, fileName: string, payload: unknown): Promise { + await mkdir(directory, { recursive: true }); + await writeFile(path.join(directory, fileName), `${JSON.stringify(payload, null, 2)}\n`, "utf8"); +} + +function resolveUpdatePaths(memoryRoot: string): MemoryUpdatePaths { + return { + updatesDir: resolveMemoryPath(memoryRoot, "system/updates"), + statePath: resolveMemoryPath(memoryRoot, STATE_RELATIVE_PATH), + migrationsPath: resolveMemoryPath(memoryRoot, MIGRATIONS_RELATIVE_PATH), + manifestPath: resolveMemoryPath(memoryRoot, MANIFEST_RELATIVE_PATH), + plansDir: resolveMemoryPath(memoryRoot, PLAN_RELATIVE_DIR), + reportsDir: resolveMemoryPath(memoryRoot, REPORT_RELATIVE_DIR), + backupsDir: resolveMemoryPath(memoryRoot, BACKUP_RELATIVE_DIR), + }; +} + +async function resolveStarterPackDir(rootDir: string): Promise { + const envOverride = process.env[STARTER_PACK_ENV]?.trim(); + const candidates = [ + envOverride, + path.resolve(rootDir, STARTER_PACK_RELATIVE_PATH), + path.resolve(rootDir, "builds", "typescript", STARTER_PACK_RELATIVE_PATH), + ].filter((value): value is string => Boolean(value && value.length > 0)); + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function findLatestMigration( + log: MemoryUpdateMigrationLog, + migrationId: string +): MemoryUpdateMigrationLogItem | null { + const matches = log.items.filter((item) => item.migration_id === migrationId); + return matches[matches.length - 1] ?? null; +} + +function migrationIdFor(version: unknown): string { + const rawVersion = typeof version === "string" && version.trim().length > 0 ? version.trim() : UNKNOWN_MEMORY_PACK_VERSION; + const normalized = normalizeVersion(rawVersion) ?? rawVersion.toLowerCase().replace(/[^a-z0-9.-]+/g, "-"); + return `starter-pack-${normalized}`; +} + +function normalizeMigrationId(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + if (!/^[a-z0-9][a-z0-9.-]{0,127}$/i.test(normalized)) { + return null; + } + return normalized; +} + +function compareVersions(left: string, right: string): number | null { + const leftParts = parseVersionParts(left); + const rightParts = parseVersionParts(right); + if (!leftParts || !rightParts) { + return null; + } + const maxLength = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < maxLength; index += 1) { + const leftPart = leftParts[index] ?? 0; + const rightPart = rightParts[index] ?? 0; + if (leftPart > rightPart) { + return 1; + } + if (leftPart < rightPart) { + return -1; + } + } + return 0; +} + +function parseVersionParts(value: string): number[] | null { + const normalized = normalizeVersion(value); + if (!normalized) { + return null; + } + return normalized.split(".").map((part) => Number(part)); +} + +function normalizeVersion(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().replace(/^v/i, ""); + if (!/^\d+(?:\.\d+){1,3}$/.test(normalized)) { + return null; + } + return normalized; +} + +function normalizeAction(value: unknown): MemoryUpdatePlanAction { + return value === "create" || value === "merge" || value === "replace" || value === "no_change" || value === "defer" + ? value + : "defer"; +} + +function normalizeRisk(value: unknown): MemoryUpdateRisk { + return value === "low" || value === "medium" || value === "high" ? value : "high"; +} + +function normalizeConfidence(value: unknown): "low" | "medium" | "high" { + return value === "low" || value === "medium" || value === "high" ? value : "medium"; +} + +function normalizeNonEmptyString(value: unknown, fallback: string): string { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback; +} + +function normalizeFileContent(content: string): string { + return content.endsWith("\n") ? content : `${content}\n`; +} + +function sha256(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +function extractJsonObject(value: string): string | null { + const trimmed = value.trim(); + if (trimmed.startsWith("{") && trimmed.endsWith("}")) { + return trimmed; + } + const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]) { + const candidate = fenced[1].trim(); + if (candidate.startsWith("{") && candidate.endsWith("}")) { + return candidate; + } + } + const first = trimmed.indexOf("{"); + const last = trimmed.lastIndexOf("}"); + if (first >= 0 && last > first) { + return trimmed.slice(first, last + 1); + } + return null; +} + +function buildSimpleDiff(currentContent: string | null, targetContent: string): string { + if (currentContent === null) { + return `+${targetContent.split("\n").join("\n+")}`; + } + if (normalizeFileContent(currentContent) === normalizeFileContent(targetContent)) { + return ""; + } + return [ + "--- current", + "+++ target", + ...normalizeFileContent(currentContent).split("\n").slice(0, 120).map((line) => `-${line}`), + ...normalizeFileContent(targetContent).split("\n").slice(0, 120).map((line) => `+${line}`), + ].join("\n"); +} + +function isMigrationLogItem(value: unknown): value is MemoryUpdateMigrationLogItem { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const item = value as Record; + return typeof item.migration_id === "string" && typeof item.status === "string"; +} + +function extractFirstMarkdownHeading(content: string): string | null { + for (const line of content.split("\n")) { + const match = line.match(/^#{1,3}\s+(.+?)\s*$/); + if (match?.[1]) { + return match[1].trim(); + } + } + return null; +} + +function humanizeSkillName(id: string): string { + return id + .split("-") + .filter(Boolean) + .map((word) => `${word.slice(0, 1).toUpperCase()}${word.slice(1)}`) + .join(" "); +}