11import type { Hooks } from "@opencode-ai/plugin" ;
22import type { GraphitiClient } from "../services/client.ts" ;
33import { calculateInjectionBudget } from "../services/context-limit.ts" ;
4+ import { PROJECT_MAX_FACTS } from "../services/constants.ts" ;
45import {
5- deduplicateContext ,
66 formatMemoryContext ,
7+ resolveProjectUserContext ,
78} from "../services/context.ts" ;
89import { logger } from "../services/logger.ts" ;
910import type { SessionManager } from "../session.ts" ;
10- import { extractTextFromParts } from "../utils.ts" ;
11+ import { extractTextFromParts , truncateAtLineBoundary } from "../utils.ts" ;
1112
1213type ChatMessageHook = NonNullable < Hooks [ "chat.message" ] > ;
1314type ChatMessageInput = Parameters < ChatMessageHook > [ 0 ] ;
1415type ChatMessageOutput = Parameters < ChatMessageHook > [ 1 ] ;
16+ type SearchFactsResult = Awaited < ReturnType < GraphitiClient [ "searchFacts" ] > > ;
1517
1618/** Dependencies for the chat message handler. */
1719export interface ChatHandlerDeps {
@@ -25,6 +27,14 @@ export interface ChatHandlerDeps {
2527export 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