@oh-my-pi/pi-coding-agent 14.6.3 → 14.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,469 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+ import type { AgentSession } from "../session/agent-session";
3
+ import { type BankScope, ensureBankMission } from "./bank";
4
+ import type { HindsightApi, MemoryItemInput } from "./client";
5
+ import type { HindsightConfig } from "./config";
6
+ import {
7
+ composeRecallQuery,
8
+ formatCurrentTime,
9
+ formatMemories,
10
+ type HindsightMessage,
11
+ prepareRetentionTranscript,
12
+ sliceLastTurnsByUserBoundary,
13
+ truncateRecallQuery,
14
+ } from "./content";
15
+ import {
16
+ ensureMentalModels,
17
+ loadMentalModelsBlock,
18
+ MENTAL_MODEL_FIRST_TURN_DEADLINE_MS,
19
+ resolveSeedsForScope,
20
+ } from "./mental-models";
21
+ import { extractMessages } from "./transcript";
22
+
23
+ const RETAIN_FLUSH_BATCH_SIZE = 16;
24
+ const RETAIN_FLUSH_INTERVAL_MS = 5_000;
25
+
26
+ interface PendingRetainItem {
27
+ content: string;
28
+ context?: string;
29
+ }
30
+
31
+ interface RecallOutcome {
32
+ context: string | null;
33
+ ok: boolean;
34
+ }
35
+
36
+ export interface HindsightSessionStateOptions {
37
+ /** Session id used for retain-queue metadata. */
38
+ sessionId: string;
39
+ client: HindsightApi;
40
+ bankId: string;
41
+ /** Tags applied to every retain — non-empty in per-project-tagged mode. */
42
+ retainTags?: string[];
43
+ /** Tag filter applied to every recall/reflect — non-empty in per-project-tagged mode. */
44
+ recallTags?: string[];
45
+ recallTagsMatch?: "any" | "all" | "any_strict" | "all_strict";
46
+ config: HindsightConfig;
47
+ session: AgentSession;
48
+ missionsSet: Set<string>;
49
+ lastRetainedTurn?: number;
50
+ hasRecalledForFirstTurn?: boolean;
51
+ /**
52
+ * When set, this entry is a subagent alias that reuses the parent's bank,
53
+ * scope, config, client, and missionsSet. Aliases skip auto-recall and
54
+ * auto-retain — those run on the parent only — but the recall/retain/reflect
55
+ * tools resolve via the alias so they persist to the same bank as the parent.
56
+ */
57
+ aliasOf?: HindsightSessionState;
58
+ }
59
+
60
+ /**
61
+ * Debounced batch queue for tool-initiated `retain` calls owned by one
62
+ * Hindsight session state instance.
63
+ *
64
+ * Auto-retain (`HindsightSessionState.retainSession`) is intentionally not
65
+ * routed through this queue — it submits a full transcript as one large item
66
+ * and already runs `async: true` server-side.
67
+ */
68
+ export class HindsightRetainQueue {
69
+ readonly #state: HindsightSessionState;
70
+ #items: PendingRetainItem[] = [];
71
+ #timer?: NodeJS.Timeout;
72
+ #flushing?: Promise<void>;
73
+ #closed = false;
74
+
75
+ constructor(state: HindsightSessionState) {
76
+ this.#state = state;
77
+ }
78
+
79
+ get depth(): number {
80
+ return this.#items.length;
81
+ }
82
+
83
+ enqueue(content: string, context?: string): void {
84
+ if (this.#closed) {
85
+ throw new Error("Hindsight retain queue is closed.");
86
+ }
87
+ this.#items.push({ content, context });
88
+
89
+ if (this.#items.length >= RETAIN_FLUSH_BATCH_SIZE) {
90
+ void this.flush();
91
+ return;
92
+ }
93
+ if (!this.#timer) {
94
+ this.#timer = setTimeout(() => {
95
+ void this.flush();
96
+ }, RETAIN_FLUSH_INTERVAL_MS);
97
+ // Don't pin the event loop alive just for a pending retain flush.
98
+ this.#timer.unref?.();
99
+ }
100
+ }
101
+
102
+ async flush(): Promise<void> {
103
+ if (this.#timer) {
104
+ clearTimeout(this.#timer);
105
+ this.#timer = undefined;
106
+ }
107
+
108
+ if (this.#flushing) {
109
+ // Coalesce: wait for the in-flight flush, then drain anything that
110
+ // landed after it started so we don't strand items.
111
+ await this.#flushing;
112
+ if (this.#items.length > 0) await this.flush();
113
+ return;
114
+ }
115
+
116
+ if (this.#items.length === 0) return;
117
+
118
+ const items = this.#items.splice(0);
119
+ const flushPromise = this.#doFlush(items);
120
+ this.#flushing = flushPromise;
121
+ try {
122
+ await flushPromise;
123
+ } finally {
124
+ this.#flushing = undefined;
125
+ }
126
+ }
127
+
128
+ dispose(): void {
129
+ this.#closed = true;
130
+ if (this.#timer) {
131
+ clearTimeout(this.#timer);
132
+ this.#timer = undefined;
133
+ }
134
+ this.#items = [];
135
+ }
136
+
137
+ async #doFlush(items: PendingRetainItem[]): Promise<void> {
138
+ const state = this.#state;
139
+ const sessionId = state.sessionId;
140
+ if (state.session.getHindsightSessionState() !== state) {
141
+ // Session went away before we could flush. We can't notify anyone, so
142
+ // log and drop — these are best-effort facts, not transactional writes.
143
+ logger.warn("Hindsight retain queue: session vanished, dropping batch", {
144
+ sessionId,
145
+ items: items.length,
146
+ });
147
+ return;
148
+ }
149
+
150
+ try {
151
+ await ensureBankMission(state.client, state.bankId, state.config, state.missionsSet);
152
+ const batch: MemoryItemInput[] = items.map(item => ({
153
+ content: item.content,
154
+ context: item.context ?? state.config.retainContext,
155
+ metadata: { session_id: sessionId },
156
+ tags: state.retainTags,
157
+ }));
158
+ await state.client.retainBatch(state.bankId, batch, { async: true });
159
+ if (state.config.debug) {
160
+ logger.debug("Hindsight retain queue: batch flushed", {
161
+ sessionId,
162
+ bankId: state.bankId,
163
+ items: items.length,
164
+ });
165
+ }
166
+ } catch (err) {
167
+ const errorText = err instanceof Error ? err.message : String(err);
168
+ logger.warn("Hindsight retain queue: batch flush failed", {
169
+ sessionId,
170
+ bankId: state.bankId,
171
+ items: items.length,
172
+ error: errorText,
173
+ });
174
+ this.#notifyRetainFailure(items.length, errorText);
175
+ }
176
+ }
177
+
178
+ #notifyRetainFailure(count: number, errorText: string): void {
179
+ const noun = count === 1 ? "memory" : "memories";
180
+ this.#state.session.emitNotice(
181
+ "warning",
182
+ `Memory retention failed for ${count} ${noun}: ${errorText}`,
183
+ "Hindsight",
184
+ );
185
+ }
186
+ }
187
+
188
+ /** Per-session Hindsight runtime state owned by its AgentSession. */
189
+ export class HindsightSessionState {
190
+ /** Session id used for retain-queue metadata. */
191
+ sessionId: string;
192
+ client: HindsightApi;
193
+ bankId: string;
194
+ /** Tags applied to every retain — non-empty in per-project-tagged mode. */
195
+ retainTags?: string[];
196
+ /** Tag filter applied to every recall/reflect — non-empty in per-project-tagged mode. */
197
+ recallTags?: string[];
198
+ recallTagsMatch?: "any" | "all" | "any_strict" | "all_strict";
199
+ config: HindsightConfig;
200
+ session: AgentSession;
201
+ missionsSet: Set<string>;
202
+ lastRetainedTurn: number;
203
+ hasRecalledForFirstTurn: boolean;
204
+ lastRecallSnippet?: string;
205
+ /** Cached `<mental_models>` block injected into developer instructions. */
206
+ mentalModelsSnippet?: string;
207
+ /** When the cached snippet was last refreshed; gates the agent_end re-list. */
208
+ mentalModelsLoadedAt?: number;
209
+ /**
210
+ * In-flight ensure+load promise. `beforeAgentStartPrompt` awaits this on
211
+ * the first turn so the MM block lands in the system prompt before the
212
+ * LLM generates, even though `start()` returns before the load completes.
213
+ */
214
+ mentalModelsLoadPromise?: Promise<void>;
215
+ unsubscribe?: () => void;
216
+ /** Alias states delegate persistence config to a primary parent state. */
217
+ aliasOf?: HindsightSessionState;
218
+ readonly retainQueue: HindsightRetainQueue;
219
+
220
+ constructor(options: HindsightSessionStateOptions) {
221
+ this.sessionId = options.sessionId;
222
+ this.client = options.client;
223
+ this.bankId = options.bankId;
224
+ this.retainTags = options.retainTags;
225
+ this.recallTags = options.recallTags;
226
+ this.recallTagsMatch = options.recallTagsMatch;
227
+ this.config = options.config;
228
+ this.session = options.session;
229
+ this.missionsSet = options.missionsSet;
230
+ this.lastRetainedTurn = options.lastRetainedTurn ?? 0;
231
+ this.hasRecalledForFirstTurn = options.hasRecalledForFirstTurn ?? false;
232
+ this.aliasOf = options.aliasOf;
233
+ this.retainQueue = new HindsightRetainQueue(this);
234
+ }
235
+
236
+ setSessionId(sessionId: string): void {
237
+ this.sessionId = sessionId;
238
+ }
239
+
240
+ resetConversationTracking(): void {
241
+ this.lastRetainedTurn = 0;
242
+ this.hasRecalledForFirstTurn = false;
243
+ this.lastRecallSnippet = undefined;
244
+ }
245
+
246
+ enqueueRetain(content: string, context?: string): void {
247
+ this.retainQueue.enqueue(content, context);
248
+ }
249
+
250
+ async flushRetainQueue(): Promise<void> {
251
+ await this.retainQueue.flush();
252
+ }
253
+
254
+ async recallForContext(query: string, signal?: AbortSignal): Promise<RecallOutcome> {
255
+ try {
256
+ const response = await this.client.recall(this.bankId, query, {
257
+ budget: this.config.recallBudget,
258
+ maxTokens: this.config.recallMaxTokens,
259
+ types: this.config.recallTypes.length > 0 ? this.config.recallTypes : undefined,
260
+ tags: this.recallTags,
261
+ tagsMatch: this.recallTagsMatch,
262
+ });
263
+ if (signal?.aborted) return { context: null, ok: false };
264
+ const results = response.results ?? [];
265
+ if (results.length === 0) return { context: null, ok: true };
266
+ const formatted = formatMemories(results);
267
+ const block = `<memories>\n${this.config.recallPromptPreamble}\nCurrent time: ${formatCurrentTime()} UTC\n\n${formatted}\n</memories>`;
268
+ return { context: block, ok: true };
269
+ } catch (err) {
270
+ if (this.config.debug) {
271
+ logger.debug("Hindsight: recall failed", { bankId: this.bankId, error: String(err) });
272
+ }
273
+ return { context: null, ok: false };
274
+ }
275
+ }
276
+
277
+ async retainSession(messages: HindsightMessage[]): Promise<void> {
278
+ const retainFullWindow = this.config.retainMode === "full-session";
279
+ let target: HindsightMessage[];
280
+ let documentId: string;
281
+
282
+ if (retainFullWindow) {
283
+ target = messages;
284
+ documentId = this.sessionId;
285
+ } else {
286
+ const windowTurns = this.config.retainEveryNTurns + this.config.retainOverlapTurns;
287
+ target = sliceLastTurnsByUserBoundary(messages, windowTurns);
288
+ documentId = `${this.sessionId}-${Date.now()}`;
289
+ }
290
+
291
+ const { transcript } = prepareRetentionTranscript(target, true);
292
+ if (!transcript) return;
293
+
294
+ await ensureBankMission(this.client, this.bankId, this.config, this.missionsSet);
295
+ await this.client.retain(this.bankId, transcript, {
296
+ documentId,
297
+ context: this.config.retainContext,
298
+ metadata: { session_id: this.sessionId },
299
+ tags: this.retainTags,
300
+ async: true,
301
+ });
302
+ }
303
+
304
+ async maybeRetainOnAgentEnd(): Promise<void> {
305
+ if (!this.config.autoRetain) return;
306
+ const messages = extractMessages(this.session.sessionManager);
307
+ if (messages.length === 0) return;
308
+ const userTurns = messages.filter(m => m.role === "user").length;
309
+ if (userTurns - this.lastRetainedTurn < this.config.retainEveryNTurns) return;
310
+
311
+ try {
312
+ await this.retainSession(messages);
313
+ this.lastRetainedTurn = userTurns;
314
+ if (this.config.debug) {
315
+ logger.debug("Hindsight: auto-retain succeeded", {
316
+ sessionId: this.sessionId,
317
+ bankId: this.bankId,
318
+ userTurns,
319
+ messages: messages.length,
320
+ });
321
+ }
322
+ } catch (err) {
323
+ logger.warn("Hindsight: auto-retain failed", {
324
+ sessionId: this.sessionId,
325
+ bankId: this.bankId,
326
+ error: String(err),
327
+ });
328
+ }
329
+ }
330
+
331
+ async forceRetainCurrentSession(): Promise<void> {
332
+ const messages = extractMessages(this.session.sessionManager);
333
+ if (messages.length === 0) return;
334
+ try {
335
+ await this.retainSession(messages);
336
+ this.lastRetainedTurn = messages.filter(m => m.role === "user").length;
337
+ } catch (err) {
338
+ logger.warn("Hindsight: forced retain failed", {
339
+ sessionId: this.sessionId,
340
+ bankId: this.bankId,
341
+ error: String(err),
342
+ });
343
+ }
344
+ }
345
+
346
+ async maybeRecallOnAgentStart(): Promise<void> {
347
+ if (!this.config.autoRecall || this.hasRecalledForFirstTurn) return;
348
+ const messages = extractMessages(this.session.sessionManager);
349
+ const lastUser = [...messages].reverse().find(m => m.role === "user");
350
+ if (!lastUser) return;
351
+
352
+ const query = composeRecallQuery(lastUser.content, messages, this.config.recallContextTurns);
353
+ const truncated = truncateRecallQuery(query, lastUser.content, this.config.recallMaxQueryChars);
354
+ const { context, ok } = await this.recallForContext(truncated);
355
+ if (!ok) return;
356
+
357
+ this.hasRecalledForFirstTurn = true;
358
+ if (!context) return;
359
+
360
+ this.lastRecallSnippet = context;
361
+ await this.#refreshBaseSystemPromptAfter("recall");
362
+ }
363
+
364
+ async beforeAgentStartPrompt(promptText: string): Promise<string | undefined> {
365
+ if (this.config.mentalModelsEnabled && this.mentalModelsLoadPromise && this.mentalModelsLoadedAt === undefined) {
366
+ await Promise.race([this.mentalModelsLoadPromise, Bun.sleep(MENTAL_MODEL_FIRST_TURN_DEADLINE_MS)]);
367
+ }
368
+
369
+ if (!this.config.autoRecall || this.hasRecalledForFirstTurn) return undefined;
370
+
371
+ const latestPrompt = promptText.trim();
372
+ if (!latestPrompt) return undefined;
373
+
374
+ const history = extractMessages(this.session.sessionManager);
375
+ const queryMessages = [...history, { role: "user" as const, content: latestPrompt }];
376
+ const query = composeRecallQuery(latestPrompt, queryMessages, this.config.recallContextTurns);
377
+ const truncated = truncateRecallQuery(query, latestPrompt, this.config.recallMaxQueryChars);
378
+ const { context, ok } = await this.recallForContext(truncated);
379
+ if (!ok) return undefined;
380
+
381
+ this.hasRecalledForFirstTurn = true;
382
+ if (!context) return undefined;
383
+
384
+ this.lastRecallSnippet = context;
385
+ return context;
386
+ }
387
+
388
+ async recallForCompaction(messages: HindsightMessage[]): Promise<string | undefined> {
389
+ const lastUser = [...messages].reverse().find(m => m.role === "user");
390
+ if (!lastUser) return undefined;
391
+
392
+ const query = composeRecallQuery(lastUser.content, messages, this.config.recallContextTurns);
393
+ const truncated = truncateRecallQuery(query, lastUser.content, this.config.recallMaxQueryChars);
394
+ const { context } = await this.recallForContext(truncated);
395
+ return context ?? undefined;
396
+ }
397
+
398
+ async runMentalModelLoad(scope: BankScope): Promise<void> {
399
+ if (!this.config.mentalModelsEnabled) return;
400
+
401
+ // Seeding is opt-in (`hindsight.mentalModelAutoSeed`). Default behaviour is
402
+ // read-only: we surface whatever models the operator has curated on the
403
+ // bank, but we do NOT POST to create new ones unless they explicitly
404
+ // asked. `/memory mm seed` remains the explicit-write entry point.
405
+ if (this.config.mentalModelAutoSeed) {
406
+ const seeds = resolveSeedsForScope(scope, this.config.scoping);
407
+ if (seeds.length > 0) {
408
+ await ensureMentalModels(this.client, this.bankId, seeds, this.config.debug);
409
+ }
410
+ }
411
+
412
+ await this.refreshMentalModelsSnippet();
413
+ await this.#refreshBaseSystemPromptAfter("MM load");
414
+ }
415
+
416
+ async refreshMentalModelsSnippet(): Promise<void> {
417
+ const snippet = await loadMentalModelsBlock(this.client, this.bankId, this.config.mentalModelMaxRenderChars);
418
+ this.mentalModelsSnippet = snippet;
419
+ this.mentalModelsLoadedAt = Date.now();
420
+ }
421
+
422
+ async reloadMentalModels(): Promise<boolean> {
423
+ if (this.aliasOf) return false;
424
+ if (!this.config.mentalModelsEnabled) return false;
425
+ await this.refreshMentalModelsSnippet();
426
+ await this.#refreshBaseSystemPromptAfter("MM reload");
427
+ return true;
428
+ }
429
+
430
+ attachSessionListeners(): void {
431
+ this.unsubscribe?.();
432
+ this.unsubscribe = this.session.subscribe(event => {
433
+ if (event.type === "agent_start") {
434
+ void this.maybeRecallOnAgentStart();
435
+ } else if (event.type === "agent_end") {
436
+ void this.maybeRetainOnAgentEnd();
437
+ // Drain any queued tool-initiated retain calls now that the turn
438
+ // is settled. The queue is also debounced/size-bounded, but
439
+ // flushing here keeps the bank fresh between turns.
440
+ void this.flushRetainQueue();
441
+ // MM TTL refresh: re-list once we're past the cache deadline. List
442
+ // is cheap (no reflect call); the LLM doesn't see this happen.
443
+ if (
444
+ this.config.mentalModelsEnabled &&
445
+ this.mentalModelsLoadedAt !== undefined &&
446
+ Date.now() - this.mentalModelsLoadedAt >= this.config.mentalModelRefreshIntervalMs
447
+ ) {
448
+ void this.refreshMentalModelsSnippet().then(async () => {
449
+ await this.#refreshBaseSystemPromptAfter("MM TTL reload");
450
+ });
451
+ }
452
+ }
453
+ });
454
+ }
455
+
456
+ dispose(): void {
457
+ this.unsubscribe?.();
458
+ this.unsubscribe = undefined;
459
+ this.retainQueue.dispose();
460
+ }
461
+
462
+ async #refreshBaseSystemPromptAfter(reason: "recall" | "MM load" | "MM reload" | "MM TTL reload"): Promise<void> {
463
+ try {
464
+ await this.session.refreshBaseSystemPrompt();
465
+ } catch (err) {
466
+ logger.debug(`Hindsight: refreshBaseSystemPrompt after ${reason} failed`, { error: String(err) });
467
+ }
468
+ }
469
+ }
@@ -9,6 +9,7 @@
9
9
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
10
10
  import type { ModelRegistry } from "../config/model-registry";
