pi-chalin 0.1.0 → 0.2.0

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/src/memory.ts CHANGED
@@ -38,6 +38,19 @@ export interface MemoryRevisionInput {
38
38
  reason?: string;
39
39
  }
40
40
 
41
+ export interface MemoryStoreLike {
42
+ submitCandidates(candidates: MemoryCandidate[]): Promise<MemoryRecord[]>;
43
+ list(status?: MemoryRecord["status"]): Promise<MemoryRecord[]>;
44
+ pendingCount(): Promise<number>;
45
+ approve(id: string): Promise<MemoryRecord | undefined>;
46
+ reject(id: string): Promise<MemoryRecord | undefined>;
47
+ delete(id: string): Promise<boolean>;
48
+ search(query: string, limit?: number): Promise<MemorySearchResult[]>;
49
+ retrieve(request: MemoryContextRequest): Promise<MemoryContextBundle>;
50
+ revise(id: string, input: MemoryRevisionInput): Promise<MemoryRecord | undefined>;
51
+ events(recordId?: string): Promise<MemoryAuditEvent[]>;
52
+ }
53
+
41
54
  type SqlJsStatic = any;
42
55
  type SqlJsDatabase = any;
43
56
 
@@ -52,7 +65,7 @@ export class MemoryStore {
52
65
 
53
66
  async submitCandidates(candidates: MemoryCandidate[]): Promise<MemoryRecord[]> {
54
67
  const now = new Date().toISOString();
55
- const records = dedupeCandidates(candidates).map((candidate) => buildMemoryRecord(candidate, now));
68
+ const records = prepareMemoryRecords(candidates, now);
56
69
 
57
70
  await this.withDb(true, (db) => {
58
71
  const existingRecords = selectRows(db, "SELECT * FROM memory_records ORDER BY createdAt DESC")
@@ -317,6 +330,10 @@ export function createMemoryCandidate(input: Omit<MemoryCandidate, "id" | "creat
317
330
  };
318
331
  }
319
332
 
333
+ export function prepareMemoryRecords(candidates: MemoryCandidate[], now = new Date().toISOString()): MemoryRecord[] {
334
+ return dedupeCandidates(candidates).map((candidate) => buildMemoryRecord(candidate, now));
335
+ }
336
+
320
337
  async function getSqlModule(): Promise<SqlJsStatic> {
321
338
  sqlModulePromise ??= initSqlJs();
322
339
  return sqlModulePromise;
package/src/runner.ts CHANGED
@@ -2,7 +2,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentDefinition, AgentThinkingLevel } from "./schemas.ts";
3
3
  import { evaluateBudgetUsage, policyForStep, recordBudgetCheckpoint, summarizeToolUtility } from "./budget.ts";
4
4
  import type { ChalinPathsOptions } from "./paths.ts";
5
- import { createMemoryCandidate, MemoryStore } from "./memory.ts";
5
+ import { createMemoryCandidate } from "./memory.ts";
6
+ import { createConfiguredMemoryStore } from "./memory-provider.ts";
6
7
  import type { AgentOutput, AgentStep, MemoryCandidate, RouteDecision, RoutePlan, RunState, RunStepMetrics, RunStepState, TokenUsageSummary } from "./schemas.ts";
7
8
  import { createChildToolPolicy, createChildTools, type ChildToolActivity, type ChildToolPolicy } from "./child-tools.ts";
8
9
  import { createChalinChildSessionManager } from "./child-sessions.ts";
@@ -544,7 +545,7 @@ function buildPromptOptionsForStep(run: RunState, step: RunStepState, agent: Age
544
545
  async function compactMemoryContextForStep(cwd: string, step: RunStepState, agent: AgentDefinition | undefined, previous?: string): Promise<string | undefined> {
545
546
  if (!agent?.memory.read || !agent.capabilities.includes("memory-read")) return undefined;
546
547
  const query = [step.task, previous ? `Previous handoff: ${previous.slice(0, 700)}` : ""].filter(Boolean).join("\n");
547
- const bundle = await new MemoryStore({ cwd }).retrieve({
548
+ const bundle = await createConfiguredMemoryStore({ cwd }).retrieve({
548
549
  query,
549
550
  sourceAgent: step.agent,
550
551
  agentConcern: agent.concern,
package/src/schemas.ts CHANGED
@@ -292,6 +292,7 @@ export interface ChalinRuntimeState {
292
292
  pendingApprovals: number;
293
293
  activeRuns: number;
294
294
  pendingMemoryCandidates: number;
295
+ memoryBackend?: string;
295
296
  lastRun?: RunState;
296
297
  }
297
298
 
package/src/tools.ts CHANGED
@@ -6,7 +6,8 @@ import { AgentCatalog } from "./agents.ts";
6
6
  import { ArtifactStore } from "./artifacts.ts";
7
7
  import { loadEffectiveConfig } from "./config.ts";
8
8
  import { ChalinKernel, routeFromPlan } from "./kernel.ts";
9
- import { createMemoryCandidate, MemoryStore } from "./memory.ts";
9
+ import { createMemoryCandidate } from "./memory.ts";
10
+ import { createConfiguredMemoryStore } from "./memory-provider.ts";
10
11
  import { formatInterviewResult, runChalinInterview, type InterviewRequestInput } from "./interview.ts";
11
12
  import { loadResumableRunState } from "./runner-state.ts";
12
13
  import { beginChalinRouteInvocation, finishChalinRouteInvocation, setLatestRun } from "./runtime-state.ts";
@@ -218,7 +219,7 @@ export function registerChalinTools(pi: ExtensionAPI): void {
218
219
  async execute(_toolCallId, params: ChalinRouteToolParams, signal, onUpdate, ctx) {
219
220
  const loaded = loadEffectiveConfig({ cwd: ctx.cwd });
220
221
  const catalog = AgentCatalog.load({ cwd: ctx.cwd });
221
- const memory = new MemoryStore({ cwd: ctx.cwd });
222
+ const memory = createConfiguredMemoryStore({ cwd: ctx.cwd }, loaded.config);
222
223
  const kernel = new ChalinKernel({
223
224
  cwd: ctx.cwd,
224
225
  config: loaded.config,
@@ -349,7 +350,7 @@ export function registerChalinTools(pi: ExtensionAPI): void {
349
350
  const run = loadResumableRunState({ cwd: ctx.cwd, runId: params.runId });
350
351
  if (!run) return textResult(params.runId ? `No resumable pi-chalin run found for '${params.runId}'.` : "No paused or stale pi-chalin run found to resume.", { runId: params.runId });
351
352
  const catalog = AgentCatalog.load({ cwd: ctx.cwd });
352
- const memory = new MemoryStore({ cwd: ctx.cwd });
353
+ const memory = createConfiguredMemoryStore({ cwd: ctx.cwd }, loaded.config);
353
354
  const kernel = new ChalinKernel({
354
355
  cwd: ctx.cwd,
355
356
  config: loaded.config,
@@ -411,7 +412,7 @@ export function registerChalinTools(pi: ExtensionAPI): void {
411
412
  ],
412
413
  parameters: ChalinMemorySearchParams,
413
414
  async execute(_toolCallId, params: ChalinMemorySearchToolParams, _signal, _onUpdate, ctx) {
414
- const memory = new MemoryStore({ cwd: ctx.cwd });
415
+ const memory = createConfiguredMemoryStore({ cwd: ctx.cwd });
415
416
  const bundle = await memory.retrieve({
416
417
  query: params.query,
417
418
  sourceAgent: "primary-pi",
@@ -437,7 +438,7 @@ export function registerChalinTools(pi: ExtensionAPI): void {
437
438
  async execute(_toolCallId, params: ChalinMemoryWriteToolParams, _signal, _onUpdate, ctx) {
438
439
  const content = params.content.trim();
439
440
  if (content.length < 24) return textResult("memory rejected: content is too short to be durable.", { status: "rejected" });
440
- const memory = new MemoryStore({ cwd: ctx.cwd });
441
+ const memory = createConfiguredMemoryStore({ cwd: ctx.cwd });
441
442
  const [record] = await memory.submitCandidates([createMemoryCandidate({
442
443
  category: params.category,
443
444
  content,
@@ -466,7 +467,7 @@ export function registerChalinTools(pi: ExtensionAPI): void {
466
467
  async execute(_toolCallId, params: ChalinMemoryReviseToolParams, _signal, _onUpdate, ctx) {
467
468
  const content = params.content.trim();
468
469
  if (content.length < 24) return textResult("memory revision rejected: content is too short to be durable.", { status: "rejected" });
469
- const memory = new MemoryStore({ cwd: ctx.cwd });
470
+ const memory = createConfiguredMemoryStore({ cwd: ctx.cwd });
470
471
  const record = await memory.revise(params.id, {
471
472
  content,
472
473
  category: params.category,
package/src/ui.ts CHANGED
@@ -46,6 +46,7 @@ export function summarizeChalinHome(state: ChalinRuntimeState, agentCount: numbe
46
46
  `agents: ${agentCount}`,
47
47
  `activity: ${summarizeActivity(state)}`,
48
48
  `guards: ${summarizeGuardHealth(state.lastRun)}`,
49
+ `memory: ${state.memoryBackend ?? "pi-chalin local"}`,
49
50
  `memory candidates: ${state.pendingMemoryCandidates}`,
50
51
  `approvals: ${state.pendingApprovals}`,
51
52
  ];
@@ -63,6 +64,7 @@ export async function openSmartPanel(
63
64
  onSelectMemory(): Promise<void>;
64
65
  onSelectArtifacts?(): Promise<void>;
65
66
  onSelectWebFetch?(): Promise<void>;
67
+ onSelectSettings?(): Promise<void>;
66
68
  },
67
69
  ): Promise<void> {
68
70
  const lines = summarizeChalinHome(options.state, options.agents.length);
@@ -85,6 +87,7 @@ export async function openSmartPanel(
85
87
  options.pendingMemories.length > 0 ? `Memory · ${options.pendingMemories.length} pending` : "Memory",
86
88
  ...(options.onSelectArtifacts ? ["Artifacts"] : []),
87
89
  ...(options.onSelectWebFetch ? ["WebFetch"] : []),
90
+ ...(options.onSelectSettings ? ["Settings"] : []),
88
91
  "Status",
89
92
  ...(options.diagnostics.length > 0 ? ["Diagnostics"] : []),
90
93
  "Close",
@@ -95,6 +98,7 @@ export async function openSmartPanel(
95
98
  if (selected?.startsWith("Memory")) return options.onSelectMemory();
96
99
  if (selected === "Artifacts") return options.onSelectArtifacts?.();
97
100
  if (selected === "WebFetch") return options.onSelectWebFetch?.();
101
+ if (selected === "Settings") return options.onSelectSettings?.();
98
102
  if (selected === "Diagnostics") return void ctx.ui.notify(options.diagnostics.join("\n"), "warning");
99
103
  if (selected === "Status") ctx.ui.notify(lines.join("\n"), "info");
100
104
  }
@@ -258,9 +262,10 @@ export async function openMemoryReview(
258
262
  ctx: ExtensionContext,
259
263
  memories: MemoryRecord[],
260
264
  actions: { approve(id: string): void; reject(id: string): void; delete(id: string): void },
265
+ options: { title?: string; emptyMessage?: string } = {},
261
266
  ): Promise<void> {
262
267
  if (memories.length === 0) {
263
- ctx.ui.notify("No memory records found.", "info");
268
+ ctx.ui.notify(options.emptyMessage ?? "No memory records found.", "info");
264
269
  return;
265
270
  }
266
271
  const sorted = [...memories].sort((a, b) => memoryStatusRank(a.status) - memoryStatusRank(b.status) || b.createdAt.localeCompare(a.createdAt));
@@ -269,7 +274,22 @@ export async function openMemoryReview(
269
274
  ctx.ui.notify(sorted.map(format).join("\n"), "info");
270
275
  return;
271
276
  }
272
- const selected = await ctx.ui.select("pi-chalin Memory", [...sorted.map(format), "Close"]);
277
+ if (typeof ctx.ui.custom === "function") {
278
+ const result = await openMemoryReviewOverlay(ctx, sorted, options.title ?? "pi-chalin Memory");
279
+ if (!result) return;
280
+ const record = sorted.find((candidate) => candidate.id === result.id);
281
+ if (!record) return;
282
+ if (result.action === "details") {
283
+ ctx.ui.notify(formatMemoryDetail(record), "info");
284
+ return;
285
+ }
286
+ if (result.action === "approve") actions.approve(record.id);
287
+ if (result.action === "reject") actions.reject(record.id);
288
+ if (result.action === "delete") actions.delete(record.id);
289
+ ctx.ui.notify(`Memory ${memoryActionPast(result.action)}: ${memoryTitle(record)}`, "info");
290
+ return;
291
+ }
292
+ const selected = await ctx.ui.select(options.title ?? "pi-chalin Memory", [...sorted.map(format), "Close"]);
273
293
  if (!selected || selected === "Close") return;
274
294
  const record = sorted.find((candidate) => selected === format(candidate));
275
295
  if (!record) return;
@@ -290,6 +310,339 @@ export async function openMemoryReview(
290
310
  if (action && action !== "Close") ctx.ui.notify(`Memory ${action.toLowerCase().replace(/\s+.*/, "")}d: ${memoryTitle(record)}`, "info");
291
311
  }
292
312
 
313
+ type MemoryOverlayAction = "details" | "approve" | "reject" | "delete";
314
+ type MemoryOverlayResult = { action: MemoryOverlayAction; id: string } | undefined;
315
+
316
+ async function openMemoryReviewOverlay(ctx: ExtensionContext, memories: MemoryRecord[], title: string): Promise<MemoryOverlayResult> {
317
+ return ctx.ui.custom<MemoryOverlayResult>(
318
+ (tui, theme, _keybindings, done) => new MemoryReviewOverlay(tui, theme, title, memories, done),
319
+ {
320
+ overlay: true,
321
+ overlayOptions: {
322
+ anchor: "center",
323
+ width: "94%",
324
+ maxHeight: "88%",
325
+ margin: 1,
326
+ },
327
+ },
328
+ );
329
+ }
330
+
331
+ class MemoryReviewOverlay implements Component, Focusable {
332
+ focused = false;
333
+ private query = "";
334
+ private filterIndex = 0;
335
+ private selectedIndex = 0;
336
+ private readonly filters: Array<MemoryRecord["status"] | "all">;
337
+
338
+ constructor(
339
+ private readonly tui: TUI,
340
+ private readonly theme: Theme,
341
+ private readonly title: string,
342
+ private readonly memories: MemoryRecord[],
343
+ private readonly done: (result: MemoryOverlayResult) => void,
344
+ ) {
345
+ const statuses = Array.from(new Set(memories.map((record) => record.status)));
346
+ this.filters = ["all", ...(["pending", "active", "quarantined", "stale", "superseded", "rejected"] as const).filter((status) => statuses.includes(status))];
347
+ }
348
+
349
+ handleInput(data: string): void {
350
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
351
+ this.done(undefined);
352
+ return;
353
+ }
354
+ if (matchesKey(data, "tab") || matchesKey(data, "right")) {
355
+ this.filterIndex = (this.filterIndex + 1) % this.filters.length;
356
+ this.selectedIndex = 0;
357
+ this.tui.requestRender();
358
+ return;
359
+ }
360
+ if (matchesKey(data, "left")) {
361
+ this.filterIndex = (this.filterIndex - 1 + this.filters.length) % this.filters.length;
362
+ this.selectedIndex = 0;
363
+ this.tui.requestRender();
364
+ return;
365
+ }
366
+ if (matchesKey(data, "up")) {
367
+ this.moveSelection(-1);
368
+ return;
369
+ }
370
+ if (matchesKey(data, "down")) {
371
+ this.moveSelection(1);
372
+ return;
373
+ }
374
+ if (matchesKey(data, "pageUp")) {
375
+ this.moveSelection(-MEMORY_OVERLAY_VISIBLE_ROWS);
376
+ return;
377
+ }
378
+ if (matchesKey(data, "pageDown")) {
379
+ this.moveSelection(MEMORY_OVERLAY_VISIBLE_ROWS);
380
+ return;
381
+ }
382
+ if (matchesKey(data, "home")) {
383
+ this.selectedIndex = 0;
384
+ this.tui.requestRender();
385
+ return;
386
+ }
387
+ if (matchesKey(data, "end")) {
388
+ this.selectedIndex = Math.max(0, this.filteredMemories().length - 1);
389
+ this.tui.requestRender();
390
+ return;
391
+ }
392
+ if (matchesKey(data, "enter")) {
393
+ this.finish("details");
394
+ return;
395
+ }
396
+ if (data === "A" || (data === "a" && this.query.length === 0)) {
397
+ this.finish("approve");
398
+ return;
399
+ }
400
+ if (data === "R" || (data === "r" && this.query.length === 0)) {
401
+ this.finish("reject");
402
+ return;
403
+ }
404
+ if (data === "D" || (data === "d" && this.query.length === 0)) {
405
+ this.finish("delete");
406
+ return;
407
+ }
408
+ if (isBackspace(data)) {
409
+ this.query = this.query.slice(0, -1);
410
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredMemories().length - 1));
411
+ this.tui.requestRender();
412
+ return;
413
+ }
414
+ if (matchesKey(data, "ctrl+u")) {
415
+ this.query = "";
416
+ this.selectedIndex = 0;
417
+ this.tui.requestRender();
418
+ return;
419
+ }
420
+ if (isPrintableInput(data)) {
421
+ this.query += data;
422
+ this.selectedIndex = 0;
423
+ this.tui.requestRender();
424
+ }
425
+ }
426
+
427
+ render(width: number): string[] {
428
+ const overlayWidth = Math.max(1, width);
429
+ const innerWidth = Math.max(1, overlayWidth - 2);
430
+ const filtered = this.filteredMemories();
431
+ this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, filtered.length - 1));
432
+ const selected = filtered[this.selectedIndex];
433
+ const border = (text: string) => this.theme.fg("border", text);
434
+ const row = (content = "") => `${border("|")}${padAnsi(content, innerWidth)}${border("|")}`;
435
+ const filter = this.filters[this.filterIndex] ?? "all";
436
+ const header = ` ${this.theme.fg("accent", this.theme.bold(this.title))} ${this.theme.fg("dim", `${filtered.length}/${this.memories.length} records · ${memoryFilterLabel(filter)}`)}`;
437
+ const search = this.query ? `search: ${this.query}` : "type to search";
438
+ const body = innerWidth >= 86
439
+ ? renderMemoryOverlayWide(filtered, selected, this.selectedIndex, this.theme, innerWidth)
440
+ : renderMemoryOverlayNarrow(filtered, selected, this.selectedIndex, this.theme, innerWidth);
441
+ const lines = [
442
+ border(`+${"-".repeat(innerWidth)}+`),
443
+ row(header),
444
+ row(` ${renderMemoryFilterTabs(this.filters, filter, this.theme, Math.max(20, innerWidth - 2))}`),
445
+ row(` ${this.theme.fg("dim", `${search} · tab filter · up/down navigate · enter details · A/R/D action · esc close`)}`),
446
+ row(""),
447
+ ...body.map((line) => row(line)),
448
+ border(`+${"-".repeat(innerWidth)}+`),
449
+ ];
450
+ return clampRenderedLines(lines, overlayWidth);
451
+ }
452
+
453
+ invalidate(): void {}
454
+
455
+ private moveSelection(delta: number): void {
456
+ const max = Math.max(0, this.filteredMemories().length - 1);
457
+ this.selectedIndex = Math.max(0, Math.min(max, this.selectedIndex + delta));
458
+ this.tui.requestRender();
459
+ }
460
+
461
+ private finish(action: MemoryOverlayAction): void {
462
+ const record = this.filteredMemories()[this.selectedIndex];
463
+ if (!record) return;
464
+ if ((action === "approve" || action === "reject") && record.status !== "pending") {
465
+ this.tui.requestRender();
466
+ return;
467
+ }
468
+ this.done({ action, id: record.id });
469
+ }
470
+
471
+ private filteredMemories(): MemoryRecord[] {
472
+ const filter = this.filters[this.filterIndex] ?? "all";
473
+ const terms = normalizeSearch(this.query).split(" ").filter(Boolean);
474
+ return this.memories.filter((record) => {
475
+ if (filter !== "all" && record.status !== filter) return false;
476
+ if (terms.length === 0) return true;
477
+ const haystack = normalizeSearch(memorySearchText(record));
478
+ return terms.every((term) => haystack.includes(term));
479
+ });
480
+ }
481
+ }
482
+
483
+ const MEMORY_OVERLAY_VISIBLE_ROWS = 18;
484
+
485
+ function renderMemoryOverlayWide(
486
+ memories: MemoryRecord[],
487
+ selected: MemoryRecord | undefined,
488
+ selectedIndex: number,
489
+ theme: Theme,
490
+ width: number,
491
+ ): string[] {
492
+ const listWidth = Math.max(36, Math.min(62, Math.floor(width * 0.48)));
493
+ const detailWidth = Math.max(24, width - listWidth - 3);
494
+ const list = renderMemoryListWindow(memories, selectedIndex, theme, listWidth);
495
+ const detail = renderMemoryDetailPane(selected, theme, detailWidth, MEMORY_OVERLAY_VISIBLE_ROWS);
496
+ const rows = Math.max(MEMORY_OVERLAY_VISIBLE_ROWS, list.length, detail.length);
497
+ const lines: string[] = [];
498
+ for (let index = 0; index < rows; index += 1) {
499
+ lines.push(`${padAnsi(list[index] ?? "", listWidth)} ${theme.fg("border", "|")} ${padAnsi(detail[index] ?? "", detailWidth)}`);
500
+ }
501
+ return lines;
502
+ }
503
+
504
+ function renderMemoryOverlayNarrow(
505
+ memories: MemoryRecord[],
506
+ selected: MemoryRecord | undefined,
507
+ selectedIndex: number,
508
+ theme: Theme,
509
+ width: number,
510
+ ): string[] {
511
+ return [
512
+ ...renderMemoryListWindow(memories, selectedIndex, theme, width),
513
+ "",
514
+ ...renderMemoryDetailPane(selected, theme, width, 8),
515
+ ];
516
+ }
517
+
518
+ function renderMemoryListWindow(memories: MemoryRecord[], selectedIndex: number, theme: Theme, width: number): string[] {
519
+ if (memories.length === 0) return [theme.fg("muted", "No matching memories.")];
520
+ const startIndex = Math.max(
521
+ 0,
522
+ Math.min(selectedIndex - Math.floor(MEMORY_OVERLAY_VISIBLE_ROWS / 2), memories.length - MEMORY_OVERLAY_VISIBLE_ROWS),
523
+ );
524
+ const endIndex = Math.min(startIndex + MEMORY_OVERLAY_VISIBLE_ROWS, memories.length);
525
+ const lines: string[] = [];
526
+ for (let index = startIndex; index < endIndex; index += 1) {
527
+ const record = memories[index];
528
+ if (!record) continue;
529
+ const active = index === selectedIndex;
530
+ const line = formatMemoryOverlayRow(record, width - 2);
531
+ const prefix = active ? theme.fg("accent", "> ") : " ";
532
+ const colored = active ? theme.bg("selectedBg", theme.fg("text", line)) : colorMemoryRow(theme, record, line);
533
+ lines.push(prefix + colored);
534
+ }
535
+ if (memories.length > MEMORY_OVERLAY_VISIBLE_ROWS) {
536
+ lines.push(theme.fg("dim", ` ${selectedIndex + 1}/${memories.length}`));
537
+ }
538
+ return lines;
539
+ }
540
+
541
+ function renderMemoryDetailPane(record: MemoryRecord | undefined, theme: Theme, width: number, maxLines: number): string[] {
542
+ if (!record) return [theme.fg("muted", "Select a memory to inspect details.")];
543
+ const title = stripMemoryPrefix(record.content) || record.category;
544
+ const metadata = [
545
+ record.status,
546
+ record.category,
547
+ record.scope,
548
+ record.sourceAgent,
549
+ record.topicKey,
550
+ ].filter(Boolean).join(" · ");
551
+ const contentLines = wrapPlainText(record.content, Math.max(12, width)).slice(0, Math.max(2, maxLines - 7));
552
+ return [
553
+ theme.fg("accent", theme.bold(truncateToWidth(title, width, "...", false))),
554
+ theme.fg("dim", truncateToWidth(metadata, width, "...", false)),
555
+ "",
556
+ ...contentLines.map((line) => theme.fg("text", line)),
557
+ "",
558
+ theme.fg("dim", `confidence ${Math.round(record.confidence * 100)}% · importance ${record.importance}`),
559
+ theme.fg("dim", `seen ${record.duplicateCount} · used ${record.useCount ?? 0} · revisions ${record.revisionCount}`),
560
+ record.evidence ? theme.fg("muted", truncateToWidth(`evidence: ${record.evidence}`, width, "...", false)) : "",
561
+ ].filter((line) => line !== "");
562
+ }
563
+
564
+ function formatMemoryOverlayRow(record: MemoryRecord, width: number): string {
565
+ const status = `${memoryStatusIcon(record.status)} ${record.status}`.padEnd(13);
566
+ const category = truncateUi(record.category, 14).padEnd(14);
567
+ const source = truncateUi(record.sourceAgent, 15).padEnd(15);
568
+ const titleWidth = Math.max(16, width - visibleWidth(status) - visibleWidth(category) - visibleWidth(source) - 6);
569
+ return truncateToWidth(`${status} ${category} ${source} ${truncateUi(stripMemoryPrefix(record.content), titleWidth)}`, width, "...", false);
570
+ }
571
+
572
+ function renderMemoryFilterTabs(filters: Array<MemoryRecord["status"] | "all">, selected: MemoryRecord["status"] | "all", theme: Theme, width: number): string {
573
+ const text = filters.map((filter) => {
574
+ const label = ` ${memoryFilterLabel(filter)} `;
575
+ return filter === selected ? theme.bg("selectedBg", theme.fg("text", label)) : theme.fg("dim", label);
576
+ }).join(" ");
577
+ return truncateToWidth(text, width, "...", true);
578
+ }
579
+
580
+ function memoryFilterLabel(filter: MemoryRecord["status"] | "all"): string {
581
+ return filter === "all" ? "all" : filter;
582
+ }
583
+
584
+ function colorMemoryRow(theme: Theme, record: MemoryRecord, line: string): string {
585
+ if (record.status === "pending") return theme.fg("accent", line);
586
+ if (record.status === "active") return theme.fg("text", line);
587
+ if (record.status === "rejected") return theme.fg("muted", line);
588
+ return theme.fg("dim", line);
589
+ }
590
+
591
+ function memorySearchText(record: MemoryRecord): string {
592
+ return [
593
+ record.id,
594
+ record.status,
595
+ record.category,
596
+ record.sourceAgent,
597
+ record.scope,
598
+ record.topicKey,
599
+ record.content,
600
+ record.evidence,
601
+ ].filter(Boolean).join(" ");
602
+ }
603
+
604
+ function normalizeSearch(value: string): string {
605
+ return value.toLowerCase().replace(/\s+/g, " ").trim();
606
+ }
607
+
608
+ function wrapPlainText(text: string, width: number): string[] {
609
+ const normalized = text.replace(/\r/g, "").split("\n").flatMap((line) => line.trim() ? [line.trim()] : [""]);
610
+ const result: string[] = [];
611
+ for (const line of normalized) {
612
+ if (!line) {
613
+ result.push("");
614
+ continue;
615
+ }
616
+ let current = "";
617
+ for (const word of line.split(/\s+/)) {
618
+ const next = current ? `${current} ${word}` : word;
619
+ if (visibleWidth(next) <= width) {
620
+ current = next;
621
+ continue;
622
+ }
623
+ if (current) result.push(current);
624
+ current = visibleWidth(word) > width ? truncateToWidth(word, width, "", false) : word;
625
+ }
626
+ if (current) result.push(current);
627
+ }
628
+ return result.length ? result : [""];
629
+ }
630
+
631
+ function isBackspace(data: string): boolean {
632
+ return data === "\b" || data === "\x7f" || matchesKey(data, "backspace");
633
+ }
634
+
635
+ function isPrintableInput(data: string): boolean {
636
+ return data.length === 1 && data >= " " && data !== "\x7f";
637
+ }
638
+
639
+ function memoryActionPast(action: MemoryOverlayAction): string {
640
+ if (action === "approve") return "approved";
641
+ if (action === "reject") return "rejected";
642
+ if (action === "delete") return "deleted";
643
+ return "opened";
644
+ }
645
+
293
646
  function formatMemoryListItem(record: MemoryRecord): string {
294
647
  return `${memoryStatusIcon(record.status)} ${record.status} · ${record.category} · ${record.sourceAgent} · ${memoryTitle(record)}`;
295
648
  }