@pellux/goodvibes-tui 0.21.0 → 0.22.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/package.json +2 -1
  4. package/src/cli/completions/generate.ts +4 -8
  5. package/src/cli/entrypoint.ts +6 -0
  6. package/src/cli/parser.ts +17 -0
  7. package/src/cli/types.ts +2 -0
  8. package/src/config/goodvibes-home-audit.ts +2 -0
  9. package/src/core/context-auto-compact.ts +77 -0
  10. package/src/core/turn-event-wiring.ts +124 -0
  11. package/src/daemon/cli.ts +5 -0
  12. package/src/input/command-registry.ts +1 -0
  13. package/src/input/commands/control-room-runtime.ts +5 -5
  14. package/src/input/commands/provider.ts +57 -3
  15. package/src/input/commands/session-workflow.ts +8 -16
  16. package/src/input/commands/session.ts +70 -20
  17. package/src/input/commands.ts +0 -2
  18. package/src/input/handler-modal-routes.ts +37 -0
  19. package/src/input/handler-modal-token-routes.ts +19 -5
  20. package/src/input/handler-onboarding.ts +18 -0
  21. package/src/input/handler.ts +1 -0
  22. package/src/input/onboarding/onboarding-wizard-apply.ts +10 -0
  23. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +14 -0
  24. package/src/input/onboarding/onboarding-wizard-steps.ts +6 -0
  25. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  26. package/src/input/settings-modal-behavior.ts +5 -0
  27. package/src/input/settings-modal-data.ts +77 -3
  28. package/src/input/settings-modal-mutations.ts +3 -0
  29. package/src/input/settings-modal-reset.ts +154 -0
  30. package/src/input/settings-modal.ts +55 -13
  31. package/src/main.ts +36 -28
  32. package/src/panels/agent-inspector-panel.ts +120 -18
  33. package/src/panels/agent-inspector-shared.ts +29 -0
  34. package/src/panels/builtin/development.ts +1 -0
  35. package/src/panels/builtin/knowledge.ts +14 -13
  36. package/src/panels/builtin/operations.ts +22 -1
  37. package/src/panels/builtin/shared.ts +7 -0
  38. package/src/panels/cockpit-panel.ts +123 -3
  39. package/src/panels/cockpit-read-model.ts +232 -0
  40. package/src/panels/index.ts +1 -1
  41. package/src/panels/knowledge-graph-panel.ts +84 -0
  42. package/src/panels/memory-panel.ts +370 -40
  43. package/src/panels/session-maintenance.ts +66 -15
  44. package/src/renderer/agent-detail-modal.ts +107 -3
  45. package/src/renderer/context-status-hint.ts +54 -0
  46. package/src/renderer/settings-modal.ts +14 -3
  47. package/src/renderer/shell-surface.ts +10 -0
  48. package/src/runtime/bootstrap-command-parts.ts +4 -0
  49. package/src/runtime/bootstrap-core.ts +24 -0
  50. package/src/runtime/bootstrap-shell.ts +11 -0
  51. package/src/runtime/bootstrap.ts +7 -0
  52. package/src/runtime/services.ts +6 -1
  53. package/src/version.ts +1 -1
  54. package/src/panels/knowledge-panel.ts +0 -343
@@ -1,18 +1,26 @@
1
1
  /**
2
- * MemoryPanel — project memory substrate TUI panel.
2
+ * MemoryPanel — merged project memory substrate panel.
3
3
  *
4
- * Migrated to SearchableListPanel<MemoryRecord> (Wave B1).
4
+ * TASK-040: Merged from the former separate MemoryPanel + KnowledgePanel.
5
+ * One panel, two filter modes toggled with Tab:
6
+ * - 'all' : full record list with search (former MemoryPanel behaviour)
7
+ * - 'review' : review-queue view with r/s/c/f actions (former KnowledgePanel behaviour)
8
+ *
9
+ * Panel id stays 'memory'; the 'knowledge' id is repointed to the graph view.
10
+ * No capability removed from either previous surface.
5
11
  */
6
12
 
7
13
  import type { Line } from '../types/grid.ts';
