@oh-my-pi/pi-coding-agent 14.6.2 → 14.6.4
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 +95 -2
- package/README.md +21 -0
- package/package.json +23 -7
- package/src/cli/grievances-cli.ts +89 -4
- package/src/commands/grievances.ts +33 -7
- package/src/config/prompt-templates.ts +14 -7
- package/src/config/settings-schema.ts +610 -100
- package/src/config/settings.ts +42 -0
- package/src/discovery/helpers.ts +13 -6
- package/src/edit/index.ts +3 -3
- package/src/edit/line-hash.ts +73 -25
- package/src/edit/modes/hashline.lark +10 -3
- package/src/edit/modes/hashline.ts +295 -40
- package/src/edit/renderer.ts +3 -3
- package/src/hindsight/backend.ts +205 -0
- package/src/hindsight/bank.ts +131 -0
- package/src/hindsight/client.ts +598 -0
- package/src/hindsight/config.ts +175 -0
- package/src/hindsight/content.ts +210 -0
- package/src/hindsight/index.ts +8 -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/hindsight/transcript.ts +71 -0
- package/src/main.ts +7 -10
- package/src/memories/index.ts +1 -1
- package/src/memory-backend/index.ts +4 -0
- package/src/memory-backend/local-backend.ts +30 -0
- package/src/memory-backend/off-backend.ts +16 -0
- package/src/memory-backend/resolve.ts +24 -0
- package/src/memory-backend/types.ts +79 -0
- package/src/modes/components/settings-defs.ts +50 -451
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/controllers/command-controller.ts +266 -6
- package/src/modes/controllers/event-controller.ts +12 -0
- package/src/modes/controllers/selector-controller.ts +3 -12
- package/src/modes/theme/theme.ts +4 -0
- package/src/prompts/tools/github.md +3 -0
- package/src/prompts/tools/hashline.md +21 -16
- package/src/prompts/tools/read.md +10 -6
- package/src/prompts/tools/recall.md +5 -0
- package/src/prompts/tools/reflect.md +5 -0
- package/src/prompts/tools/retain.md +5 -0
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +21 -9
- package/src/session/agent-session.ts +118 -3
- package/src/slash-commands/builtin-registry.ts +12 -12
- package/src/task/executor.ts +3 -0
- package/src/task/index.ts +2 -0
- package/src/tools/ast-edit.ts +14 -5
- package/src/tools/ast-grep.ts +12 -3
- package/src/tools/find.ts +47 -7
- package/src/tools/gh-renderer.ts +10 -1
- package/src/tools/gh.ts +233 -5
- package/src/tools/hindsight-recall.ts +68 -0
- package/src/tools/hindsight-reflect.ts +55 -0
- package/src/tools/hindsight-retain.ts +60 -0
- package/src/tools/index.ts +20 -0
- package/src/tools/path-utils.ts +55 -0
- package/src/tools/read.ts +1 -1
- package/src/tools/search.ts +45 -8
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull plain-text user/assistant messages out of a session manager.
|
|
3
|
+
*
|
|
4
|
+
* The Hindsight retain/recall API only takes flat `{role, content}` records,
|
|
5
|
+
* so we drop tool calls, tool results, bash execution wrappers, custom
|
|
6
|
+
* messages, and anything else that isn't a primary conversation turn. Each
|
|
7
|
+
* surviving message's `TextContent` parts are joined with newlines.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
11
|
+
import type { SessionEntry } from "../session/session-manager";
|
|
12
|
+
import type { HindsightMessage } from "./content";
|
|
13
|
+
|
|
14
|
+
export interface ReadonlySessionManagerLike {
|
|
15
|
+
getEntries(): SessionEntry[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk session entries top-to-bottom, returning a flat user/assistant list.
|
|
20
|
+
*
|
|
21
|
+
* Implementation choices:
|
|
22
|
+
* - Skip entries whose type isn't `"message"` (compaction, branch_summary,
|
|
23
|
+
* custom_message, tool exec records, ...). Those don't represent a
|
|
24
|
+
* conversational turn, only the LLM's plain-text utterances do.
|
|
25
|
+
* - Skip messages whose role isn't `"user"` or `"assistant"`. We deliberately
|
|
26
|
+
* ignore `toolResult`, `bashExecution`, `hookMessage`, etc. — they're noise
|
|
27
|
+
* for memory purposes.
|
|
28
|
+
* - For assistant messages, only `text` blocks contribute. Thinking and
|
|
29
|
+
* toolCall blocks are intentionally dropped: the user never saw them, so
|
|
30
|
+
* retaining them would prime recall on internal monologue.
|
|
31
|
+
*/
|
|
32
|
+
export function extractMessages(sessionManager: ReadonlySessionManagerLike): HindsightMessage[] {
|
|
33
|
+
const messages: HindsightMessage[] = [];
|
|
34
|
+
|
|
35
|
+
for (const entry of sessionManager.getEntries()) {
|
|
36
|
+
if (entry.type !== "message") continue;
|
|
37
|
+
const msg = entry.message;
|
|
38
|
+
const role = msg.role;
|
|
39
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
40
|
+
|
|
41
|
+
const text = role === "user" ? extractUserText(msg) : extractAssistantText(msg as AssistantMessage);
|
|
42
|
+
if (text.length === 0) continue;
|
|
43
|
+
messages.push({ role, content: text });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return messages;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function extractUserText(msg: { content: unknown }): string {
|
|
50
|
+
const content = msg.content;
|
|
51
|
+
if (typeof content === "string") return content;
|
|
52
|
+
if (!Array.isArray(content)) return "";
|
|
53
|
+
|
|
54
|
+
const parts: string[] = [];
|
|
55
|
+
for (const block of content) {
|
|
56
|
+
if (!block || typeof block !== "object") continue;
|
|
57
|
+
const maybeText = block as { type?: unknown; text?: unknown };
|
|
58
|
+
if (maybeText.type === "text" && typeof maybeText.text === "string") {
|
|
59
|
+
parts.push(maybeText.text);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return parts.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractAssistantText(msg: AssistantMessage): string {
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
for (const block of msg.content) {
|
|
68
|
+
if (block.type === "text" && block.text) parts.push(block.text);
|
|
69
|
+
}
|
|
70
|
+
return parts.join("\n");
|
|
71
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -11,9 +11,8 @@ import * as os from "node:os";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { createInterface } from "node:readline/promises";
|
|
13
13
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
14
|
-
import { $env,
|
|
14
|
+
import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from "@oh-my-pi/pi-utils";
|
|
15
15
|
import chalk from "chalk";
|
|
16
|
-
import { invalidate as invalidateFsCache } from "./capability/fs";
|
|
17
16
|
import type { Args } from "./cli/args";
|
|
18
17
|
import { processFileArguments } from "./cli/file-processor";
|
|
19
18
|
import { buildInitialMessage } from "./cli/initial-message";
|
|
@@ -25,7 +24,7 @@ import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedM
|
|
|
25
24
|
import { getDefault, type SettingPath, Settings, settings } from "./config/settings";
|
|
26
25
|
import { initializeWithSettings } from "./discovery";
|
|
27
26
|
import {
|
|
28
|
-
|
|
27
|
+
clearPluginRootsAndCaches,
|
|
29
28
|
injectPluginDirRoots,
|
|
30
29
|
preloadPluginRoots,
|
|
31
30
|
resolveActiveProjectRegistryPath,
|
|
@@ -90,6 +89,10 @@ const RPC_DEFAULTED_SETTING_PATHS: SettingPath[] = [
|
|
|
90
89
|
"task.maxRecursionDepth",
|
|
91
90
|
"task.disabledAgents",
|
|
92
91
|
"task.agentModelOverrides",
|
|
92
|
+
// Memory subsystems are off-by-default for RPC hosts; embedders that want
|
|
93
|
+
// memory should opt in explicitly through their own settings layer.
|
|
94
|
+
"memory.backend",
|
|
95
|
+
"memories.enabled",
|
|
93
96
|
];
|
|
94
97
|
|
|
95
98
|
function applyRpcDefaultSettingOverrides(): void {
|
|
@@ -758,13 +761,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
758
761
|
projectInstalledRegistryPath: (await resolveActiveProjectRegistryPath(getProjectDir())) ?? undefined,
|
|
759
762
|
marketplacesCacheDir: getMarketplacesCacheDir(),
|
|
760
763
|
pluginsCacheDir: getPluginsCacheDir(),
|
|
761
|
-
clearPluginRootsCache:
|
|
762
|
-
const h = os.homedir();
|
|
763
|
-
invalidateFsCache(path.join(h, ".claude", "plugins", "installed_plugins.json"));
|
|
764
|
-
invalidateFsCache(path.join(h, getConfigDirName(), "plugins", "installed_plugins.json"));
|
|
765
|
-
for (const p of extraPaths ?? []) invalidateFsCache(p);
|
|
766
|
-
clearClaudePluginRootsCache();
|
|
767
|
-
},
|
|
764
|
+
clearPluginRootsCache: clearPluginRootsAndCaches,
|
|
768
765
|
});
|
|
769
766
|
await mgr.refreshStaleMarketplaces();
|
|
770
767
|
const updates = await mgr.checkForUpdates();
|
package/src/memories/index.ts
CHANGED
|
@@ -1077,7 +1077,7 @@ async function resolveMemoryModel(options: {
|
|
|
1077
1077
|
|
|
1078
1078
|
function loadMemoryConfig(settings: Settings): MemoryRuntimeConfig {
|
|
1079
1079
|
return {
|
|
1080
|
-
enabled: settings.get("memories.enabled")
|
|
1080
|
+
enabled: settings.get("memory.backend") === "local" || settings.get("memories.enabled") === true,
|
|
1081
1081
|
maxRolloutsPerStartup: settings.get("memories.maxRolloutsPerStartup") ?? DEFAULTS.maxRolloutsPerStartup,
|
|
1082
1082
|
maxRolloutAgeDays: settings.get("memories.maxRolloutAgeDays") ?? DEFAULTS.maxRolloutAgeDays,
|
|
1083
1083
|
minRolloutIdleHours: settings.get("memories.minRolloutIdleHours") ?? DEFAULTS.minRolloutIdleHours,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildMemoryToolDeveloperInstructions,
|
|
3
|
+
clearMemoryData,
|
|
4
|
+
enqueueMemoryConsolidation,
|
|
5
|
+
startMemoryStartupTask,
|
|
6
|
+
} from "../memories";
|
|
7
|
+
import type { MemoryBackend } from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Wraps the existing `memories/` module as a `MemoryBackend`.
|
|
11
|
+
*
|
|
12
|
+
* No behavioural change — every call delegates to the legacy entry points so
|
|
13
|
+
* the local memory pipeline (rollout summarisation → SQLite → memory_summary.md)
|
|
14
|
+
* keeps working exactly as before.
|
|
15
|
+
*/
|
|
16
|
+
export const localBackend: MemoryBackend = {
|
|
17
|
+
id: "local",
|
|
18
|
+
start(options) {
|
|
19
|
+
startMemoryStartupTask(options);
|
|
20
|
+
},
|
|
21
|
+
async buildDeveloperInstructions(agentDir, settings) {
|
|
22
|
+
return buildMemoryToolDeveloperInstructions(agentDir, settings);
|
|
23
|
+
},
|
|
24
|
+
async clear(agentDir, cwd) {
|
|
25
|
+
await clearMemoryData(agentDir, cwd);
|
|
26
|
+
},
|
|
27
|
+
async enqueue(agentDir, cwd) {
|
|
28
|
+
enqueueMemoryConsolidation(agentDir, cwd);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { MemoryBackend } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* No-op memory backend.
|
|
5
|
+
*
|
|
6
|
+
* Selected when `memory.backend` is `"off"`.
|
|
7
|
+
*/
|
|
8
|
+
export const offBackend: MemoryBackend = {
|
|
9
|
+
id: "off",
|
|
10
|
+
async start() {},
|
|
11
|
+
async buildDeveloperInstructions() {
|
|
12
|
+
return undefined;
|
|
13
|
+
},
|
|
14
|
+
async clear() {},
|
|
15
|
+
async enqueue() {},
|
|
16
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Settings } from "../config/settings";
|
|
2
|
+
import { hindsightBackend } from "../hindsight";
|
|
3
|
+
import { localBackend } from "./local-backend";
|
|
4
|
+
import { offBackend } from "./off-backend";
|
|
5
|
+
import type { MemoryBackend } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pick the active memory backend for a Settings instance.
|
|
9
|
+
*
|
|
10
|
+
* Selection rules (single source of truth — every memory consumer routes
|
|
11
|
+
* through this):
|
|
12
|
+
* - `memory.backend === "hindsight"` → Hindsight remote memory
|
|
13
|
+
* - `memory.backend === "local"` → local pipeline
|
|
14
|
+
* - everything else → no-op
|
|
15
|
+
*
|
|
16
|
+
* `memories.enabled` remains accepted only as a legacy migration input. Once
|
|
17
|
+
* a config is loaded, `memory.backend` is the sole runtime selector.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveMemoryBackend(settings: Settings): MemoryBackend {
|
|
20
|
+
const id = settings.get("memory.backend");
|
|
21
|
+
if (id === "hindsight") return hindsightBackend;
|
|
22
|
+
if (id === "local") return localBackend;
|
|
23
|
+
return offBackend;
|
|
24
|
+
}
|