Skip to content

Commit 27a71b0

Browse files
committed
feat: add workspace and user memory controls
1 parent 640fc57 commit 27a71b0

18 files changed

Lines changed: 1365 additions & 654 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { registerIpcHandlers } from './ipc/register';
2020
import { getPendingUpdate, setupAutoUpdater } from './ipc/update';
2121
import { registerLocaleIpc } from './locale-ipc';
2222
import { getLogger, initLogger } from './logger';
23+
import { registerMemoryIpc } from './memory-ipc';
2324
import { loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc';
2425
import { isAllowedExternalUrl } from './open-external';
2526
import { readPersisted as readPreferences, registerPreferencesIpc } from './preferences-ipc';
@@ -260,6 +261,7 @@ if (!IS_VITEST) {
260261
registerOnboardingIpc();
261262
registerCodexOAuthIpc();
262263
registerPreferencesIpc();
264+
registerMemoryIpc();
263265
registerImageGenerationSettingsIpc();
264266
registerExporterIpc(getMainWindow, diagnosticsDb);
265267
registerDiagnosticsIpc(diagnosticsDb);

apps/desktop/src/main/ipc/generate.ts

Lines changed: 178 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ import {
4040
import { resolveGenerationWorkspaceRoot } from '../generation-workspace';
4141
import { resolveImageGenerationConfig, toGenerateImageOptions } from '../image-generation-settings';
4242
import { getLogger } from '../logger';
43-
import { loadMemoryContext, triggerMemoryUpdate } from '../memory-ipc';
43+
import {
44+
loadMemoryContext,
45+
triggerUserMemoryCandidateCapture,
46+
triggerUserMemoryConsolidation,
47+
triggerWorkspaceMemoryUpdate,
48+
workspaceNameFromPath,
49+
} from '../memory-ipc';
4450
import { getApiKeyForProvider, getCachedConfig, hasApiKeyForProvider } from '../onboarding-ipc';
4551
import { readPersisted as readPreferences } from '../preferences-ipc';
4652
import { runPreview } from '../preview-runtime';
@@ -70,6 +76,56 @@ export function contextWindowForContextPack(model: unknown): number {
7076
: DEFAULT_CONTEXT_WINDOW_FOR_CONTEXT_PACK;
7177
}
7278

79+
function designMdSummaryForMemory(
80+
projectContext: Awaited<ReturnType<typeof preparePromptContext>>['projectContext'],
81+
): string | null {
82+
const raw = projectContext.designMd ?? projectContext.invalidDesignMd?.raw ?? null;
83+
if (raw === null || raw.trim().length === 0) return null;
84+
const headings = raw
85+
.split('\n')
86+
.map((line) => line.trim())
87+
.filter((line) => /^#{1,3}\s+\S/.test(line))
88+
.slice(0, 24);
89+
if (headings.length === 0) {
90+
return projectContext.invalidDesignMd
91+
? 'DESIGN.md exists but currently fails validation.'
92+
: 'DESIGN.md exists and should remain the authoritative design-system source.';
93+
}
94+
return [
95+
projectContext.invalidDesignMd
96+
? 'DESIGN.md exists but currently fails validation.'
97+
: 'DESIGN.md exists and should remain the authoritative design-system source.',
98+
'Headings:',
99+
...headings.map((heading) => `- ${heading.replace(/^#+\s*/, '')}`),
100+
].join('\n');
101+
}
102+
103+
function extractUserMessagesForMemory(messages: DesignBriefConversationMessages): string[] {
104+
const out: string[] = [];
105+
for (const msg of messages) {
106+
if (msg.role !== 'user') continue;
107+
const content = (msg as { content?: unknown }).content;
108+
if (typeof content === 'string') {
109+
const trimmed = content.trim();
110+
if (trimmed.length > 0) out.push(trimmed);
111+
continue;
112+
}
113+
if (!Array.isArray(content)) continue;
114+
const text = content
115+
.map((part) => {
116+
if (typeof part !== 'object' || part === null) return '';
117+
const record = part as Record<string, unknown>;
118+
return record['type'] === 'text' && typeof record['text'] === 'string'
119+
? record['text']
120+
: '';
121+
})
122+
.join('\n')
123+
.trim();
124+
if (text.length > 0) out.push(text);
125+
}
126+
return out;
127+
}
128+
73129
/**
74130
* Pull an HTTP status code out of a caught provider error. Mirrors
75131
* `packages/providers/src/retry.ts::extractStatus` intentionally — we don't
@@ -570,22 +626,25 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
570626
});
571627

572628
const { designId, workspaceRoot } = requireWorkspaceRootForDesign(payload.designId);
629+
const prefs = await readPreferences();
573630
const promptContext = await preparePromptContext({
574631
attachments: payload.attachments,
575632
referenceUrl: payload.referenceUrl,
576633
designSystem: cfg.designSystem ?? null,
577634
workspaceRoot,
578635
});
579-
let memoryContext: string[] | undefined;
636+
let memoryContext: Awaited<ReturnType<typeof loadMemoryContext>> | undefined;
580637
let memoryLoadWarning: string | undefined;
581-
try {
582-
memoryContext = await loadMemoryContext(workspaceRoot);
583-
} catch (err) {
584-
memoryLoadWarning = `Project memory unavailable: ${err instanceof Error ? err.message : String(err)}`;
585-
logIpc.warn('memory.load.fail', {
586-
generationId: id,
587-
message: err instanceof Error ? err.message : String(err),
588-
});
638+
if (prefs.memoryEnabled) {
639+
try {
640+
memoryContext = await loadMemoryContext(workspaceRoot);
641+
} catch (err) {
642+
memoryLoadWarning = `Project memory unavailable: ${err instanceof Error ? err.message : String(err)}`;
643+
logIpc.warn('memory.load.fail', {
644+
generationId: id,
645+
message: err instanceof Error ? err.message : String(err),
646+
});
647+
}
589648
}
590649

591650
logIpc.info('generate', {
@@ -615,7 +674,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
615674
let aggressivePruneDetected = false;
616675
const chatRows = chatRowsForDesign(designId);
617676
const resourceState = deriveResourceStateFromChatRows(chatRows);
618-
const existingBrief = briefForDesign(designId);
677+
const existingBrief = prefs.memoryEnabled ? briefForDesign(designId) : null;
619678
const contextPack = buildDesignContextPack({
620679
chatRows,
621680
brief: existingBrief,
@@ -646,7 +705,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
646705
referenceUrl: promptContext.referenceUrl,
647706
designSystem: promptContext.designSystem ?? null,
648707
sessionContext: contextPack.contextSections,
649-
...(memoryContext !== undefined ? { memoryContext } : {}),
708+
...(memoryContext !== undefined ? { memoryContext: memoryContext.sections } : {}),
650709
projectContext: promptContext.projectContext,
651710
initialResourceState: resourceState,
652711
...(baseUrl !== undefined ? { baseUrl } : {}),
@@ -679,6 +738,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
679738
cost: result.costUsd,
680739
});
681740
if (capturedMessages !== null) {
741+
const messagesForMemory = capturedMessages;
682742
const design = db !== null ? getDesign(db, designId) : null;
683743
let memoryWorkspaceRoot = workspaceRoot;
684744
try {
@@ -689,59 +749,114 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
689749
message: err instanceof Error ? err.message : String(err),
690750
});
691751
}
692-
const briefUpdate = updateDesignSessionBrief({
693-
existingBrief,
694-
conversationMessages: capturedMessages,
695-
designId,
696-
designName: design?.name ?? 'Untitled',
697-
model: active.model,
698-
apiKey,
699-
...(baseUrl !== undefined ? { baseUrl } : {}),
700-
wire: active.wire,
701-
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
702-
...(active.reasoningLevel !== undefined
703-
? { reasoningLevel: active.reasoningLevel }
704-
: {}),
705-
...(allowKeyless ? { allowKeyless: true } : {}),
706-
})
707-
.then((briefResult) => {
708-
const opts = chatStoreOptions();
709-
if (opts !== null) appendSessionDesignBrief(opts, designId, briefResult.brief);
710-
logIpc.info('design-brief.update.ok', {
711-
generationId: id,
712-
outputLen: JSON.stringify(briefResult.brief).length,
713-
});
714-
})
715-
.catch((err) => {
716-
logIpc.warn('design-brief.update.fail', {
717-
generationId: id,
718-
message: err instanceof Error ? err.message : String(err),
719-
});
720-
});
721-
const memoryUpdate = triggerMemoryUpdate({
722-
workspacePath: memoryWorkspaceRoot,
723-
designId,
724-
designName: design?.name ?? 'Untitled',
725-
conversationMessages: capturedMessages,
726-
model: active.model,
727-
apiKey,
728-
db,
729-
...(baseUrl !== undefined ? { baseUrl } : {}),
730-
wire: active.wire,
731-
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
732-
...(active.reasoningLevel !== undefined
733-
? { reasoningLevel: active.reasoningLevel }
734-
: {}),
735-
...(allowKeyless ? { allowKeyless: true } : {}),
736-
}).catch((err) => {
737-
logIpc.warn('memory.update.fail', {
738-
generationId: id,
739-
message: err instanceof Error ? err.message : String(err),
740-
});
741-
});
752+
const designName = design?.name ?? 'Untitled';
753+
const workspaceMemoryUpdate =
754+
prefs.memoryEnabled === true && prefs.workspaceMemoryAutoUpdate === true
755+
? triggerWorkspaceMemoryUpdate({
756+
workspacePath: memoryWorkspaceRoot,
757+
workspaceName: workspaceNameFromPath(memoryWorkspaceRoot),
758+
designId,
759+
designName,
760+
conversationMessages: messagesForMemory,
761+
userMemory: memoryContext?.userMemory?.content ?? null,
762+
designMdSummary: designMdSummaryForMemory(promptContext.projectContext),
763+
model: active.model,
764+
apiKey,
765+
...(baseUrl !== undefined ? { baseUrl } : {}),
766+
wire: active.wire,
767+
...(active.httpHeaders !== undefined
768+
? { httpHeaders: active.httpHeaders }
769+
: {}),
770+
...(active.reasoningLevel !== undefined
771+
? { reasoningLevel: active.reasoningLevel }
772+
: {}),
773+
...(allowKeyless ? { allowKeyless: true } : {}),
774+
}).catch((err) => {
775+
logIpc.warn('workspace-memory.update.fail', {
776+
generationId: id,
777+
message: err instanceof Error ? err.message : String(err),
778+
});
779+
return memoryContext?.workspaceMemory ?? null;
780+
})
781+
: Promise.resolve(memoryContext?.workspaceMemory ?? null);
742782

783+
const briefUpdate = prefs.memoryEnabled
784+
? workspaceMemoryUpdate
785+
.then((workspaceMemory) =>
786+
updateDesignSessionBrief({
787+
existingBrief,
788+
conversationMessages: messagesForMemory,
789+
designId,
790+
designName,
791+
userMemory: memoryContext?.userMemory?.content ?? null,
792+
workspaceMemory: workspaceMemory?.content ?? null,
793+
sourceUserMemoryHash: memoryContext?.userMemory?.hash,
794+
sourceWorkspaceMemoryHash: workspaceMemory?.hash,
795+
sourceMemoryUpdatedAt:
796+
workspaceMemory?.updatedAt ?? memoryContext?.userMemory?.updatedAt,
797+
model: active.model,
798+
apiKey,
799+
...(baseUrl !== undefined ? { baseUrl } : {}),
800+
wire: active.wire,
801+
...(active.httpHeaders !== undefined
802+
? { httpHeaders: active.httpHeaders }
803+
: {}),
804+
...(active.reasoningLevel !== undefined
805+
? { reasoningLevel: active.reasoningLevel }
806+
: {}),
807+
...(allowKeyless ? { allowKeyless: true } : {}),
808+
}),
809+
)
810+
.then((briefResult) => {
811+
const opts = chatStoreOptions();
812+
if (opts !== null)
813+
appendSessionDesignBrief(opts, designId, briefResult.brief);
814+
logIpc.info('design-brief.update.ok', {
815+
generationId: id,
816+
outputLen: JSON.stringify(briefResult.brief).length,
817+
});
818+
})
819+
.catch((err) => {
820+
logIpc.warn('design-brief.update.fail', {
821+
generationId: id,
822+
message: err instanceof Error ? err.message : String(err),
823+
});
824+
})
825+
: Promise.resolve();
826+
const userMemoryMaintenance =
827+
prefs.memoryEnabled === true
828+
? triggerUserMemoryCandidateCapture({
829+
designId,
830+
designName,
831+
userMessages: extractUserMessagesForMemory(messagesForMemory),
832+
})
833+
.then(() => {
834+
if (prefs.userMemoryAutoUpdate !== true) {
835+
return { updated: false, candidateCount: 0 };
836+
}
837+
return triggerUserMemoryConsolidation({
838+
model: active.model,
839+
apiKey,
840+
...(baseUrl !== undefined ? { baseUrl } : {}),
841+
wire: active.wire,
842+
...(active.httpHeaders !== undefined
843+
? { httpHeaders: active.httpHeaders }
844+
: {}),
845+
...(active.reasoningLevel !== undefined
846+
? { reasoningLevel: active.reasoningLevel }
847+
: {}),
848+
...(allowKeyless ? { allowKeyless: true } : {}),
849+
});
850+
})
851+
.catch((err) => {
852+
logIpc.warn('user-memory.maintenance.fail', {
853+
generationId: id,
854+
message: err instanceof Error ? err.message : String(err),
855+
});
856+
})
857+
: Promise.resolve();
743858
if (aggressivePruneDetected) {
744-
await Promise.all([briefUpdate, memoryUpdate]);
859+
await Promise.all([briefUpdate, userMemoryMaintenance]);
745860
}
746861
}
747862
if (memoryLoadWarning !== undefined) {

0 commit comments

Comments
 (0)