@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -2
  2. package/README.md +21 -0
  3. package/package.json +23 -7
  4. package/src/cli/grievances-cli.ts +89 -4
  5. package/src/commands/grievances.ts +33 -7
  6. package/src/config/prompt-templates.ts +14 -7
  7. package/src/config/settings-schema.ts +610 -100
  8. package/src/config/settings.ts +42 -0
  9. package/src/discovery/helpers.ts +13 -6
  10. package/src/edit/index.ts +3 -3
  11. package/src/edit/line-hash.ts +73 -25
  12. package/src/edit/modes/hashline.lark +10 -3
  13. package/src/edit/modes/hashline.ts +295 -40
  14. package/src/edit/renderer.ts +3 -3
  15. package/src/hindsight/backend.ts +205 -0
  16. package/src/hindsight/bank.ts +131 -0
  17. package/src/hindsight/client.ts +598 -0
  18. package/src/hindsight/config.ts +175 -0
  19. package/src/hindsight/content.ts +210 -0
  20. package/src/hindsight/index.ts +8 -0
  21. package/src/hindsight/mental-models.ts +382 -0
  22. package/src/hindsight/seeds.json +32 -0
  23. package/src/hindsight/state.ts +469 -0
  24. package/src/hindsight/transcript.ts +71 -0
  25. package/src/main.ts +7 -10
  26. package/src/memories/index.ts +1 -1
  27. package/src/memory-backend/index.ts +4 -0
  28. package/src/memory-backend/local-backend.ts +30 -0
  29. package/src/memory-backend/off-backend.ts +16 -0
  30. package/src/memory-backend/resolve.ts +24 -0
  31. package/src/memory-backend/types.ts +79 -0
  32. package/src/modes/components/settings-defs.ts +50 -451
  33. package/src/modes/components/settings-selector.ts +2 -2
  34. package/src/modes/components/status-line/presets.ts +1 -1
  35. package/src/modes/controllers/command-controller.ts +266 -6
  36. package/src/modes/controllers/event-controller.ts +12 -0
  37. package/src/modes/controllers/selector-controller.ts +3 -12
  38. package/src/modes/theme/theme.ts +4 -0
  39. package/src/prompts/tools/github.md +3 -0
  40. package/src/prompts/tools/hashline.md +21 -16
  41. package/src/prompts/tools/read.md +10 -6
  42. package/src/prompts/tools/recall.md +5 -0
  43. package/src/prompts/tools/reflect.md +5 -0
  44. package/src/prompts/tools/retain.md +5 -0
  45. package/src/prompts/tools/search.md +1 -1
  46. package/src/sdk.ts +21 -9
  47. package/src/session/agent-session.ts +118 -3
  48. package/src/slash-commands/builtin-registry.ts +12 -12
  49. package/src/task/executor.ts +3 -0
  50. package/src/task/index.ts +2 -0
  51. package/src/tools/ast-edit.ts +14 -5
  52. package/src/tools/ast-grep.ts +12 -3
  53. package/src/tools/find.ts +47 -7
  54. package/src/tools/gh-renderer.ts +10 -1
  55. package/src/tools/gh.ts +233 -5
  56. package/src/tools/hindsight-recall.ts +68 -0
  57. package/src/tools/hindsight-reflect.ts +55 -0
  58. package/src/tools/hindsight-retain.ts +60 -0
  59. package/src/tools/index.ts +20 -0
  60. package/src/tools/path-utils.ts +55 -0
  61. package/src/tools/read.ts +1 -1
  62. 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, getConfigDirName, getProjectDir, logger, postmortem, setProjectDir, VERSION } from "@oh-my-pi/pi-utils";
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
- clearClaudePluginRootsCache,
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: (extraPaths?: readonly string[]) => {
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();
@@ -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") ?? DEFAULTS.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,4 @@
1
+ export * from "./local-backend";
2
+ export * from "./off-backend";
3
+ export * from "./resolve";
4
+ export * from "./types";
@@ -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
+ }