Skip to content

Commit 03c474e

Browse files
committed
refactor: streamline memory context plumbing
Share truncation and context-fetching utilities, centralize session defaults, and tighten tests so memory injection stays consistent with less duplicated work.
1 parent 7212e12 commit 03c474e

20 files changed

Lines changed: 691 additions & 894 deletions

src/handlers/chat.test.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ class MockSessionManager implements Partial<SessionManager> {
1010
private sessions = new Map<string, any>();
1111
private parentIds = new Map<string, string | null>();
1212

13-
async isSubagentSession(sessionId: string): Promise<boolean> {
14-
return this.parentIds.get(sessionId) !== null &&
15-
this.parentIds.get(sessionId) !== undefined;
16-
}
17-
1813
async resolveSessionState(sessionId: string) {
1914
const parentId = this.parentIds.get(sessionId);
2015
if (parentId === undefined) return { state: null, resolved: false };
@@ -433,8 +428,9 @@ describe("chat handler integration", () => {
433428
{ parts: [{ type: "text", text: "Second message" }] } as any,
434429
);
435430

436-
// Should perform drift check (1 call) + full search (1 call for project only, no user on reinjection)
437-
assertEquals(client.searchFactsCalls.length, callsBefore + 2);
431+
// Drift-check result is reused as project facts, so only 1 new call total.
432+
assertEquals(client.searchFactsCalls.length, callsBefore + 1);
433+
assertEquals(client.searchFactsCalls.at(-1)?.maxFacts, 50);
438434

439435
// Should have updated cached context
440436
const updatedState = sessionManager.getState("session-1");
@@ -559,8 +555,8 @@ describe("chat handler integration", () => {
559555
);
560556

561557
// Empty current vs non-empty last = similarity 0 < threshold
562-
// Should trigger drift check + reinjection attempt (but will early-return with no facts)
563-
assertEquals(client.searchFactsCalls.length, callsBefore + 2);
558+
// Task 8: drift-check result is reused as project facts, so only 1 new call total.
559+
assertEquals(client.searchFactsCalls.length, callsBefore + 1);
564560
});
565561

566562
it("should handle both empty fact sets (edge case)", async () => {
@@ -1026,13 +1022,11 @@ describe("chat handler integration", () => {
10261022
{ parts: [{ type: "text", text: "Second message" }] } as any,
10271023
);
10281024

1029-
// Should have:
1030-
// - 1 drift check call (maxFacts=20)
1031-
// - 1 project facts call (maxFacts=50)
1032-
// - 1 project nodes call (maxNodes=30)
1033-
// NO user scope calls (because useUserScope=false on reinjection)
1025+
// Should have exactly one new project-scope facts call on reinjection.
1026+
// The drift-check result is reused as project facts, and user scope is skipped.
10341027
const newCalls = client.searchFactsCalls.length - callsAfterFirst;
1035-
assertEquals(newCalls, 2); // drift check + project facts only
1028+
assertEquals(newCalls, 1);
1029+
assertEquals(client.searchFactsCalls.at(-1)?.maxFacts, 50);
10361030
});
10371031
});
10381032

src/handlers/chat.ts

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import type { Hooks } from "@opencode-ai/plugin";
22
import type { GraphitiClient } from "../services/client.ts";
33
import { calculateInjectionBudget } from "../services/context-limit.ts";
4+
import { PROJECT_MAX_FACTS } from "../services/constants.ts";
45
import {
5-
deduplicateContext,
66
formatMemoryContext,
7+
resolveProjectUserContext,
78
} from "../services/context.ts";
89
import { logger } from "../services/logger.ts";
910
import type { SessionManager } from "../session.ts";
10-
import { extractTextFromParts } from "../utils.ts";
11+
import { extractTextFromParts, truncateAtLineBoundary } from "../utils.ts";
1112

1213
type ChatMessageHook = NonNullable<Hooks["chat.message"]>;
1314
type ChatMessageInput = Parameters<ChatMessageHook>[0];
1415
type ChatMessageOutput = Parameters<ChatMessageHook>[1];
16+
type SearchFactsResult = Awaited<ReturnType<GraphitiClient["searchFacts"]>>;
1517

1618
/** Dependencies for the chat message handler. */
1719
export interface ChatHandlerDeps {
@@ -25,6 +27,14 @@ export interface ChatHandlerDeps {
2527
export function createChatHandler(deps: ChatHandlerDeps) {
2628
const { sessionManager, driftThreshold, factStaleDays, client } = deps;
2729

30+
/**
31+
* Fetch project facts (and optionally user facts/nodes) then build and cache
32+
* the formatted memory context string.
33+
*
34+
* Task 8: When `seedProjectFacts` is supplied (from the drift check), those
35+
* facts are used directly for the project scope so we avoid a redundant
36+
* second searchFacts query.
37+
*/
2838
const searchAndCacheMemoryContext = async (
2939
state: {
3040
groupId: string;
@@ -38,14 +48,21 @@ export function createChatHandler(deps: ChatHandlerDeps) {
3848
messageText: string,
3949
useUserScope: boolean,
4050
characterBudget: number,
41-
seedFactUuids?: string[] | null,
51+
seedProjectFacts?: SearchFactsResult,
4252
) => {
4353
const userGroupId = state.userGroupId;
44-
const projectFactsPromise = client.searchFacts({
45-
query: messageText,
46-
groupIds: [state.groupId],
47-
maxFacts: 50,
48-
});
54+
55+
// Task 8: reuse drift-check project facts when available; only issue a new
56+
// project searchFacts call when we don't already have them.
57+
const projectFactsPromise: Promise<SearchFactsResult> =
58+
seedProjectFacts != null
59+
? Promise.resolve(seedProjectFacts)
60+
: client.searchFacts({
61+
query: messageText,
62+
groupIds: [state.groupId],
63+
maxFacts: PROJECT_MAX_FACTS,
64+
});
65+
4966
const projectNodesPromise = client.searchNodes({
5067
query: messageText,
5168
groupIds: [state.groupId],
@@ -66,21 +83,18 @@ export function createChatHandler(deps: ChatHandlerDeps) {
6683
})
6784
: Promise.resolve([]);
6885

69-
const [projectFacts, projectNodes, userFacts, userNodes] = await Promise
70-
.all([
71-
projectFactsPromise,
72-
projectNodesPromise,
73-
userFactsPromise,
74-
userNodesPromise,
75-
]);
76-
77-
const projectContext = deduplicateContext({
78-
facts: projectFacts,
79-
nodes: projectNodes,
80-
});
81-
const userContext = deduplicateContext({
82-
facts: userFacts,
83-
nodes: userNodes,
86+
const {
87+
projectContext,
88+
userContext,
89+
projectFacts,
90+
projectNodes,
91+
userFacts,
92+
userNodes,
93+
} = await resolveProjectUserContext({
94+
projectFacts: projectFactsPromise,
95+
projectNodes: projectNodesPromise,
96+
userFacts: userFactsPromise,
97+
userNodes: userNodesPromise,
8498
});
8599

86100
const visibleSet = new Set(state.visibleFactUuids ?? []);
@@ -143,38 +157,49 @@ export function createChatHandler(deps: ChatHandlerDeps) {
143157
return bTime - aTime;
144158
})[0];
145159
if (snapshot?.content) {
160+
// Task 2: truncate snapshot at a line boundary.
146161
const snapshotBudget = Math.min(characterBudget, 1200);
162+
const snapshotBody = truncateAtLineBoundary(
163+
snapshot.content,
164+
snapshotBudget,
165+
);
147166
snapshotPrimer = [
148167
"## Session Snapshot",
149168
"> Most recent session snapshot; use to restore active strategy and open questions.",
150169
"",
151-
snapshot.content.slice(0, snapshotBudget),
170+
snapshotBody,
152171
].join("\n");
153172
}
154173
} catch (err) {
155174
logger.error("Failed to load session snapshot", { err });
156175
}
157176
}
158177

178+
// Task 2: truncate project/user context strings at line boundaries.
159179
const projectBudget = useUserScope
160180
? Math.floor(characterBudget * 0.7)
161181
: characterBudget;
162182
const userBudget = characterBudget - projectBudget;
163-
const truncatedProject = projectContextString.slice(0, projectBudget);
183+
const truncatedProject = truncateAtLineBoundary(
184+
projectContextString,
185+
projectBudget,
186+
);
164187
const truncatedUser = useUserScope
165-
? userContextString.slice(0, userBudget)
188+
? truncateAtLineBoundary(userContextString, userBudget)
166189
: "";
167-
const memoryContext = [snapshotPrimer, truncatedProject, truncatedUser]
190+
191+
// Task 2: final combined context also truncated at a line boundary.
192+
const combined = [snapshotPrimer, truncatedProject, truncatedUser]
168193
.filter((section) => section.trim().length > 0)
169-
.join("\n\n")
170-
.slice(0, characterBudget);
194+
.join("\n\n");
195+
const memoryContext = truncateAtLineBoundary(combined, characterBudget);
171196
if (!memoryContext) return;
172197

173198
const allFactUuids = [
174199
...projectContext.facts.map((fact) => fact.uuid),
175200
...userContext.facts.map((fact) => fact.uuid),
176201
];
177-
const factUuids = seedFactUuids ?? Array.from(new Set(allFactUuids));
202+
const factUuids = Array.from(new Set(allFactUuids));
178203
state.cachedMemoryContext = memoryContext;
179204
state.cachedFactUuids = factUuids;
180205
logger.info(
@@ -201,10 +226,6 @@ export function createChatHandler(deps: ChatHandlerDeps) {
201226
};
202227

203228
return async ({ sessionID }: ChatMessageInput, output: ChatMessageOutput) => {
204-
if (await sessionManager.isSubagentSession(sessionID)) {
205-
logger.debug("Ignoring subagent chat message:", sessionID);
206-
return;
207-
}
208229
const { state, resolved } = await sessionManager.resolveSessionState(
209230
sessionID,
210231
);
@@ -230,22 +251,25 @@ export function createChatHandler(deps: ChatHandlerDeps) {
230251
});
231252

232253
const shouldInjectOnFirst = !state.injectedMemories;
233-
let shouldReinject = false;
234254

235-
let currentFactUuids: string[] | null = null;
255+
// Task 8: driftFacts from the drift check are passed into
256+
// searchAndCacheMemoryContext so the project searchFacts is not repeated.
257+
let driftProjectFacts: SearchFactsResult | null = null;
258+
236259
if (!shouldInjectOnFirst) {
237260
try {
238-
const driftFacts = await client.searchFacts({
261+
const fetched = await client.searchFacts({
239262
query: messageText,
240263
groupIds: [state.groupId],
241-
maxFacts: 20,
264+
maxFacts: PROJECT_MAX_FACTS,
242265
});
243-
currentFactUuids = driftFacts.map((fact) => fact.uuid);
266+
driftProjectFacts = fetched;
267+
const currentFactUuids = fetched.map((fact) => fact.uuid);
244268
const similarity = computeJaccardSimilarity(
245269
currentFactUuids,
246270
state.lastInjectionFactUuids,
247271
);
248-
shouldReinject = similarity < driftThreshold;
272+
const shouldReinject = similarity < driftThreshold;
249273
if (!shouldReinject) {
250274
logger.debug("Skipping reinjection; similarity above threshold", {
251275
sessionID,
@@ -270,7 +294,10 @@ export function createChatHandler(deps: ChatHandlerDeps) {
270294
messageText,
271295
useUserScope,
272296
characterBudget,
273-
currentFactUuids,
297+
// Task 8: on reinjection, pass the drift facts so the project query is
298+
// not duplicated. On first injection driftProjectFacts is null, which
299+
// triggers a full maxFacts=PROJECT_MAX_FACTS project search.
300+
driftProjectFacts ?? undefined,
274301
);
275302
state.injectedMemories = true;
276303
} catch (err) {

0 commit comments

Comments
 (0)