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

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 (63) hide show
  1. package/CHANGELOG.md +82 -1
  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 +595 -100
  8. package/src/config/settings.ts +46 -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 +104 -38
  14. package/src/edit/renderer.ts +3 -3
  15. package/src/hindsight/backend.ts +444 -0
  16. package/src/hindsight/bank.ts +131 -0
  17. package/src/hindsight/client.ts +445 -0
  18. package/src/hindsight/config.ts +165 -0
  19. package/src/hindsight/content.ts +205 -0
  20. package/src/hindsight/index.ts +6 -0
  21. package/src/hindsight/retain-queue.ts +166 -0
  22. package/src/hindsight/transcript.ts +71 -0
  23. package/src/main.ts +7 -10
  24. package/src/memories/index.ts +1 -1
  25. package/src/memory-backend/index.ts +4 -0
  26. package/src/memory-backend/local-backend.ts +30 -0
  27. package/src/memory-backend/off-backend.ts +16 -0
  28. package/src/memory-backend/resolve.ts +24 -0
  29. package/src/memory-backend/types.ts +69 -0
  30. package/src/modes/components/settings-defs.ts +50 -451
  31. package/src/modes/components/settings-selector.ts +4 -2
  32. package/src/modes/components/status-line/presets.ts +1 -1
  33. package/src/modes/components/status-line.ts +4 -1
  34. package/src/modes/controllers/command-controller.ts +6 -5
  35. package/src/modes/controllers/event-controller.ts +12 -0
  36. package/src/modes/controllers/mcp-command-controller.ts +23 -0
  37. package/src/modes/controllers/selector-controller.ts +10 -12
  38. package/src/modes/interactive-mode.ts +3 -2
  39. package/src/modes/theme/theme.ts +4 -0
  40. package/src/prompts/tools/github.md +3 -0
  41. package/src/prompts/tools/hashline.md +20 -16
  42. package/src/prompts/tools/read.md +10 -6
  43. package/src/prompts/tools/recall.md +5 -0
  44. package/src/prompts/tools/reflect.md +5 -0
  45. package/src/prompts/tools/retain.md +5 -0
  46. package/src/prompts/tools/search.md +1 -1
  47. package/src/sdk.ts +12 -9
  48. package/src/session/agent-session.ts +75 -3
  49. package/src/slash-commands/builtin-registry.ts +2 -12
  50. package/src/ssh/connection-manager.ts +1 -1
  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 +70 -0
  57. package/src/tools/hindsight-reflect.ts +57 -0
  58. package/src/tools/hindsight-retain.ts +63 -0
  59. package/src/tools/index.ts +17 -0
  60. package/src/tools/output-meta.ts +1 -0
  61. package/src/tools/path-utils.ts +55 -0
  62. package/src/tools/read.ts +1 -1
  63. package/src/tools/search.ts +45 -8
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Minimal fetch-based client for the Hindsight HTTP API.
3
+ *
4
+ * Replaces the `@vectorize-io/hindsight-client` SDK with hand-rolled fetch
5
+ * calls so we depend on nothing more than the API endpoints we actually use:
6
+ * `retain`, `retainBatch`, `recall`, `reflect`, bank + document management,
7
+ * and bulk listing. Centralising construction here keeps a single seam for
8
+ * tests to spy on.
9
+ */
10
+
11
+ import type { HindsightConfig } from "./config";
12
+
13
+ const USER_AGENT = "oh-my-pi-coding-agent";
14
+ const DEFAULT_USER_AGENT = USER_AGENT;
15
+
16
+ export type Budget = "low" | "mid" | "high" | string;
17
+ export type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
18
+ export type UpdateMode = "replace" | "append";
19
+ export type ConsolidationState = "failed" | "pending" | "done";
20
+
21
+ export interface HindsightApiOptions {
22
+ baseUrl: string;
23
+ apiKey?: string;
24
+ userAgent?: string;
25
+ }
26
+
27
+ export interface RecallResult {
28
+ id?: string;
29
+ text: string;
30
+ type?: string | null;
31
+ mentioned_at?: string | null;
32
+ [key: string]: unknown;
33
+ }
34
+
35
+ export interface RecallResponse {
36
+ results: RecallResult[];
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ export interface ReflectResponse {
41
+ text?: string;
42
+ [key: string]: unknown;
43
+ }
44
+
45
+ export interface RetainResponse {
46
+ [key: string]: unknown;
47
+ }
48
+
49
+ export interface BankProfileResponse {
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ export interface ListMemoriesResponse {
54
+ [key: string]: unknown;
55
+ }
56
+
57
+ export interface DocumentResponse {
58
+ [key: string]: unknown;
59
+ }
60
+
61
+ export interface ListDocumentsResponse {
62
+ [key: string]: unknown;
63
+ }
64
+
65
+ /** Mirrors the shape accepted by `POST /v1/default/banks/{bank_id}/memories`. */
66
+ export interface MemoryItemInput {
67
+ content: string;
68
+ timestamp?: Date | string;
69
+ context?: string;
70
+ metadata?: Record<string, string>;
71
+ documentId?: string;
72
+ tags?: string[];
73
+ /** Scoping policy for observations derived from this item. */
74
+ observationScopes?: "per_tag" | "combined" | "all_combinations" | string[][];
75
+ /** Per-item extraction strategy override. */
76
+ strategy?: string;
77
+ updateMode?: UpdateMode;
78
+ }
79
+
80
+ export interface RetainOptions {
81
+ timestamp?: Date | string;
82
+ context?: string;
83
+ metadata?: Record<string, string>;
84
+ documentId?: string;
85
+ async?: boolean;
86
+ tags?: string[];
87
+ updateMode?: UpdateMode;
88
+ }
89
+
90
+ export interface RetainBatchOptions {
91
+ /** Document id applied to every item that doesn't carry its own. */
92
+ documentId?: string;
93
+ /** Tags attached to the resulting document(s), not individual items. */
94
+ documentTags?: string[];
95
+ async?: boolean;
96
+ }
97
+
98
+ export interface RecallOptions {
99
+ types?: string[];
100
+ maxTokens?: number;
101
+ budget?: Budget;
102
+ tags?: string[];
103
+ tagsMatch?: TagsMatch;
104
+ }
105
+
106
+ export interface ReflectOptions {
107
+ context?: string;
108
+ budget?: Budget;
109
+ tags?: string[];
110
+ tagsMatch?: TagsMatch;
111
+ }
112
+
113
+ export interface CreateBankOptions {
114
+ reflectMission?: string;
115
+ retainMission?: string;
116
+ }
117
+
118
+ export interface ListMemoriesOptions {
119
+ limit?: number;
120
+ offset?: number;
121
+ type?: string;
122
+ q?: string;
123
+ consolidationState?: ConsolidationState;
124
+ }
125
+
126
+ export interface ListDocumentsOptions {
127
+ limit?: number;
128
+ offset?: number;
129
+ }
130
+
131
+ export interface UpdateDocumentOptions {
132
+ tags?: string[];
133
+ }
134
+
135
+ export class HindsightError extends Error {
136
+ statusCode?: number;
137
+ details?: unknown;
138
+
139
+ constructor(message: string, statusCode?: number, details?: unknown) {
140
+ super(message);
141
+ this.name = "HindsightError";
142
+ this.statusCode = statusCode;
143
+ this.details = details;
144
+ }
145
+ }
146
+
147
+ interface RequestOptions {
148
+ body?: Record<string, unknown>;
149
+ query?: Record<string, unknown>;
150
+ /** Return null instead of throwing on a 404 response. */
151
+ allow404?: boolean;
152
+ }
153
+
154
+ export class HindsightApi {
155
+ #baseUrl: string;
156
+ #headers: Record<string, string>;
157
+
158
+ constructor(options: HindsightApiOptions) {
159
+ this.#baseUrl = options.baseUrl.replace(/\/+$/, "");
160
+ this.#headers = {
161
+ "User-Agent": options.userAgent ?? DEFAULT_USER_AGENT,
162
+ "Content-Type": "application/json",
163
+ };
164
+ if (options.apiKey) {
165
+ this.#headers.Authorization = `Bearer ${options.apiKey}`;
166
+ }
167
+ }
168
+
169
+ async retain(bankId: string, content: string, options?: RetainOptions): Promise<RetainResponse> {
170
+ const item = buildMemoryItem({
171
+ content,
172
+ timestamp: options?.timestamp,
173
+ context: options?.context,
174
+ metadata: options?.metadata,
175
+ documentId: options?.documentId,
176
+ tags: options?.tags,
177
+ updateMode: options?.updateMode,
178
+ });
179
+
180
+ return this.#request<RetainResponse>(
181
+ "POST",
182
+ `/v1/default/banks/${encodeURIComponent(bankId)}/memories`,
183
+ "retain",
184
+ { body: { items: [item], async: options?.async } },
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Retain multiple memories in a single request. Mirrors the official
190
+ * client's `retainBatch` — items hit `POST /memories` together so the
191
+ * server can dedupe and consolidate as a batch instead of N round-trips.
192
+ *
193
+ * Per-item `documentId` wins; `options.documentId` only fills the gaps.
194
+ */
195
+ async retainBatch(bankId: string, items: MemoryItemInput[], options?: RetainBatchOptions): Promise<RetainResponse> {
196
+ const processed = items.map(item => {
197
+ const built = buildMemoryItem(item);
198
+ if (built.document_id === undefined && options?.documentId !== undefined) {
199
+ built.document_id = options.documentId;
200
+ }
201
+ return built;
202
+ });
203
+
204
+ return this.#request<RetainResponse>(
205
+ "POST",
206
+ `/v1/default/banks/${encodeURIComponent(bankId)}/memories`,
207
+ "retainBatch",
208
+ {
209
+ body: {
210
+ items: processed,
211
+ document_tags: options?.documentTags,
212
+ async: options?.async,
213
+ },
214
+ },
215
+ );
216
+ }
217
+
218
+ async recall(bankId: string, query: string, options?: RecallOptions): Promise<RecallResponse> {
219
+ return this.#request<RecallResponse>(
220
+ "POST",
221
+ `/v1/default/banks/${encodeURIComponent(bankId)}/memories/recall`,
222
+ "recall",
223
+ {
224
+ body: {
225
+ query,
226
+ types: options?.types,
227
+ max_tokens: options?.maxTokens,
228
+ budget: options?.budget ?? "mid",
229
+ tags: options?.tags,
230
+ tags_match: options?.tagsMatch,
231
+ },
232
+ },
233
+ );
234
+ }
235
+
236
+ async reflect(bankId: string, query: string, options?: ReflectOptions): Promise<ReflectResponse> {
237
+ return this.#request<ReflectResponse>(
238
+ "POST",
239
+ `/v1/default/banks/${encodeURIComponent(bankId)}/reflect`,
240
+ "reflect",
241
+ {
242
+ body: {
243
+ query,
244
+ context: options?.context,
245
+ budget: options?.budget ?? "low",
246
+ tags: options?.tags,
247
+ tags_match: options?.tagsMatch,
248
+ },
249
+ },
250
+ );
251
+ }
252
+
253
+ async createBank(bankId: string, options: CreateBankOptions = {}): Promise<BankProfileResponse> {
254
+ return this.#request<BankProfileResponse>(
255
+ "PUT",
256
+ `/v1/default/banks/${encodeURIComponent(bankId)}`,
257
+ "createBank",
258
+ {
259
+ body: {
260
+ reflect_mission: options.reflectMission,
261
+ retain_mission: options.retainMission,
262
+ },
263
+ },
264
+ );
265
+ }
266
+
267
+ /**
268
+ * Bulk-list memory units in a bank with optional filters and pagination.
269
+ * Endpoint: `GET /v1/default/banks/{bank_id}/memories/list`.
270
+ */
271
+ async listMemories(bankId: string, options?: ListMemoriesOptions): Promise<ListMemoriesResponse> {
272
+ return this.#request<ListMemoriesResponse>(
273
+ "GET",
274
+ `/v1/default/banks/${encodeURIComponent(bankId)}/memories/list`,
275
+ "listMemories",
276
+ {
277
+ query: {
278
+ type: options?.type,
279
+ q: options?.q,
280
+ consolidation_state: options?.consolidationState,
281
+ limit: options?.limit,
282
+ offset: options?.offset,
283
+ },
284
+ },
285
+ );
286
+ }
287
+
288
+ /** Bulk-list documents in a bank. */
289
+ async listDocuments(bankId: string, options?: ListDocumentsOptions): Promise<ListDocumentsResponse> {
290
+ return this.#request<ListDocumentsResponse>(
291
+ "GET",
292
+ `/v1/default/banks/${encodeURIComponent(bankId)}/documents`,
293
+ "listDocuments",
294
+ { query: { limit: options?.limit, offset: options?.offset } },
295
+ );
296
+ }
297
+
298
+ /** Fetch a document. Returns `null` on 404 instead of throwing. */
299
+ async getDocument(bankId: string, documentId: string): Promise<DocumentResponse | null> {
300
+ return this.#request<DocumentResponse | null>(
301
+ "GET",
302
+ `/v1/default/banks/${encodeURIComponent(bankId)}/documents/${encodeURIComponent(documentId)}`,
303
+ "getDocument",
304
+ { allow404: true },
305
+ );
306
+ }
307
+
308
+ /** Update a document's mutable fields (currently just tags). */
309
+ async updateDocument(bankId: string, documentId: string, options: UpdateDocumentOptions): Promise<DocumentResponse> {
310
+ return this.#request<DocumentResponse>(
311
+ "PATCH",
312
+ `/v1/default/banks/${encodeURIComponent(bankId)}/documents/${encodeURIComponent(documentId)}`,
313
+ "updateDocument",
314
+ { body: { tags: options.tags } },
315
+ );
316
+ }
317
+
318
+ /**
319
+ * Delete a document and every memory derived from it. Returns `true` on
320
+ * success, `false` if the document was already gone (404).
321
+ */
322
+ async deleteDocument(bankId: string, documentId: string): Promise<boolean> {
323
+ const result = await this.#request<{ __deleted: boolean } | null>(
324
+ "DELETE",
325
+ `/v1/default/banks/${encodeURIComponent(bankId)}/documents/${encodeURIComponent(documentId)}`,
326
+ "deleteDocument",
327
+ { allow404: true },
328
+ );
329
+ return result !== null;
330
+ }
331
+
332
+ async #request<T>(method: string, path: string, operation: string, opts?: RequestOptions): Promise<T> {
333
+ let url = `${this.#baseUrl}${path}`;
334
+ if (opts?.query) {
335
+ const qs = buildQueryString(opts.query);
336
+ if (qs) url += `?${qs}`;
337
+ }
338
+
339
+ const init: RequestInit = { method, headers: this.#headers };
340
+ if (opts?.body !== undefined) {
341
+ init.body = JSON.stringify(pruneUndefined(opts.body));
342
+ }
343
+
344
+ let response: Response;
345
+ try {
346
+ response = await fetch(url, init);
347
+ } catch (err) {
348
+ throw new HindsightError(
349
+ `${operation} request failed: ${err instanceof Error ? err.message : String(err)}`,
350
+ undefined,
351
+ err,
352
+ );
353
+ }
354
+
355
+ if (opts?.allow404 && response.status === 404) {
356
+ return null as T;
357
+ }
358
+
359
+ const text = await response.text();
360
+ const parsed = text ? safeJsonParse(text) : null;
361
+
362
+ if (!response.ok) {
363
+ const details =
364
+ (parsed && typeof parsed === "object"
365
+ ? ((parsed as { detail?: unknown; message?: unknown }).detail ??
366
+ (parsed as { message?: unknown }).message)
367
+ : undefined) ??
368
+ parsed ??
369
+ text;
370
+ throw new HindsightError(
371
+ `${operation} failed: ${typeof details === "string" ? details : JSON.stringify(details)}`,
372
+ response.status,
373
+ details,
374
+ );
375
+ }
376
+
377
+ return (parsed ?? {}) as T;
378
+ }
379
+ }
380
+
381
+ interface BuiltMemoryItem {
382
+ content: string;
383
+ timestamp?: string;
384
+ context?: string;
385
+ metadata?: Record<string, string>;
386
+ document_id?: string;
387
+ tags?: string[];
388
+ observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][];
389
+ strategy?: string;
390
+ update_mode?: UpdateMode;
391
+ }
392
+
393
+ function buildMemoryItem(item: MemoryItemInput): BuiltMemoryItem {
394
+ const out: BuiltMemoryItem = { content: item.content };
395
+ if (item.timestamp !== undefined) {
396
+ out.timestamp = item.timestamp instanceof Date ? item.timestamp.toISOString() : item.timestamp;
397
+ }
398
+ if (item.context !== undefined) out.context = item.context;
399
+ if (item.metadata !== undefined) out.metadata = item.metadata;
400
+ if (item.documentId !== undefined) out.document_id = item.documentId;
401
+ if (item.tags !== undefined) out.tags = item.tags;
402
+ if (item.observationScopes !== undefined) out.observation_scopes = item.observationScopes;
403
+ if (item.strategy !== undefined) out.strategy = item.strategy;
404
+ if (item.updateMode !== undefined) out.update_mode = item.updateMode;
405
+ return out;
406
+ }
407
+
408
+ function buildQueryString(query: Record<string, unknown>): string {
409
+ const params = new URLSearchParams();
410
+ for (const [key, value] of Object.entries(query)) {
411
+ if (value === undefined || value === null) continue;
412
+ if (Array.isArray(value)) {
413
+ for (const item of value) {
414
+ if (item === undefined || item === null) continue;
415
+ params.append(key, String(item));
416
+ }
417
+ } else {
418
+ params.set(key, String(value));
419
+ }
420
+ }
421
+ return params.toString();
422
+ }
423
+
424
+ function pruneUndefined(obj: Record<string, unknown>): Record<string, unknown> {
425
+ const out: Record<string, unknown> = {};
426
+ for (const [k, v] of Object.entries(obj)) {
427
+ if (v !== undefined) out[k] = v;
428
+ }
429
+ return out;
430
+ }
431
+
432
+ function safeJsonParse(text: string): unknown {
433
+ try {
434
+ return JSON.parse(text);
435
+ } catch {
436
+ return null;
437
+ }
438
+ }
439
+ export function createHindsightClient(config: HindsightConfig & { hindsightApiUrl: string }): HindsightApi {
440
+ return new HindsightApi({
441
+ baseUrl: config.hindsightApiUrl,
442
+ apiKey: config.hindsightApiToken ?? undefined,
443
+ userAgent: USER_AGENT,
444
+ });
445
+ }
@@ -0,0 +1,165 @@
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
+
46
+ const VALID_RETAIN_MODES: HindsightConfig["retainMode"][] = ["full-session", "last-turn"];
47
+ const VALID_BUDGETS: HindsightConfig["recallBudget"][] = ["low", "mid", "high"];
48
+ const VALID_SCOPINGS: HindsightScoping[] = ["global", "per-project", "per-project-tagged"];
49
+
50
+ const DEFAULT_PREAMBLE =
51
+ "Relevant memories from past conversations (prioritize recent when conflicting). " +
52
+ "Only use memories that are directly useful to continue this conversation; ignore the rest:";
53
+
54
+ /** Coerce an env var value into a boolean using the OpenCode plugin's semantics. */
55
+ function envBool(value: string | undefined): boolean | undefined {
56
+ if (value === undefined) return undefined;
57
+ return ["true", "1", "yes"].includes(value.toLowerCase());
58
+ }
59
+
60
+ /** Coerce an env var value into an int, returning undefined for non-numeric input. */
61
+ function envInt(value: string | undefined): number | undefined {
62
+ if (value === undefined) return undefined;
63
+ const n = Number.parseInt(value, 10);
64
+ return Number.isFinite(n) ? n : undefined;
65
+ }
66
+
67
+ function envString(value: string | undefined): string | undefined {
68
+ if (value === undefined) return undefined;
69
+ const trimmed = value.trim();
70
+ return trimmed.length === 0 ? undefined : trimmed;
71
+ }
72
+
73
+ function pickBudget(value: unknown): HindsightConfig["recallBudget"] | undefined {
74
+ return typeof value === "string" && (VALID_BUDGETS as string[]).includes(value)
75
+ ? (value as HindsightConfig["recallBudget"])
76
+ : undefined;
77
+ }
78
+
79
+ function pickRetainMode(value: unknown): HindsightConfig["retainMode"] | undefined {
80
+ return typeof value === "string" && (VALID_RETAIN_MODES as string[]).includes(value)
81
+ ? (value as HindsightConfig["retainMode"])
82
+ : undefined;
83
+ }
84
+
85
+ function pickScoping(value: unknown): HindsightScoping | undefined {
86
+ return typeof value === "string" && (VALID_SCOPINGS as string[]).includes(value)
87
+ ? (value as HindsightScoping)
88
+ : undefined;
89
+ }
90
+
91
+ /**
92
+ * Load the resolved Hindsight config.
93
+ *
94
+ * Pure (no I/O) aside from reading from `process.env` and the supplied
95
+ * Settings instance. Tests can pass `Settings.isolated({...})` and stub
96
+ * `process.env` per case.
97
+ */
98
+ export function loadHindsightConfig(settings: Settings, env: NodeJS.ProcessEnv = process.env): HindsightConfig {
99
+ const apiUrlEnv = envString(env.HINDSIGHT_API_URL);
100
+ const apiTokenEnv = envString(env.HINDSIGHT_API_TOKEN);
101
+ const bankIdEnv = envString(env.HINDSIGHT_BANK_ID);
102
+ const bankMissionEnv = envString(env.HINDSIGHT_BANK_MISSION);
103
+ const retainModeEnv = pickRetainMode(env.HINDSIGHT_RETAIN_MODE);
104
+ const recallBudgetEnv = pickBudget(env.HINDSIGHT_RECALL_BUDGET);
105
+ const autoRecallEnv = envBool(env.HINDSIGHT_AUTO_RECALL);
106
+ const autoRetainEnv = envBool(env.HINDSIGHT_AUTO_RETAIN);
107
+ const scopingEnv = pickScoping(env.HINDSIGHT_SCOPING);
108
+ const debugEnv = envBool(env.HINDSIGHT_DEBUG);
109
+ const recallMaxTokensEnv = envInt(env.HINDSIGHT_RECALL_MAX_TOKENS);
110
+ const recallContextTurnsEnv = envInt(env.HINDSIGHT_RECALL_CONTEXT_TURNS);
111
+ const recallMaxQueryCharsEnv = envInt(env.HINDSIGHT_RECALL_MAX_QUERY_CHARS);
112
+ const retainEveryNTurnsEnv = envInt(env.HINDSIGHT_RETAIN_EVERY_N_TURNS);
113
+
114
+ // Read from settings (each falls back to its schema default).
115
+ const settingsRetainMode = pickRetainMode(settings.get("hindsight.retainMode"));
116
+ if (settings.get("hindsight.retainMode") && !settingsRetainMode) {
117
+ logger.warn("Hindsight: invalid retainMode setting, falling back to full-session", {
118
+ value: settings.get("hindsight.retainMode"),
119
+ });
120
+ }
121
+ const settingsRecallBudget = pickBudget(settings.get("hindsight.recallBudget"));
122
+ const settingsScoping = pickScoping(settings.get("hindsight.scoping"));
123
+ if (settings.get("hindsight.scoping") && !settingsScoping) {
124
+ logger.warn("Hindsight: invalid scoping setting, falling back to per-project-tagged", {
125
+ value: settings.get("hindsight.scoping"),
126
+ });
127
+ }
128
+
129
+ const config: HindsightConfig = {
130
+ hindsightApiUrl: apiUrlEnv ?? settings.get("hindsight.apiUrl") ?? null,
131
+ hindsightApiToken: apiTokenEnv ?? settings.get("hindsight.apiToken") ?? null,
132
+
133
+ bankId: bankIdEnv ?? settings.get("hindsight.bankId") ?? null,
134
+ bankIdPrefix: settings.get("hindsight.bankIdPrefix") ?? "",
135
+ scoping: scopingEnv ?? settingsScoping ?? "per-project-tagged",
136
+ bankMission: bankMissionEnv ?? settings.get("hindsight.bankMission") ?? "",
137
+ retainMission: settings.get("hindsight.retainMission") ?? null,
138
+
139
+ autoRecall: autoRecallEnv ?? settings.get("hindsight.autoRecall"),
140
+ autoRetain: autoRetainEnv ?? settings.get("hindsight.autoRetain"),
141
+
142
+ retainMode: retainModeEnv ?? settingsRetainMode ?? "full-session",
143
+ retainEveryNTurns: retainEveryNTurnsEnv ?? settings.get("hindsight.retainEveryNTurns"),
144
+ retainOverlapTurns: settings.get("hindsight.retainOverlapTurns"),
145
+ retainContext: settings.get("hindsight.retainContext") ?? "omp",
146
+
147
+ recallBudget: recallBudgetEnv ?? settingsRecallBudget ?? "mid",
148
+ recallMaxTokens: recallMaxTokensEnv ?? settings.get("hindsight.recallMaxTokens"),
149
+ recallTypes: settings.get("hindsight.recallTypes") as string[],
150
+ recallContextTurns: recallContextTurnsEnv ?? settings.get("hindsight.recallContextTurns"),
151
+ recallMaxQueryChars: recallMaxQueryCharsEnv ?? settings.get("hindsight.recallMaxQueryChars"),
152
+ recallPromptPreamble: DEFAULT_PREAMBLE,
153
+
154
+ debug: debugEnv ?? settings.get("hindsight.debug"),
155
+ };
156
+
157
+ return config;
158
+ }
159
+
160
+ /** Whether the caller has enough config to talk to a Hindsight server. */
161
+ export function isHindsightConfigured(
162
+ config: HindsightConfig,
163
+ ): config is HindsightConfig & { hindsightApiUrl: string } {
164
+ return typeof config.hindsightApiUrl === "string" && config.hindsightApiUrl.length > 0;
165
+ }