From 332c6bf86094a9e83353770ec7547c89eb0b6de2 Mon Sep 17 00:00:00 2001 From: DJJones66 Date: Thu, 30 Apr 2026 16:42:34 -0400 Subject: [PATCH 1/4] fix(client): prevent assistant reply auto-scroll on mobile --- .../src/components/chat/MessageList.test.tsx | 37 +++++++++++++++++++ .../src/components/chat/MessageList.tsx | 17 +++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 builds/typescript/client_web/src/components/chat/MessageList.test.tsx diff --git a/builds/typescript/client_web/src/components/chat/MessageList.test.tsx b/builds/typescript/client_web/src/components/chat/MessageList.test.tsx new file mode 100644 index 0000000..66d87f6 --- /dev/null +++ b/builds/typescript/client_web/src/components/chat/MessageList.test.tsx @@ -0,0 +1,37 @@ +import { render } from "@testing-library/react"; + +import type { Message } from "@/types/ui"; + +import MessageList from "./MessageList"; + +const scrollIntoViewMock = vi.fn(); + +beforeEach(() => { + scrollIntoViewMock.mockReset(); + Element.prototype.scrollIntoView = scrollIntoViewMock; +}); + +describe("MessageList scroll behavior", () => { + it("does not jump to the bottom when an assistant response starts", () => { + const userMessage: Message = { id: "u-1", role: "user", content: "Build me a fitness plan" }; + const assistantMessage: Message = { id: "a-1", role: "assistant", content: "Here is a plan..." }; + + const { rerender } = render(); + scrollIntoViewMock.mockClear(); + + rerender(); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + }); + + it("scrolls down when the user submits a new message", () => { + const userMessage: Message = { id: "u-1", role: "user", content: "Build me a fitness plan" }; + + const { rerender } = render(); + scrollIntoViewMock.mockClear(); + + rerender(); + + expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/builds/typescript/client_web/src/components/chat/MessageList.tsx b/builds/typescript/client_web/src/components/chat/MessageList.tsx index 597f290..cb6c465 100644 --- a/builds/typescript/client_web/src/components/chat/MessageList.tsx +++ b/builds/typescript/client_web/src/components/chat/MessageList.tsx @@ -23,6 +23,7 @@ export default function MessageList({ const [showJumpToBottom, setShowJumpToBottom] = useState(false); const isNearBottomRef = useRef(true); const prevMessageCountRef = useRef(0); + const hasRenderedMessagesRef = useRef(false); useEffect(() => { const container = scrollRef.current; @@ -40,14 +41,24 @@ export default function MessageList({ return () => container.removeEventListener("scroll", handleScroll); }, []); - // Only auto-scroll when a new message is added (not on content updates during streaming) + // Keep assistant replies anchored so users can read from the start as content streams in. + // User messages still scroll down to reveal the submitted prompt and response area. useEffect(() => { const messageCount = messages.length; - if (messageCount > prevMessageCountRef.current && isNearBottomRef.current) { + const lastMessage = messages[messageCount - 1]; + const shouldScrollForNewUserMessage = + hasRenderedMessagesRef.current && + messageCount > prevMessageCountRef.current && + lastMessage?.role === "user" && + isNearBottomRef.current; + + if (shouldScrollForNewUserMessage) { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); } + + hasRenderedMessagesRef.current = true; prevMessageCountRef.current = messageCount; - }, [messages.length]); + }, [messages]); function scrollToBottom() { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); From 63aaeba73efc30798c3f66b64ca7b3d1bcd2a8de Mon Sep 17 00:00:00 2001 From: navaneethkrishnansuresh Date: Fri, 1 May 2026 18:38:54 +0530 Subject: [PATCH 2/4] config-adapter: retry getConfig before falling through to local mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defaulting to mode='local' on the first failed /api/config response caused a confusing login screen for managed-mode users post-relogin: the user was already authenticated via the gateway, but BD Core booted faster than its own fastify routes responded, getConfig() failed open to local, and App.tsx routed to the auth screen. Retry budget: 5 attempts with exponential backoff (~7.5s total). After that we still fall through to local — that's correct for genuine local installs where /api/config legitimately 404s. The gateway side also probes /api/config before redirecting (defense-in-depth), so reaching the fallback at all should be rare in managed mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client_web/src/api/config-adapter.ts | 84 ++++++++++++------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/builds/typescript/client_web/src/api/config-adapter.ts b/builds/typescript/client_web/src/api/config-adapter.ts index d715cd6..4b7221c 100644 --- a/builds/typescript/client_web/src/api/config-adapter.ts +++ b/builds/typescript/client_web/src/api/config-adapter.ts @@ -21,41 +21,67 @@ export type GatewayClientConfig = { appVersion: string; }; +const DEFAULT_LOCAL_CONFIG: GatewayClientConfig = { + mode: "local", + gatewayUrl: "/api", + billingUrl: "https://my.braindrive.ai/credits", + installMode: "unknown", + installLocation: "unknown", + appVersion: "unknown", +}; + export async function getConfig(): Promise { - try { - const response = await fetch("/api/config", { - headers: buildLocalOwnerHeaders(), - }); - if (!response.ok) { + // Retry on failure with exponential backoff before falling through to + // the local-mode default. This endpoint determines whether BD Core + // shows its own login screen or trusts gateway-injected auth (managed + // mode); falling through to "local" prematurely on a transient network + // hiccup or a still-booting container produces a confusing login screen + // for users who are already authenticated via the gateway. After this + // retry budget is exhausted, we still default to "local" — that's the + // safe choice for genuine local installs where /api/config legitimately + // 404s. The handoff path on the gateway side also probes this endpoint + // before redirecting, so reaching this fallback at all should be rare. + const attempts = [0, 500, 1000, 2000, 4000]; // ms backoff between attempts (5 total tries) + let lastError: unknown = null; + + for (let i = 0; i < attempts.length; i++) { + if (attempts[i] > 0) { + await new Promise((r) => setTimeout(r, attempts[i])); + } + try { + const response = await fetch("/api/config", { + headers: buildLocalOwnerHeaders(), + }); + if (!response.ok) { + // Non-200: server reachable but unhappy. Could be 404 (genuine + // local install with no managed gateway) or 502/503 (managed + // container still booting). Retry — only fall through if we run + // out of attempts. + lastError = new Error(`HTTP ${response.status}`); + continue; + } + const payload = (await response.json()) as GatewayConfig; return { - mode: "local", - gatewayUrl: "/api", - billingUrl: "https://my.braindrive.ai/credits", - installMode: "unknown", - installLocation: "unknown", - appVersion: "unknown" + mode: toDeploymentMode(payload.mode), + gatewayUrl: payload.gateway_url || "/api", + billingUrl: payload.billing_url ?? "https://my.braindrive.ai/credits", + installMode: toInstallMode(payload.install_mode), + installLocation: toInstallLocation(payload.install_location), + appVersion: toAppVersion(payload.app_version), }; + } catch (err) { + lastError = err; } + } - const payload = (await response.json()) as GatewayConfig; - return { - mode: toDeploymentMode(payload.mode), - gatewayUrl: payload.gateway_url || "/api", - billingUrl: payload.billing_url ?? "https://my.braindrive.ai/credits", - installMode: toInstallMode(payload.install_mode), - installLocation: toInstallLocation(payload.install_location), - appVersion: toAppVersion(payload.app_version), - }; - } catch { - return { - mode: "local", - gatewayUrl: "/api", - billingUrl: "https://my.braindrive.ai/credits", - installMode: "unknown", - installLocation: "unknown", - appVersion: "unknown" - }; + // All attempts exhausted — log and fall through to local-mode default. + // For a true local install this is correct; for a managed install it + // means the gateway is genuinely down and the user can't authenticate + // anyway, so showing the login screen is acceptable degraded UX. + if (lastError) { + console.warn("[BD Core] getConfig failed after retries, defaulting to local mode:", lastError); } + return DEFAULT_LOCAL_CONFIG; } function toDeploymentMode(value: unknown): "local" | "managed" { From 96d8dc0aff59a2b36ed5f2f4023fe692ad4b9aeb Mon Sep 17 00:00:00 2001 From: navaneethkrishnansuresh Date: Fri, 1 May 2026 18:40:57 +0530 Subject: [PATCH 3/4] settings: style 'Need more credits?' as amber button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Was rendering as plain muted text — easy to miss next to the credits balance. Match the existing Buy Credits button's golden accent theme (bg-bd-amber + bg-bd-bg-primary text + amber-hover) so it reads as a clear affordance to expand the topup form. Same Tailwind tokens as the actual Buy Credits button (slightly less prominent — no extra padding, just the inline + glyph) so visual hierarchy still distinguishes "show form" from "complete purchase". Co-Authored-By: Claude Opus 4.7 (1M context) --- .../client_web/src/components/settings/SettingsModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/builds/typescript/client_web/src/components/settings/SettingsModal.tsx b/builds/typescript/client_web/src/components/settings/SettingsModal.tsx index 25c0731..1e637bc 100644 --- a/builds/typescript/client_web/src/components/settings/SettingsModal.tsx +++ b/builds/typescript/client_web/src/components/settings/SettingsModal.tsx @@ -2563,8 +2563,9 @@ function AccountSection() { ) : ( From 8010c22fb962f315c847b692ab14d26d32a1d85b Mon Sep 17 00:00:00 2001 From: DJJones66 Date: Fri, 1 May 2026 11:03:36 -0400 Subject: [PATCH 4/4] 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(" "); +}