@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.
- package/CHANGELOG.md +24 -0
- package/package.json +7 -7
- package/src/config/settings-schema.ts +25 -0
- package/src/edit/modes/hashline.ts +191 -2
- package/src/hindsight/backend.ts +85 -324
- package/src/hindsight/client.ts +153 -0
- package/src/hindsight/config.ts +10 -0
- package/src/hindsight/content.ts +9 -4
- package/src/hindsight/index.ts +2 -0
- package/src/hindsight/mental-models.ts +382 -0
- package/src/hindsight/seeds.json +32 -0
- package/src/hindsight/state.ts +469 -0
- package/src/memory-backend/types.ts +14 -4
- package/src/modes/controllers/command-controller.ts +263 -4
- package/src/modes/controllers/input-controller.ts +9 -4
- package/src/modes/interactive-mode.ts +33 -3
- package/src/modes/types.ts +13 -0
- package/src/modes/utils/ui-helpers.ts +22 -15
- package/src/prompts/tools/hashline.md +1 -0
- package/src/sdk.ts +10 -1
- package/src/session/agent-session.ts +44 -1
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/task/executor.ts +3 -0
- package/src/task/index.ts +2 -0
- package/src/tools/hindsight-recall.ts +1 -3
- package/src/tools/hindsight-reflect.ts +1 -3
- package/src/tools/hindsight-retain.ts +6 -9
- package/src/tools/index.ts +3 -0
- package/src/hindsight/retain-queue.ts +0 -166
|
@@ -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(
|
|
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?(
|
|
74
|
+
preCompactionContext?(
|
|
75
|
+
messages: AgentMessage[],
|
|
76
|
+
settings: Settings,
|
|
77
|
+
session?: AgentSession,
|
|
78
|
+
): Promise<string | undefined>;
|
|
69
79
|
}
|