11
11
  import type { Settings } from "../config/settings";
12
+ import type { HindsightSessionState } from "../hindsight/state";
12
13
  import type { AgentSession } from "../session/agent-session";
13
14
 
14
15
  export type MemoryBackendId = "off" | "local" | "hindsight";
@@ -19,6 +20,7 @@ export interface MemoryBackendStartOptions {
19
20
  modelRegistry: ModelRegistry;
20
21
  agentDir: string;
21
22
  taskDepth: number;
23
+ parentHindsightSessionState?: HindsightSessionState;
22
24
  }
23
25
 
24
26
  export interface MemoryBackend {
@@ -37,13 +39,17 @@ export interface MemoryBackend {
37
39
  * Markdown injected as the system-prompt append section.
38
40
  * Returned on every prompt rebuild via `refreshBaseSystemPrompt()`.
39
41
  */
40
- buildDeveloperInstructions(agentDir: string, settings: Settings): Promise<string | undefined>;
42
+ buildDeveloperInstructions(
43
+ agentDir: string,
44
+ settings: Settings,
45
+ session?: AgentSession,
46
+ ): Promise<string | undefined>;
41
47
 
42
48
  /** Wipe all persisted state for this backend (slash `/memory clear`). */
43
- clear(agentDir: string, cwd: string): Promise<void>;
49
+ clear(agentDir: string, cwd: string, session?: AgentSession): Promise<void>;
44
50
 
45
51
  /** Force consolidation/retain to happen now (slash `/memory enqueue`). */
46
- enqueue(agentDir: string, cwd: string): Promise<void>;
52
+ enqueue(agentDir: string, cwd: string, session?: AgentSession): Promise<void>;
47
53
 
48
54
  /**
49
55
  * Optional hook to inject a backend-specific block into the current turn's
@@ -65,5 +71,9 @@ export interface MemoryBackend {
65
71
  * to inject nothing — the local backend takes this branch because its
66
72
  * summary is already part of the system prompt.
67
73
  */
68
- preCompactionContext?(messages: AgentMessage[], settings: Settings): Promise<string | undefined>;
74
+ preCompactionContext?(
75
+ messages: AgentMessage[],
76
+ settings: Settings,
77
+ session?: AgentSession,
78
+ ): Promise<string | undefined>;
69
79
  }