8
14
  import type { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
9
- import type { MemoryRecord, MemoryClass } from '@pellux/goodvibes-sdk/platform/state';
10
- import { SearchableListPanel } from './scrollable-list-panel.ts';
15
+ import type { MemoryClass, MemoryRecord, MemoryReviewState } from '@pellux/goodvibes-sdk/platform/state';
16
+ import { ScrollableListPanel, SearchableListPanel } from './scrollable-list-panel.ts';
17
+ import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
11
18
  import {
12
19
  buildBodyText,
13
20
  buildGuidanceLine,
14
21
  buildKeyValueLine,
15
22
  buildPanelLine,
23
+ buildPanelWorkspace,
16
24
  extendPalette,
17
25
  DEFAULT_PANEL_PALETTE,
18
26
  } from './polish.ts';
@@ -21,6 +29,10 @@ import {
21
29
  isPanelSearchCancel,
22
30
  } from './search-focus.ts';
23
31
 
32
+ // ---------------------------------------------------------------------------
33
+ // Colour palette
34
+ // ---------------------------------------------------------------------------
35
+
24
36
  const C = extendPalette(DEFAULT_PANEL_PALETTE, {
25
37
  header: '#94a3b8',
26
38
  headerBg: '#1e293b',
@@ -38,6 +50,21 @@ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
38
50
  searchFg: '#e2e8f0',
39
51
  });
40
52
 
53
+ // ---------------------------------------------------------------------------
54
+ // Filter modes
55
+ // ---------------------------------------------------------------------------
56
+
57
+ type FilterMode = 'all' | 'review';
58
+
59
+ const FILTER_LABELS: Record<FilterMode, string> = {
60
+ all: 'All Records',
61
+ review: 'Review Queue',
62
+ };
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Helpers
66
+ // ---------------------------------------------------------------------------
67
+
41
68
  function fmtTime(ts: number): string {
42
69
  const d = new Date(ts);
43
70
  return d.toISOString().slice(0, 16).replace('T', ' ');
@@ -45,50 +72,82 @@ function fmtTime(ts: number): string {
45
72
 
46
73
  function classColor(cls: MemoryClass): string {
47
74
  switch (cls) {
48
- case 'decision': return C.decision;
49
- case 'constraint': return C.constraint;
50
- case 'incident': return C.incident;
51
- case 'pattern': return C.pattern;
52
- case 'fact': return C.fact;
53
- case 'risk': return C.risk;
54
- case 'runbook': return C.runbook;
75
+ case 'decision': return C.decision;
76
+ case 'constraint': return C.constraint;
77
+ case 'incident': return C.incident;
78
+ case 'pattern': return C.pattern;
79
+ case 'fact': return C.fact;
80
+ case 'risk': return C.risk;
81
+ case 'runbook': return C.runbook;
55
82
  case 'architecture': return C.architecture;
56
- case 'ownership': return C.ownership;
83
+ case 'ownership': return C.ownership;
57
84
  }
58
85
  }
59
86
 
87
+ function reviewStateColor(state: MemoryReviewState): string {
88
+ switch (state) {
89
+ case 'reviewed': return C.good;
90
+ case 'stale': return C.warn;
91
+ case 'contradicted': return C.bad;
92
+ case 'fresh':
93
+ default: return C.info;
94
+ }
95
+ }
96
+
97
+ function formatConfidence(confidence: number): string {
98
+ return `${confidence.toString().padStart(3, ' ')}%`;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // MemoryPanel
103
+ // ---------------------------------------------------------------------------
104
+
60
105
  export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
61
- private registry: MemoryRegistry;
106
+ private readonly registry: MemoryRegistry;
107
+ private filterMode: FilterMode = 'all';
62
108
  private filterFocused = false;
63
109
  private unsubscribe?: () => void;
64
110
 
111
+ // Review-mode confirm state (for destructive stale/contradict actions)
112
+ private confirm: ConfirmState<{ id: string; action: 'stale' | 'contradicted' }> | null = null;
113
+
114
+ // Cached records for review-mode (reviewQueue-first, same as former KnowledgePanel)
115
+ private reviewRecords: MemoryRecord[] = [];
116
+
65
117
  constructor(registry: MemoryRegistry) {
66
118
  super('memory', 'Memory', 'M', 'agent');
67
119
  this.registry = registry;
68
120
  }
69
121
 
70
- onActivate(): void {
122
+ // ---------------------------------------------------------------------------
123
+ // Lifecycle
124
+ // ---------------------------------------------------------------------------
125
+
126
+ override onActivate(): void {
71
127
  super.onActivate();
72
128
  this.searchQuery = '';
73
129
  this.invalidateFilter();
74
130
  this.filterFocused = false;
131
+ this.confirm = null;
132
+ this.refreshReviewRecords();
75
133
  this.unsubscribe = this.registry.subscribe(() => {
76
134
  this.invalidateFilter();
135
+ this.refreshReviewRecords();
77
136
  this.markDirty();
78
137
  });
79
138
  }
80
139
 
81
- onDeactivate(): void {
140
+ override onDeactivate(): void {
82
141
  super.onDeactivate();
83
142
  }
84
143
 
85
- onDestroy(): void {
144
+ override onDestroy(): void {
86
145
  this.unsubscribe?.();
87
146
  this.unsubscribe = undefined;
88
147
  }
89
148
 
90
149
  // ---------------------------------------------------------------------------
91
- // SearchableListPanel implementation
150
+ // SearchableListPanel implementation (used in 'all' filter mode)
92
151
  // ---------------------------------------------------------------------------
93
152
 
94
153
  protected getAllItems(): readonly MemoryRecord[] {
@@ -109,6 +168,17 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
109
168
  }
110
169
 
111
170
  protected renderItem(record: MemoryRecord, index: number, selected: boolean, width: number): Line {
171
+ if (this.filterMode === 'review') {
172
+ // Review-mode row: reviewState + confidence (matches former KnowledgePanel row)
173
+ const bg = selected ? C.selectBg : undefined;
174
+ return buildPanelLine(width, [
175
+ [' ', C.label, bg],
176
+ [record.reviewState.padEnd(13), reviewStateColor(record.reviewState), bg],
177
+ [` ${formatConfidence(record.confidence)} `, C.value, bg],
178
+ [record.summary.slice(0, Math.max(0, width - 26)), C.value, bg],
179
+ ]);
180
+ }
181
+ // All-mode row: scope/class + id + time + summary (matches former MemoryPanel row)
112
182
  const bg = selected ? C.selected : undefined;
113
183
  return buildPanelLine(width, [
114
184
  [' ', C.label, bg],
@@ -122,63 +192,222 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
122
192
  }
123
193
 
124
194
  protected override getPalette() { return C; }
195
+
125
196
  protected override getEmptyStateMessage() {
197
+ if (this.filterMode === 'review') return 'No records in the review queue.';
126
198
  return this.searchQuery
127
199
  ? ` No records matching "${this.searchQuery}"`
128
200
  : ' No memory records. Use /recall add <class> <summary> to create one.';
129
201
  }
202
+
130
203
  protected override getEmptyStateActions() {
131
204
  return [
132
205
  { command: '/recall add fact <summary>', summary: 'capture a durable fact directly' },
133
206
  { command: '/recall capture incident latest', summary: 'promote the latest incident into memory' },
207
+ { command: '/recall capture policy', summary: 'store the current policy posture as durable evidence' },
134
208
  ];
135
209
  }
136
210
 
211
+ // ---------------------------------------------------------------------------
212
+ // Internal helpers
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Override getItems() so that ScrollableListPanel infrastructure (renderList,
217
+ * clampSelection, navigation bounds) all operate on reviewRecords in review
218
+ * mode — preventing the render/action index desync where rendered list index N
219
+ * did not correspond to reviewRecords[N] when the two lists differ.
220
+ */
221
+ protected override getItems(): readonly MemoryRecord[] {
222
+ if (this.filterMode === 'review') return this.reviewRecords;
223
+ return super.getItems();
224
+ }
225
+
226
+ private refreshReviewRecords(): void {
227
+ this.reviewRecords = this.registry.reviewQueue(24);
228
+ this.clampSelection();
229
+ }
230
+
231
+ private cycleFilter(): void {
232
+ const modes: FilterMode[] = ['all', 'review'];
233
+ const next = modes[(modes.indexOf(this.filterMode) + 1) % modes.length];
234
+ this.filterMode = next;
235
+ this.invalidateFilter();
236
+ this.refreshReviewRecords();
237
+ this.markDirty();
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Input
242
+ // ---------------------------------------------------------------------------
243
+
137
244
  handleInput(key: string): boolean {
138
- // Filter-focus mode: typing goes into the search query
139
- if (this.filterFocused) {
140
- const items = this.getItems();
141
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
142
- if (transition === 'focus-list') {
143
- this.filterFocused = false;
245
+ // Review confirm dialog intercepts all input
246
+ if (this.confirm) {
247
+ const result = handleConfirmInput(this.confirm, key);
248
+ if (result === 'confirmed') {
249
+ const { id, action } = this.confirm.subject;
250
+ this.confirm = null;
251
+ const record = this.reviewRecords.find((r) => r.id === id);
252
+ if (record) {
253
+ try {
254
+ if (action === 'stale') {
255
+ this.registry.review(id, {
256
+ state: 'stale',
257
+ confidence: Math.min(record.confidence, 40),
258
+ reviewedBy: 'operator',
259
+ staleReason: 'marked stale from the memory panel',
260
+ });
261
+ } else {
262
+ this.registry.review(id, {
263
+ state: 'contradicted',
264
+ confidence: 0,
265
+ reviewedBy: 'operator',
266
+ staleReason: 'marked contradicted from the memory panel',
267
+ });
268
+ }
269
+ } catch (e) {
270
+ this.setError(`Review update failed: ${e instanceof Error ? e.message : String(e)}`);
271
+ }
272
+ }
273
+ this.refreshReviewRecords();
144
274
  this.markDirty();
145
275
  return true;
146
276
  }
147
- if (isPanelSearchCancel(key)) {
148
- this.filterFocused = false;
149
- return super.handleInput(key);
277
+ if (result === 'cancelled') {
278
+ this.confirm = null;
279
+ this.markDirty();
280
+ return true;
150
281
  }
151
- return super.handleInput(key);
282
+ if (result === 'absorbed') return true;
152
283
  }
153
284
 
154
- const items = this.getItems();
155
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
156
- if (transition === 'focus-search') {
157
- this.filterFocused = true;
158
- this.markDirty();
285
+ // Tab cycles filter modes
286
+ if (key === 'tab') {
287
+ this.cycleFilter();
159
288
  return true;
160
289
  }
161
290
 
162
- if (key === 'r') {
163
- this.invalidateFilter();
164
- this.markDirty();
165
- return true;
291
+ // Review-mode specific actions (r/s/c/f)
292
+ if (this.filterMode === 'review') {
293
+ const selected = this.reviewRecords[this.selectedIndex];
294
+
295
+ if (key === 'enter' || key === 'return' || key === 'r') {
296
+ if (!selected) return false;
297
+ this.registry.review(selected.id, {
298
+ state: 'reviewed',
299
+ confidence: Math.max(selected.confidence, 85),
300
+ reviewedBy: 'operator',
301
+ });
302
+ this.refreshReviewRecords();
303
+ this.markDirty();
304
+ return true;
305
+ }
306
+ if (key === 's') {
307
+ if (!selected) return false;
308
+ this.confirm = { subject: { id: selected.id, action: 'stale' }, label: selected.summary.slice(0, 40) };
309
+ this.markDirty();
310
+ return true;
311
+ }
312
+ if (key === 'c') {
313
+ if (!selected) return false;
314
+ this.confirm = { subject: { id: selected.id, action: 'contradicted' }, label: selected.summary.slice(0, 40) };
315
+ this.markDirty();
316
+ return true;
317
+ }
318
+ if (key === 'f') {
319
+ if (!selected) return false;
320
+ this.registry.review(selected.id, {
321
+ state: 'fresh',
322
+ confidence: Math.max(selected.confidence, 60),
323
+ reviewedBy: 'operator',
324
+ });
325
+ this.refreshReviewRecords();
326
+ this.markDirty();
327
+ return true;
328
+ }
329
+ }
330
+
331
+ // All-mode: search filter focus
332
+ if (this.filterMode === 'all') {
333
+ if (this.filterFocused) {
334
+ const items = this.getItems();
335
+ const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
336
+ if (transition === 'focus-list') {
337
+ this.filterFocused = false;
338
+ this.markDirty();
339
+ return true;
340
+ }
341
+ if (isPanelSearchCancel(key)) {
342
+ this.filterFocused = false;
343
+ return super.handleInput(key);
344
+ }
345
+ return super.handleInput(key);
346
+ }
347
+
348
+ const items = this.getItems();
349
+ const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
350
+ if (transition === 'focus-search') {
351
+ this.filterFocused = true;
352
+ this.markDirty();
353
+ return true;
354
+ }
355
+
356
+ if (key === 'r') {
357
+ this.invalidateFilter();
358
+ this.markDirty();
359
+ return true;
360
+ }
166
361
  }
167
362
 
363
+ // In review mode, navigation keys (j/k/up/down/etc.) must bypass
364
+ // SearchableListPanel's printable-character interception, which would
365
+ // otherwise swallow single-char keys like 'j' and 'k' as search input.
366
+ if (this.filterMode === 'review') {
367
+ return ScrollableListPanel.prototype.handleInput.call(this, key);
368
+ }
168
369
  return super.handleInput(key);
169
370
  }
170
371
 
372
+ // ---------------------------------------------------------------------------
373
+ // Render
374
+ // ---------------------------------------------------------------------------
375
+
171
376
  render(width: number, height: number): Line[] {
172
377
  this.clampSelection();
173
- const intro = 'Durable project memory across decisions, constraints, incidents, patterns, risks, runbooks, and related provenance.';
174
378
 
379
+ // Review confirm dialog takes over the full panel
380
+ if (this.confirm) {
381
+ return buildPanelWorkspace(width, height, {
382
+ title: 'Memory',
383
+ intro: '',
384
+ sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
385
+ footerLines: [buildPanelLine(width, [[' y confirm n / Esc cancel', C.dim]])],
386
+ palette: C,
387
+ });
388
+ }
389
+
390
+ const filterLabel = FILTER_LABELS[this.filterMode];
391
+ const filterToggleLine = buildPanelLine(width, [
392
+ [' Filter: ', C.label],
393
+ [filterLabel, C.info],
394
+ [' (Tab to toggle)', C.dim],
395
+ ]);
396
+
397
+ if (this.filterMode === 'review') {
398
+ return this.renderReviewMode(width, height, filterToggleLine);
399
+ }
400
+ return this.renderAllMode(width, height, filterToggleLine);
401
+ }
402
+
403
+ private renderAllMode(width: number, height: number, filterToggleLine: Line): Line[] {
175
404
  const records = this.getItems();
176
405
  const byClass = new Map<MemoryClass, number>();
177
406
  for (const record of records) {
178
407
  byClass.set(record.cls, (byClass.get(record.cls) ?? 0) + 1);
179
408
  }
180
409
 
181
- const filterLine = this.buildFilterInputLine(width, 'Filter', this.filterFocused);
410
+ const filterInputLine = this.buildFilterInputLine(width, 'Search', this.filterFocused);
182
411
 
183
412
  const summaryLines: Line[] = [
184
413
  buildKeyValueLine(width, [
@@ -188,7 +417,8 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
188
417
  { label: 'incidents', value: String(byClass.get('incident') ?? 0), valueColor: C.incident },
189
418
  { label: 'runbooks', value: String(byClass.get('runbook') ?? 0), valueColor: C.runbook },
190
419
  ], C),
191
- filterLine,
420
+ filterToggleLine,
421
+ filterInputLine,
192
422
  buildGuidanceLine(width, '/recall review', 'review durable knowledge and queue posture from the command surface', C),
193
423
  ];
194
424
 
@@ -218,7 +448,107 @@ export class MemoryPanel extends SearchableListPanel<MemoryRecord> {
218
448
  header: summaryLines,
219
449
  footer: [
220
450
  ...selectedLines,
221
- buildPanelLine(width, [[' / search j/k or Up/Down move r reload Esc clear search', C.dim]]),
451
+ buildPanelLine(width, [[' / search j/k or Up/Down move r reload Tab: Review Queue Esc clear search', C.dim]]),
452
+ ],
453
+ });
454
+ }
455
+
456
+ private renderReviewMode(width: number, height: number, filterToggleLine: Line): Line[] {
457
+ if (this.reviewRecords.length === 0) this.refreshReviewRecords();
458
+
459
+ const allRecords = this.registry.search({ limit: 200 });
460
+ const queue = this.registry.reviewQueue(24);
461
+ const byClass = new Map<MemoryClass, number>();
462
+ const byReview = new Map<MemoryReviewState, number>();
463
+ const byScope = new Map<string, number>();
464
+ for (const record of allRecords) {
465
+ byClass.set(record.cls, (byClass.get(record.cls) ?? 0) + 1);
466
+ byReview.set(record.reviewState, (byReview.get(record.reviewState) ?? 0) + 1);
467
+ byScope.set(record.scope, (byScope.get(record.scope) ?? 0) + 1);
468
+ }
469
+
470
+ const classLines: Line[] = [
471
+ buildPanelLine(width, [
472
+ [' facts ', C.label], [String(byClass.get('fact') ?? 0), C.good],
473
+ [' risks ', C.label], [String(byClass.get('risk') ?? 0), (byClass.get('risk') ?? 0) > 0 ? C.warn : C.good],
474
+ [' runbooks ', C.label], [String(byClass.get('runbook') ?? 0), C.info],
475
+ [' architecture ', C.label], [String(byClass.get('architecture') ?? 0), C.info],
476
+ [' incidents ', C.label], [String(byClass.get('incident') ?? 0), (byClass.get('incident') ?? 0) > 0 ? C.bad : C.good],
477
+ ]),
478
+ buildPanelLine(width, [
479
+ [' decisions ', C.label], [String(byClass.get('decision') ?? 0), C.value],
480
+ [' constraints ', C.label], [String(byClass.get('constraint') ?? 0), C.value],
481
+ [' ownership ', C.label], [String(byClass.get('ownership') ?? 0), C.value],
482
+ [' patterns ', C.label], [String(byClass.get('pattern') ?? 0), C.value],
483
+ [' total ', C.label], [String(allRecords.length), C.value],
484
+ ]),
485
+ ];
486
+
487
+ const reviewLines: Line[] = [
488
+ buildPanelLine(width, [
489
+ [' reviewed ', C.label], [String(byReview.get('reviewed') ?? 0), C.good],
490
+ [' fresh ', C.label], [String(byReview.get('fresh') ?? 0), C.info],
491
+ [' stale ', C.label], [String(byReview.get('stale') ?? 0), C.warn],
492
+ [' contradicted ', C.label], [String(byReview.get('contradicted') ?? 0), C.bad],
493
+ [' Queue ', C.label], [String(queue.length), queue.length > 0 ? C.warn : C.good],
494
+ ]),
495
+ buildPanelLine(width, [
496
+ [' session ', C.label], [String(byScope.get('session') ?? 0), C.info],
497
+ [' project ', C.label], [String(byScope.get('project') ?? 0), C.value],
498
+ [' team ', C.label], [String(byScope.get('team') ?? 0), C.good],
499
+ ]),
500
+ filterToggleLine,
501
+ buildGuidanceLine(width, '/recall review', 'work the stale and contradicted queue from the command surface', C),
502
+ ];
503
+
504
+ const selectedRecord = this.reviewRecords[this.selectedIndex];
505
+ const selectedLines: Line[] = [];
506
+ if (selectedRecord) {
507
+ selectedLines.push(buildPanelLine(width, [[' Selected', C.label]]));
508
+ selectedLines.push(buildKeyValueLine(width, [
509
+ { label: 'Class', value: selectedRecord.cls, valueColor: C.value },
510
+ { label: 'Scope', value: selectedRecord.scope, valueColor: C.info },
511
+ { label: 'Review', value: selectedRecord.reviewState, valueColor: reviewStateColor(selectedRecord.reviewState) },
512
+ { label: 'Confidence', value: formatConfidence(selectedRecord.confidence), valueColor: C.value },
513
+ ], C));
514
+ selectedLines.push(...buildBodyText(width, `Summary: ${selectedRecord.summary}`, C, C.value));
515
+ if (selectedRecord.detail) selectedLines.push(...buildBodyText(width, `Detail: ${selectedRecord.detail}`, C, C.dim));
516
+ if (selectedRecord.provenance.length) {
517
+ selectedLines.push(...buildBodyText(
518
+ width,
519
+ `Provenance: ${selectedRecord.provenance.map((p) => `${p.kind}:${p.ref}`).join(', ')}`,
520
+ C,
521
+ C.dim,
522
+ ));
523
+ }
524
+ if (selectedRecord.staleReason) {
525
+ selectedLines.push(...buildBodyText(
526
+ width,
527
+ `Stale reason: ${selectedRecord.staleReason}`,
528
+ C,
529
+ selectedRecord.reviewState === 'contradicted' ? C.bad : C.warn,
530
+ ));
531
+ }
532
+ if (selectedRecord.reviewedAt) {
533
+ selectedLines.push(buildPanelLine(width, [
534
+ [' Reviewed: ', C.label],
535
+ [new Date(selectedRecord.reviewedAt).toLocaleString(), C.dim],
536
+ ]));
537
+ if (selectedRecord.reviewedBy) {
538
+ selectedLines.push(buildPanelLine(width, [
539
+ [' Reviewer: ', C.label],
540
+ [selectedRecord.reviewedBy, C.dim],
541
+ ]));
542
+ }
543
+ }
544
+ }
545
+
546
+ return this.renderList(width, height, {
547
+ title: 'Memory',
548
+ header: [...classLines, ...reviewLines],
549
+ footer: [
550
+ ...(selectedLines.length > 0 ? selectedLines : []),
551
+ buildPanelLine(width, [[' Tab: All Records Up/Down move r/Enter reviewed s stale c contradicted f fresh', C.dim]]),
222
552
  ],
223
553
  });
224
554
  }
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Session maintenance types and local evaluator.
3
+ *
4
+ * The canonical evaluator is the SDK version exported from @/runtime/index.ts
5
+ * (via operations.evaluateSessionMaintenance), which reads from configManager.
6
+ *
7
+ * This module provides:
8
+ * - The shared type surface used across TUI panels.
9
+ * - A thin local evaluator kept in sync with the SDK signature so panel code
10
+ * that passes configManager has a coherent call site.
11
+ */
12
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
13
+
1
14
  export type PanelGuidanceMode = 'off' | 'minimal' | 'guided';
2
15
  export type PanelSessionMaintenanceLevel = 'stable' | 'watch' | 'suggest-compact' | 'compacting' | 'needs-repair' | 'unknown';
3
16
 
@@ -9,9 +22,12 @@ export interface PanelSessionMaintenanceSession {
9
22
  readonly lineage?: readonly PanelSessionMaintenanceLineageEntry[];
10
23
  readonly lastCompactedAt?: number;
11
24
  readonly compactionMessageCount?: number;
25
+ readonly compactionState?: string;
12
26
  }
13
27
 
14
28
  export interface PanelSessionMaintenanceInput {
29
+ /** ConfigManager used to read behavior.autoCompactThreshold and related keys. */
30
+ readonly configManager: Pick<ConfigManager, 'get'>;
15
31
  readonly currentTokens: number;
16
32
  readonly contextWindow: number;
17
33
  readonly messageCount?: number;
@@ -27,7 +43,15 @@ export interface PanelSessionMaintenanceStatus {
27
43
  readonly guidanceMode: PanelGuidanceMode;
28
44
  readonly usagePct: number;
29
45
  readonly remainingTokens: number;
46
+ /**
47
+ * Compact threshold as a percent integer.
48
+ * SDK schema range is [10, 100]; default is 80.
49
+ */
30
50
  readonly thresholdPct: number;
51
+ /**
52
+ * True when autoCompactThreshold is in its valid range (>0).
53
+ * With SDK default (80), auto-compact is active unless explicitly lowered below 10.
54
+ */
31
55
  readonly autoCompactEnabled: boolean;
32
56
  readonly sessionMemoryCount: number;
33
57
  readonly compactionCount: number;
@@ -35,19 +59,33 @@ export interface PanelSessionMaintenanceStatus {
35
59
  readonly compactRecommended: boolean;
36
60
  }
37
61
 
62
+ /**
63
+ * Evaluate session maintenance from config-driven thresholds.
64
+ *
65
+ * behavior.autoCompactThreshold (percent integer, SDK schema range [10, 100], default 80):
66
+ * - [10, 100] → threshold at that percent; autoCompactEnabled = true.
67
+ * - 0 (defensive fallback for null/missing config only; not a valid schema value).
68
+ *
69
+ * NOTE: The SDK's evaluateSessionMaintenance (from @/runtime/index.ts) is the
70
+ * canonical implementation used in production. This local version exists so
71
+ * panel tests can import types without crossing the SDK boundary.
72
+ */
38
73
  export function evaluateSessionMaintenance(input: PanelSessionMaintenanceInput): PanelSessionMaintenanceStatus {
39
- const guidanceMode: PanelGuidanceMode = 'minimal';
40
- const thresholdPct = 80;
41
- const autoCompactEnabled = false;
74
+ const guidanceMode: PanelGuidanceMode = (input.configManager.get('behavior.guidanceMode') as PanelGuidanceMode | undefined) ?? 'minimal';
75
+ const rawThreshold = Number(input.configManager.get('behavior.autoCompactThreshold') ?? 0);
76
+ const thresholdPct = Math.max(0, Number.isFinite(rawThreshold) ? rawThreshold : 0);
77
+ const autoCompactEnabled = thresholdPct > 0;
78
+
42
79
  const usagePct = input.contextWindow > 0 ? Math.min(100, Math.round((Math.max(0, input.currentTokens) / input.contextWindow) * 100)) : 0;
43
80
  const remainingTokens = Math.max(0, input.contextWindow - input.currentTokens);
44
81
  const sessionMemoryCount = Math.max(0, input.sessionMemoryCount ?? 0);
45
82
  const compactionCount = Math.max(0, input.session?.lineage?.filter((entry) => entry.branchReason === 'compaction').length ?? 0);
46
83
  const lastCompactedAt = input.session?.lastCompactedAt;
47
84
  const messageCount = Math.max(0, input.messageCount ?? 0);
48
- const staleByMessageGrowth = compactionCount > 0
85
+ const staleByMessageGrowth = (input.session?.compactionMessageCount ?? 0) > 0
49
86
  ? messageCount - (input.session?.compactionMessageCount ?? 0) >= 12
50
87
  : messageCount >= 24;
88
+
51
89
  if (input.contextWindow <= 0) {
52
90
  return {
53
91
  level: 'unknown',
@@ -66,32 +104,45 @@ export function evaluateSessionMaintenance(input: PanelSessionMaintenanceInput):
66
104
  };
67
105
  }
68
106
 
107
+ if (input.session?.compactionState === 'failed') {
108
+ return {
109
+ level: 'needs-repair',
110
+ summary: 'Compaction needs operator repair.',
111
+ reasons: ['Compaction failed and the session may need manual recovery.'],
112
+ nextSteps: ['/compact', '/health review'],
113
+ guidanceMode,
114
+ usagePct,
115
+ remainingTokens,
116
+ thresholdPct,
117
+ autoCompactEnabled,
118
+ sessionMemoryCount,
119
+ compactionCount,
120
+ lastCompactedAt,
121
+ compactRecommended: true,
122
+ };
123
+ }
124
+
69
125
  const reasons: string[] = [];
70
126
  const nextSteps: string[] = [];
71
127
  let summary = 'Session maintenance is stable.';
72
128
  let compactRecommended = false;
73
129
  let level: PanelSessionMaintenanceLevel = 'stable';
74
130
 
75
- if (usagePct >= 90) {
76
- level = 'needs-repair';
131
+ const atThreshold = autoCompactEnabled ? usagePct >= thresholdPct : usagePct >= 80;
132
+ if (atThreshold || remainingTokens <= 15_000) {
133
+ level = 'suggest-compact';
77
134
  summary = `Compact now to recover context headroom (${usagePct}% used).`;
78
135
  reasons.push(`Context pressure is high at ${usagePct}% usage.`);
79
136
  nextSteps.push('/compact', '/panel tokens');
80
137
  compactRecommended = true;
81
- } else if (usagePct >= thresholdPct || remainingTokens <= 15_000) {
82
- level = 'suggest-compact';
83
- summary = `Watch context growth (${usagePct}% used).`;
84
- reasons.push(`Context pressure is climbing at ${usagePct}% usage.`);
85
- nextSteps.push('/panel tokens');
86
- compactRecommended = true;
87
- } else if (usagePct >= 70 || staleByMessageGrowth) {
138
+ } else if (usagePct >= Math.max(70, autoCompactEnabled ? thresholdPct - 10 : 70) || staleByMessageGrowth) {
88
139
  level = 'watch';
89
140
  summary = staleByMessageGrowth
90
141
  ? `Conversation has grown ${messageCount.toLocaleString()} messages since the last maintenance checkpoint.`
91
142
  : `Watch context growth (${usagePct}% used, threshold ${thresholdPct}%).`;
92
143
  reasons.push(staleByMessageGrowth
93
144
  ? `Conversation has grown ${messageCount.toLocaleString()} messages since the last maintenance checkpoint.`
94
- : `Context usage is climbing toward the ${thresholdPct}% maintenance threshold.`);
145
+ : `Context usage is climbing toward the ${thresholdPct > 0 ? `${thresholdPct}% auto-compact threshold` : 'maintenance band'}.`);
95
146
  nextSteps.push('/panel tokens');
96
147
  } else {
97
148
  reasons.push('Context pressure is currently within the stable operating band.');
@@ -104,7 +155,7 @@ export function evaluateSessionMaintenance(input: PanelSessionMaintenanceInput):
104
155
  reasons.push(`Last compaction ran ${lastCompactedAt ? new Date(lastCompactedAt).toISOString() : 'recently'}.`);
105
156
  }
106
157
  if (!autoCompactEnabled) {
107
- reasons.push('Auto-compact is currently disabled; use /compact when you need to recover headroom.');
158
+ reasons.push('Auto-compaction is disabled; maintenance stays fully manual.');
108
159
  }
109
160
 
110
161
  return {