@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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved Hindsight runtime configuration.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth precedence (last wins):
|
|
5
|
+
* 1. Built-in defaults
|
|
6
|
+
* 2. Settings (`hindsight.*` schema entries via `Settings.get(...)`)
|
|
7
|
+
* 3. `HINDSIGHT_*` environment variables
|
|
8
|
+
*
|
|
9
|
+
* Env wins because operators frequently override per-shell (CI, prod) without
|
|
10
|
+
* touching the persisted settings file.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
14
|
+
import type { Settings } from "../config/settings";
|
|
15
|
+
|
|
16
|
+
export type HindsightScoping = "global" | "per-project" | "per-project-tagged";
|
|
17
|
+
|
|
18
|
+
export interface HindsightConfig {
|
|
19
|
+
hindsightApiUrl: string | null;
|
|
20
|
+
hindsightApiToken: string | null;
|
|
21
|
+
|
|
22
|
+
bankId: string | null;
|
|
23
|
+
bankIdPrefix: string;
|
|
24
|
+
scoping: HindsightScoping;
|
|
25
|
+
bankMission: string;
|
|
26
|
+
retainMission: string | null;
|
|
27
|
+
|
|
28
|
+
autoRecall: boolean;
|
|
29
|
+
autoRetain: boolean;
|
|
30
|
+
|
|
31
|
+
retainMode: "full-session" | "last-turn";
|
|
32
|
+
retainEveryNTurns: number;
|
|
33
|
+
retainOverlapTurns: number;
|
|
34
|
+
retainContext: string;
|
|
35
|
+
|
|
36
|
+
recallBudget: "low" | "mid" | "high";
|
|
37
|
+
recallMaxTokens: number;
|
|
38
|
+
recallTypes: string[];
|
|
39
|
+
recallContextTurns: number;
|
|
40
|
+
recallMaxQueryChars: number;
|
|
41
|
+
recallPromptPreamble: string;
|
|
42
|
+
|
|
43
|
+
debug: boolean;
|
|
44
|
+
|
|
45
|
+
mentalModelsEnabled: boolean;
|
|
46
|
+
mentalModelAutoSeed: boolean;
|
|
47
|
+
mentalModelRefreshIntervalMs: number;
|
|
48
|
+
mentalModelMaxRenderChars: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const VALID_RETAIN_MODES: HindsightConfig["retainMode"][] = ["full-session", "last-turn"];
|
|
52
|
+
const VALID_BUDGETS: HindsightConfig["recallBudget"][] = ["low", "mid", "high"];
|
|
53
|
+
const VALID_SCOPINGS: HindsightScoping[] = ["global", "per-project", "per-project-tagged"];
|
|
54
|
+
|
|
55
|
+
const DEFAULT_PREAMBLE =
|
|
56
|
+
"Relevant memories from past conversations (prioritize recent when conflicting). " +
|
|
57
|
+
"Only use memories that are directly useful to continue this conversation; ignore the rest:";
|
|
58
|
+
|
|
59
|
+
/** Coerce an env var value into a boolean using the OpenCode plugin's semantics. */
|
|
60
|
+
function envBool(value: string | undefined): boolean | undefined {
|
|
61
|
+
if (value === undefined) return undefined;
|
|
62
|
+
return ["true", "1", "yes"].includes(value.toLowerCase());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Coerce an env var value into an int, returning undefined for non-numeric input. */
|
|
66
|
+
function envInt(value: string | undefined): number | undefined {
|
|
67
|
+
if (value === undefined) return undefined;
|
|
68
|
+
const n = Number.parseInt(value, 10);
|
|
69
|
+
return Number.isFinite(n) ? n : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function envString(value: string | undefined): string | undefined {
|
|
73
|
+
if (value === undefined) return undefined;
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function pickBudget(value: unknown): HindsightConfig["recallBudget"] | undefined {
|
|
79
|
+
return typeof value === "string" && (VALID_BUDGETS as string[]).includes(value)
|
|
80
|
+
? (value as HindsightConfig["recallBudget"])
|
|
81
|
+
: undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function pickRetainMode(value: unknown): HindsightConfig["retainMode"] | undefined {
|
|
85
|
+
return typeof value === "string" && (VALID_RETAIN_MODES as string[]).includes(value)
|
|
86
|
+
? (value as HindsightConfig["retainMode"])
|
|
87
|
+
: undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pickScoping(value: unknown): HindsightScoping | undefined {
|
|
91
|
+
return typeof value === "string" && (VALID_SCOPINGS as string[]).includes(value)
|
|
92
|
+
? (value as HindsightScoping)
|
|
93
|
+
: undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Load the resolved Hindsight config.
|
|
98
|
+
*
|
|
99
|
+
* Pure (no I/O) aside from reading from `process.env` and the supplied
|
|
100
|
+
* Settings instance. Tests can pass `Settings.isolated({...})` and stub
|
|
101
|
+
* `process.env` per case.
|
|
102
|
+
*/
|
|
103
|
+
export function loadHindsightConfig(settings: Settings, env: NodeJS.ProcessEnv = process.env): HindsightConfig {
|
|
104
|
+
const apiUrlEnv = envString(env.HINDSIGHT_API_URL);
|
|
105
|
+
const apiTokenEnv = envString(env.HINDSIGHT_API_TOKEN);
|
|
106
|
+
const bankIdEnv = envString(env.HINDSIGHT_BANK_ID);
|
|
107
|
+
const bankMissionEnv = envString(env.HINDSIGHT_BANK_MISSION);
|
|
108
|
+
const retainModeEnv = pickRetainMode(env.HINDSIGHT_RETAIN_MODE);
|
|
109
|
+
const recallBudgetEnv = pickBudget(env.HINDSIGHT_RECALL_BUDGET);
|
|
110
|
+
const autoRecallEnv = envBool(env.HINDSIGHT_AUTO_RECALL);
|
|
111
|
+
const autoRetainEnv = envBool(env.HINDSIGHT_AUTO_RETAIN);
|
|
112
|
+
const scopingEnv = pickScoping(env.HINDSIGHT_SCOPING);
|
|
113
|
+
const debugEnv = envBool(env.HINDSIGHT_DEBUG);
|
|
114
|
+
const recallMaxTokensEnv = envInt(env.HINDSIGHT_RECALL_MAX_TOKENS);
|
|
115
|
+
const recallContextTurnsEnv = envInt(env.HINDSIGHT_RECALL_CONTEXT_TURNS);
|
|
116
|
+
const recallMaxQueryCharsEnv = envInt(env.HINDSIGHT_RECALL_MAX_QUERY_CHARS);
|
|
117
|
+
const retainEveryNTurnsEnv = envInt(env.HINDSIGHT_RETAIN_EVERY_N_TURNS);
|
|
118
|
+
|
|
119
|
+
// Read from settings (each falls back to its schema default).
|
|
120
|
+
const settingsRetainMode = pickRetainMode(settings.get("hindsight.retainMode"));
|
|
121
|
+
if (settings.get("hindsight.retainMode") && !settingsRetainMode) {
|
|
122
|
+
logger.warn("Hindsight: invalid retainMode setting, falling back to full-session", {
|
|
123
|
+
value: settings.get("hindsight.retainMode"),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const settingsRecallBudget = pickBudget(settings.get("hindsight.recallBudget"));
|
|
127
|
+
const settingsScoping = pickScoping(settings.get("hindsight.scoping"));
|
|
128
|
+
if (settings.get("hindsight.scoping") && !settingsScoping) {
|
|
129
|
+
logger.warn("Hindsight: invalid scoping setting, falling back to per-project-tagged", {
|
|
130
|
+
value: settings.get("hindsight.scoping"),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const config: HindsightConfig = {
|
|
135
|
+
hindsightApiUrl: apiUrlEnv ?? settings.get("hindsight.apiUrl") ?? null,
|
|
136
|
+
hindsightApiToken: apiTokenEnv ?? settings.get("hindsight.apiToken") ?? null,
|
|
137
|
+
|
|
138
|
+
bankId: bankIdEnv ?? settings.get("hindsight.bankId") ?? null,
|
|
139
|
+
bankIdPrefix: settings.get("hindsight.bankIdPrefix") ?? "",
|
|
140
|
+
scoping: scopingEnv ?? settingsScoping ?? "per-project-tagged",
|
|
141
|
+
bankMission: bankMissionEnv ?? settings.get("hindsight.bankMission") ?? "",
|
|
142
|
+
retainMission: settings.get("hindsight.retainMission") ?? null,
|
|
143
|
+
|
|
144
|
+
autoRecall: autoRecallEnv ?? settings.get("hindsight.autoRecall"),
|
|
145
|
+
autoRetain: autoRetainEnv ?? settings.get("hindsight.autoRetain"),
|
|
146
|
+
|
|
147
|
+
retainMode: retainModeEnv ?? settingsRetainMode ?? "full-session",
|
|
148
|
+
retainEveryNTurns: retainEveryNTurnsEnv ?? settings.get("hindsight.retainEveryNTurns"),
|
|
149
|
+
retainOverlapTurns: settings.get("hindsight.retainOverlapTurns"),
|
|
150
|
+
retainContext: settings.get("hindsight.retainContext") ?? "omp",
|
|
151
|
+
|
|
152
|
+
recallBudget: recallBudgetEnv ?? settingsRecallBudget ?? "mid",
|
|
153
|
+
recallMaxTokens: recallMaxTokensEnv ?? settings.get("hindsight.recallMaxTokens"),
|
|
154
|
+
recallTypes: settings.get("hindsight.recallTypes") as string[],
|
|
155
|
+
recallContextTurns: recallContextTurnsEnv ?? settings.get("hindsight.recallContextTurns"),
|
|
156
|
+
recallMaxQueryChars: recallMaxQueryCharsEnv ?? settings.get("hindsight.recallMaxQueryChars"),
|
|
157
|
+
recallPromptPreamble: DEFAULT_PREAMBLE,
|
|
158
|
+
|
|
159
|
+
debug: debugEnv ?? settings.get("hindsight.debug"),
|
|
160
|
+
|
|
161
|
+
mentalModelsEnabled: settings.get("hindsight.mentalModelsEnabled"),
|
|
162
|
+
mentalModelAutoSeed: settings.get("hindsight.mentalModelAutoSeed"),
|
|
163
|
+
mentalModelRefreshIntervalMs: settings.get("hindsight.mentalModelRefreshIntervalMs"),
|
|
164
|
+
mentalModelMaxRenderChars: settings.get("hindsight.mentalModelMaxRenderChars"),
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return config;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Whether the caller has enough config to talk to a Hindsight server. */
|
|
171
|
+
export function isHindsightConfigured(
|
|
172
|
+
config: HindsightConfig,
|
|
173
|
+
): config is HindsightConfig & { hindsightApiUrl: string } {
|
|
174
|
+
return typeof config.hindsightApiUrl === "string" && config.hindsightApiUrl.length > 0;
|
|
175
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure content utilities for the Hindsight backend.
|
|
3
|
+
*
|
|
4
|
+
* Ports the semantics of the upstream OpenCode plugin
|
|
5
|
+
* (vectorize-io/hindsight @ hindsight-integrations/opencode/src/content.ts):
|
|
6
|
+
* - tag stripping for anti-feedback (a recalled <memories> block must
|
|
7
|
+
* never end up retained as a new memory)
|
|
8
|
+
* - recall query composition + truncation under a character budget
|
|
9
|
+
* - retention transcript framing
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface HindsightMessage {
|
|
13
|
+
role: string;
|
|
14
|
+
content: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RecallResultLike {
|
|
18
|
+
text: string;
|
|
19
|
+
type?: string | null;
|
|
20
|
+
mentioned_at?: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MEMORIES_REGEX = /<memories>[\s\S]*?<\/memories>/g;
|
|
24
|
+
const LEGACY_HINDSIGHT_MEMORIES_REGEX = /<hindsight_memories>[\s\S]*?<\/hindsight_memories>/g;
|
|
25
|
+
const LEGACY_RELEVANT_MEMORIES_REGEX = /<relevant_memories>[\s\S]*?<\/relevant_memories>/g;
|
|
26
|
+
const MENTAL_MODELS_REGEX = /<mental_models>[\s\S]*?<\/mental_models>/g;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Strip `<memories>`, `<mental_models>`, and legacy memory blocks.
|
|
30
|
+
*
|
|
31
|
+
* Both `<memories>` (per-turn recall) and `<mental_models>` (curated semantic
|
|
32
|
+
* memory) are injected into the system prompt. If either leaks into the
|
|
33
|
+
* retention transcript, every retain becomes a tighter feedback loop —
|
|
34
|
+
* paraphrased memories feed the next consolidation, which feeds the next
|
|
35
|
+
* mental-model refresh, which feeds the next retain. Always strip before
|
|
36
|
+
* retaining.
|
|
37
|
+
*/
|
|
38
|
+
export function stripMemoryTags(content: string): string {
|
|
39
|
+
return content
|
|
40
|
+
.replace(MEMORIES_REGEX, "")
|
|
41
|
+
.replace(MENTAL_MODELS_REGEX, "")
|
|
42
|
+
.replace(LEGACY_HINDSIGHT_MEMORIES_REGEX, "")
|
|
43
|
+
.replace(LEGACY_RELEVANT_MEMORIES_REGEX, "");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Format recall results into a bullet list for context injection. */
|
|
47
|
+
export function formatMemories(results: RecallResultLike[]): string {
|
|
48
|
+
if (results.length === 0) return "";
|
|
49
|
+
return results
|
|
50
|
+
.map(r => {
|
|
51
|
+
const typeStr = r.type ? ` [${r.type}]` : "";
|
|
52
|
+
const dateStr = r.mentioned_at ? ` (${r.mentioned_at})` : "";
|
|
53
|
+
return `- ${r.text}${typeStr}${dateStr}`;
|
|
54
|
+
})
|
|
55
|
+
.join("\n\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Format current UTC time for the recall preamble. */
|
|
59
|
+
export function formatCurrentTime(now: Date = new Date()): string {
|
|
60
|
+
const y = now.getUTCFullYear();
|
|
61
|
+
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
62
|
+
const d = String(now.getUTCDate()).padStart(2, "0");
|
|
63
|
+
const h = String(now.getUTCHours()).padStart(2, "0");
|
|
64
|
+
const min = String(now.getUTCMinutes()).padStart(2, "0");
|
|
65
|
+
return `${y}-${m}-${d} ${h}:${min}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Slice messages to the last N turns, where a turn boundary is a user message.
|
|
70
|
+
* Returns the trailing tail starting at the (N-th from the end) user message.
|
|
71
|
+
*/
|
|
72
|
+
export function sliceLastTurnsByUserBoundary(messages: HindsightMessage[], turns: number): HindsightMessage[] {
|
|
73
|
+
if (messages.length === 0 || turns <= 0) return [];
|
|
74
|
+
|
|
75
|
+
let userTurnsSeen = 0;
|
|
76
|
+
let startIndex = -1;
|
|
77
|
+
|
|
78
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
79
|
+
if (messages[i].role === "user") {
|
|
80
|
+
userTurnsSeen += 1;
|
|
81
|
+
if (userTurnsSeen >= turns) {
|
|
82
|
+
startIndex = i;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return startIndex === -1 ? [...messages] : messages.slice(startIndex);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compose a recall query from the latest user prompt plus optional prior context.
|
|
93
|
+
*
|
|
94
|
+
* When `recallContextTurns <= 1` the query is just the trimmed latest prompt.
|
|
95
|
+
* Otherwise we prepend a `Prior context:` block built from the trailing
|
|
96
|
+
* `recallContextTurns` user-bounded turns (memory tags stripped, latest prompt
|
|
97
|
+
* suppressed to avoid duplicating it inside the context block).
|
|
98
|
+
*/
|
|
99
|
+
export function composeRecallQuery(
|
|
100
|
+
latestQuery: string,
|
|
101
|
+
messages: HindsightMessage[],
|
|
102
|
+
recallContextTurns: number,
|
|
103
|
+
): string {
|
|
104
|
+
const latest = latestQuery.trim();
|
|
105
|
+
if (recallContextTurns <= 1 || messages.length === 0) return latest;
|
|
106
|
+
|
|
107
|
+
const contextual = sliceLastTurnsByUserBoundary(messages, recallContextTurns);
|
|
108
|
+
const contextLines: string[] = [];
|
|
109
|
+
|
|
110
|
+
for (const msg of contextual) {
|
|
111
|
+
const content = stripMemoryTags(msg.content).trim();
|
|
112
|
+
if (!content) continue;
|
|
113
|
+
if (msg.role === "user" && content === latest) continue;
|
|
114
|
+
contextLines.push(`${msg.role}: ${content}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (contextLines.length === 0) return latest;
|
|
118
|
+
return ["Prior context:", contextLines.join("\n"), latest].join("\n\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Truncate a composed recall query to `maxChars`.
|
|
123
|
+
*
|
|
124
|
+
* Always preserves the latest user message. Drops oldest context lines first
|
|
125
|
+
* and degrades gracefully when even the latest message exceeds the budget.
|
|
126
|
+
*/
|
|
127
|
+
export function truncateRecallQuery(query: string, latestQuery: string, maxChars: number): string {
|
|
128
|
+
if (maxChars <= 0 || query.length <= maxChars) return query;
|
|
129
|
+
|
|
130
|
+
const latest = latestQuery.trim();
|
|
131
|
+
const latestOnly = latest.length > maxChars ? latest.slice(0, maxChars) : latest;
|
|
132
|
+
|
|
133
|
+
if (!query.includes("Prior context:")) return latestOnly;
|
|
134
|
+
|
|
135
|
+
const contextMarker = "Prior context:\n\n";
|
|
136
|
+
const markerIndex = query.indexOf(contextMarker);
|
|
137
|
+
if (markerIndex === -1) return latestOnly;
|
|
138
|
+
|
|
139
|
+
const suffix = `\n\n${latest}`;
|
|
140
|
+
const suffixIndex = query.lastIndexOf(suffix);
|
|
141
|
+
if (suffixIndex === -1) return latestOnly;
|
|
142
|
+
if (suffix.length >= maxChars) return latestOnly;
|
|
143
|
+
|
|
144
|
+
const contextBody = query.slice(markerIndex + contextMarker.length, suffixIndex);
|
|
145
|
+
const contextLines = contextBody.split("\n").filter(Boolean);
|
|
146
|
+
|
|
147
|
+
const kept: string[] = [];
|
|
148
|
+
for (let i = contextLines.length - 1; i >= 0; i--) {
|
|
149
|
+
kept.unshift(contextLines[i]);
|
|
150
|
+
const candidate = `${contextMarker}${kept.join("\n")}${suffix}`;
|
|
151
|
+
if (candidate.length > maxChars) {
|
|
152
|
+
kept.shift();
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (kept.length > 0) return `${contextMarker}${kept.join("\n")}${suffix}`;
|
|
158
|
+
return latestOnly;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface RetentionTranscript {
|
|
162
|
+
transcript: string | null;
|
|
163
|
+
messageCount: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Format messages into a retention transcript using `[role: ...]` markers.
|
|
168
|
+
*
|
|
169
|
+
* - When `retainFullWindow` is true, all messages are included (used when the
|
|
170
|
+
* caller pre-sliced the window itself).
|
|
171
|
+
* - Otherwise, only the last user turn (last user message → end) is retained.
|
|
172
|
+
*
|
|
173
|
+
* Messages are tag-stripped before framing to break the recall→retain loop.
|
|
174
|
+
* Returns `{ transcript: null }` when nothing meaningful survives.
|
|
175
|
+
*/
|
|
176
|
+
export function prepareRetentionTranscript(
|
|
177
|
+
messages: HindsightMessage[],
|
|
178
|
+
retainFullWindow = false,
|
|
179
|
+
): RetentionTranscript {
|
|
180
|
+
if (messages.length === 0) return { transcript: null, messageCount: 0 };
|
|
181
|
+
|
|
182
|
+
let targetMessages: HindsightMessage[];
|
|
183
|
+
if (retainFullWindow) {
|
|
184
|
+
targetMessages = messages;
|
|
185
|
+
} else {
|
|
186
|
+
let lastUserIdx = -1;
|
|
187
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
188
|
+
if (messages[i].role === "user") {
|
|
189
|
+
lastUserIdx = i;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (lastUserIdx === -1) return { transcript: null, messageCount: 0 };
|
|
194
|
+
targetMessages = messages.slice(lastUserIdx);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const parts: string[] = [];
|
|
198
|
+
for (const msg of targetMessages) {
|
|
199
|
+
const content = stripMemoryTags(msg.content).trim();
|
|
200
|
+
if (!content) continue;
|
|
201
|
+
parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (parts.length === 0) return { transcript: null, messageCount: 0 };
|
|
205
|
+
|
|
206
|
+
const transcript = parts.join("\n\n");
|
|
207
|
+
if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
|
|
208
|
+
|
|
209
|
+
return { transcript, messageCount: parts.length };
|
|
210
|
+
}
|