memorix 1.6.0 → 1.6.1

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 (119) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/TEAM.md +86 -86
  3. package/dist/cli/index.js +317 -62
  4. package/dist/cli/memcode.js +12 -1
  5. package/dist/index.js +56 -9
  6. package/dist/maintenance-runner.js +54 -7
  7. package/dist/media-audio-runner.js +54 -7
  8. package/dist/media-video-runner.js +54 -7
  9. package/dist/memcode-runtime/CHANGELOG.md +24 -0
  10. package/dist/sdk.js +55 -8
  11. package/dist/vector-backfill-runner.js +54 -7
  12. package/docs/AGENT_OPERATOR_PLAYBOOK.md +11 -3
  13. package/docs/API_REFERENCE.md +1 -1
  14. package/docs/DESIGN_DECISIONS.md +357 -357
  15. package/docs/DOCKER.md +5 -1
  16. package/docs/PERFORMANCE.md +2 -2
  17. package/docs/README.md +3 -1
  18. package/docs/SETUP.md +8 -2
  19. package/package.json +1 -1
  20. package/plugins/claude/memorix/.claude-plugin/plugin.json +1 -1
  21. package/plugins/codebuddy/memorix/.codebuddy-plugin/plugin.json +1 -1
  22. package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
  23. package/plugins/copilot/memorix/plugin.json +1 -1
  24. package/plugins/gemini/memorix/gemini-extension.json +1 -1
  25. package/plugins/omp/memorix/package.json +1 -1
  26. package/plugins/openclaw/memorix/.codex-plugin/plugin.json +1 -1
  27. package/plugins/pi/memorix/package.json +1 -1
  28. package/server.json +2 -2
  29. package/src/audit/index.ts +156 -156
  30. package/src/cli/commands/agent-integrations.ts +9 -3
  31. package/src/cli/commands/doctor.ts +46 -15
  32. package/src/cli/commands/formation.ts +48 -48
  33. package/src/cli/commands/git-hook-install.ts +111 -111
  34. package/src/cli/commands/handoff.ts +54 -54
  35. package/src/cli/commands/hook.ts +13 -1
  36. package/src/cli/commands/hooks-status.ts +63 -63
  37. package/src/cli/commands/ingest.ts +44 -44
  38. package/src/cli/commands/integrate-shared.ts +15 -15
  39. package/src/cli/commands/lock.ts +82 -82
  40. package/src/cli/commands/message.ts +104 -104
  41. package/src/cli/commands/poll.ts +58 -58
  42. package/src/cli/commands/reasoning.ts +118 -118
  43. package/src/cli/commands/serve-http.ts +68 -7
  44. package/src/cli/commands/skills.ts +114 -114
  45. package/src/cli/commands/task.ts +167 -167
  46. package/src/cli/commands/transfer.ts +47 -47
  47. package/src/cli/heap.ts +76 -0
  48. package/src/cli/index.ts +406 -406
  49. package/src/cli/invocation.ts +7 -4
  50. package/src/cli/tui/ChatView.tsx +234 -234
  51. package/src/cli/tui/CommandBar.tsx +312 -312
  52. package/src/cli/tui/ContextRail.tsx +118 -118
  53. package/src/cli/tui/HeaderBar.tsx +72 -72
  54. package/src/cli/tui/LogoBanner.tsx +51 -51
  55. package/src/cli/tui/index.ts +41 -41
  56. package/src/cli/version.ts +7 -7
  57. package/src/cli/workbench.ts +1 -1
  58. package/src/dashboard/project-classification.ts +64 -64
  59. package/src/embedding/provider.ts +76 -6
  60. package/src/git/hooks-path.ts +85 -85
  61. package/src/hooks/handler.ts +9 -18
  62. package/src/hooks/lifecycle.ts +173 -0
  63. package/src/hooks/pattern-detector.ts +173 -173
  64. package/src/hooks/significance-filter.ts +250 -250
  65. package/src/llm/memory-manager.ts +328 -328
  66. package/src/llm/quality.ts +248 -248
  67. package/src/memory/attribution-guard.ts +249 -249
  68. package/src/memory/disclosure-policy.ts +135 -135
  69. package/src/memory/entity-extractor.ts +197 -197
  70. package/src/memory/formation/evaluate.ts +217 -217
  71. package/src/memory/freshness.ts +122 -122
  72. package/src/memory/graph.ts +197 -197
  73. package/src/memory/refs.ts +94 -94
  74. package/src/memory/secret-filter.ts +79 -79
  75. package/src/orchestrate/adapters/claude-stream.ts +192 -192
  76. package/src/orchestrate/adapters/claude.ts +111 -111
  77. package/src/orchestrate/adapters/codex-stream.ts +134 -134
  78. package/src/orchestrate/adapters/codex.ts +41 -41
  79. package/src/orchestrate/adapters/gemini-stream.ts +166 -166
  80. package/src/orchestrate/adapters/gemini.ts +42 -42
  81. package/src/orchestrate/adapters/index.ts +73 -73
  82. package/src/orchestrate/adapters/opencode-stream.ts +143 -143
  83. package/src/orchestrate/adapters/opencode.ts +47 -47
  84. package/src/orchestrate/adapters/types.ts +77 -77
  85. package/src/orchestrate/capability-router.ts +284 -284
  86. package/src/orchestrate/cost-tracker.ts +219 -219
  87. package/src/orchestrate/error-recovery.ts +191 -191
  88. package/src/orchestrate/evidence.ts +140 -140
  89. package/src/orchestrate/ledger.ts +110 -110
  90. package/src/orchestrate/memorix-bridge.ts +343 -343
  91. package/src/orchestrate/output-budget.ts +80 -80
  92. package/src/orchestrate/permission.ts +152 -152
  93. package/src/orchestrate/pipeline-trace.ts +131 -131
  94. package/src/orchestrate/prompt-builder.ts +155 -155
  95. package/src/orchestrate/ring-buffer.ts +37 -37
  96. package/src/orchestrate/task-graph.ts +389 -389
  97. package/src/project/aliases.ts +374 -374
  98. package/src/rules/adapters/trae.ts +56 -56
  99. package/src/rules/syncer.ts +235 -235
  100. package/src/search/query-expansion.ts +52 -52
  101. package/src/server/formation-timeout.ts +27 -27
  102. package/src/skills/mini-skills.ts +386 -386
  103. package/src/store/chat-store.ts +119 -119
  104. package/src/store/graph-store.ts +249 -249
  105. package/src/store/mini-skill-store.ts +349 -349
  106. package/src/store/persistence-json.ts +212 -212
  107. package/src/store/persistence.ts +291 -291
  108. package/src/store/project-affinity.ts +195 -195
  109. package/src/team/event-bus.ts +76 -76
  110. package/src/team/handoff.ts +161 -161
  111. package/src/team/poll.ts +132 -132
  112. package/src/workspace/mcp-adapters/codex.ts +191 -191
  113. package/src/workspace/mcp-adapters/copilot.ts +105 -105
  114. package/src/workspace/mcp-adapters/cursor.ts +53 -53
  115. package/src/workspace/mcp-adapters/kiro.ts +64 -64
  116. package/src/workspace/mcp-adapters/opencode.ts +123 -123
  117. package/src/workspace/mcp-adapters/trae.ts +134 -134
  118. package/src/workspace/mcp-adapters/windsurf.ts +91 -91
  119. package/src/workspace/sanitizer.ts +60 -60
@@ -1,4 +1,4 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { existsSync, realpathSync, statSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
 
4
4
  export interface CliInvocation {
@@ -88,10 +88,13 @@ export function normalizeCliInvocation(argv: string[] = process.argv): CliInvoca
88
88
  }
89
89
 
90
90
  if (invocation.projectRoot) {
91
- const projectRoot = resolve(invocation.projectRoot);
92
- if (!existsSync(projectRoot) || !statSync(projectRoot).isDirectory()) {
93
- throw new Error(`CLI project directory does not exist: ${projectRoot}`);
91
+ const resolvedProjectRoot = resolve(invocation.projectRoot);
92
+ if (!existsSync(resolvedProjectRoot) || !statSync(resolvedProjectRoot).isDirectory()) {
93
+ throw new Error(`CLI project directory does not exist: ${resolvedProjectRoot}`);
94
94
  }
95
+ // Keep one project identity when an OS exposes the same directory through
96
+ // two paths (for example /var and /private/var on macOS).
97
+ const projectRoot = realpathSync(resolvedProjectRoot);
95
98
  process.chdir(projectRoot);
96
99
  process.env.MEMORIX_CLI_PROJECT_ROOT = projectRoot;
97
100
  invocation.projectRoot = projectRoot;
@@ -1,234 +1,234 @@
1
- import React, { useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react';
2
- import { Box, Text, useInput, useStdout } from 'ink';
3
- import { ScrollView, type ScrollViewRef } from 'ink-scroll-view';
4
- import Spinner from 'ink-spinner';
5
- import { COLORS, SEP, SYMBOLS, computeLayoutWidths } from './theme.js';
6
- import type { ProjectInfo } from './data.js';
7
- import type { ChatSource } from './chat-service.js';
8
-
9
- export interface ChatTranscriptMessage {
10
- role: 'user' | 'assistant';
11
- content: string;
12
- sources?: ChatSource[];
13
- error?: boolean;
14
- timestamp?: string;
15
- meta?: {
16
- usedLLM?: boolean;
17
- searchMode?: string;
18
- llmModel?: string;
19
- warning?: string;
20
- };
21
- }
22
-
23
- interface ChatViewProps {
24
- project: ProjectInfo | null;
25
- messages: ChatTranscriptMessage[];
26
- loading: boolean;
27
- contentWidth?: number;
28
- viewportHeight?: number;
29
- threadId?: string;
30
- keyboardScrollEnabled?: boolean;
31
- }
32
-
33
- function separator(width: number): string {
34
- return SEP.thin.repeat(Math.max(12, Math.min(width, 72)));
35
- }
36
-
37
- function truncate(text: string, max: number): string {
38
- if (text.length <= max) return text;
39
- return `${text.slice(0, max - 1)}…`;
40
- }
41
-
42
- /** Measure the display width of a string, counting CJK characters as 2 columns. */
43
- function displayWidth(str: string): number {
44
- let w = 0;
45
- for (const ch of str) {
46
- const cp = ch.codePointAt(0)!;
47
- // CJK Unified Ideographs + CJK Extension + Katakana + Hangul + fullwidth forms
48
- if ((cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3040 && cp <= 0x30FF) ||
49
- (cp >= 0xAC00 && cp <= 0xD7AF) || (cp >= 0xFF01 && cp <= 0xFF60) ||
50
- (cp >= 0x3400 && cp <= 0x4DBF)) {
51
- w += 2;
52
- } else {
53
- w += 1;
54
- }
55
- }
56
- return w;
57
- }
58
-
59
- /** Split text into lines respecting CJK double-width characters.
60
- * Ink's wrap="wrap" doesn't understand CJK width, causing garbled text. */
61
- function cjkAwareLines(text: string, maxCols: number): string[] {
62
- if (maxCols < 4) return [text];
63
- const lines: string[] = [];
64
- let line = '';
65
- let lineW = 0;
66
- for (const ch of text) {
67
- if (ch === '\n') {
68
- lines.push(line);
69
- line = '';
70
- lineW = 0;
71
- continue;
72
- }
73
- const cw = displayWidth(ch);
74
- if (lineW + cw > maxCols && line.length > 0) {
75
- lines.push(line);
76
- line = ch;
77
- lineW = cw;
78
- } else {
79
- line += ch;
80
- lineW += cw;
81
- }
82
- }
83
- if (line) lines.push(line);
84
- return lines.length > 0 ? lines : [''];
85
- }
86
-
87
- function MessageBlock({ message, width }: { message: ChatTranscriptMessage; width: number }): React.ReactElement {
88
- const heading = message.role === 'user' ? 'You' : 'Memorix';
89
- const headingColor = message.role === 'user'
90
- ? COLORS.brand
91
- : message.error
92
- ? COLORS.error
93
- : COLORS.assistant;
94
-
95
- return (
96
- <Box flexDirection="column" marginBottom={0}>
97
- <Box>
98
- <Text color={headingColor} bold wrap="truncate-end">{heading}</Text>
99
- {message.meta?.usedLLM && message.role === 'assistant' && (
100
- <Text color={COLORS.muted} wrap="truncate-end"> {SYMBOLS.pill(message.meta.llmModel || 'LLM')}</Text>
101
- )}
102
- {message.meta?.searchMode && message.role === 'assistant' && (
103
- <Text color={COLORS.textDim} wrap="truncate-end">{` ${SYMBOLS.pill(message.meta.searchMode)}`}</Text>
104
- )}
105
- </Box>
106
- <Box flexDirection="column">
107
- {cjkAwareLines(message.content, Math.max(20, width - 4)).map((ln, i) => (
108
- <Text key={i} color={message.error ? COLORS.error : COLORS.text}>{ln}</Text>
109
- ))}
110
- </Box>
111
- {message.meta?.warning && !message.error && (
112
- <Text color={COLORS.warning} wrap="truncate-end">{message.meta.warning}</Text>
113
- )}
114
- {message.sources && message.sources.length > 0 && (
115
- <Box flexDirection="column">
116
- <Text color={COLORS.muted} wrap="truncate-end">Sources</Text>
117
- {message.sources.slice(0, 3).map((source) => (
118
- <Text key={source.id} color={COLORS.textDim} wrap="truncate-end">
119
- {` [obs:${source.id}] ${truncate(source.title, Math.max(20, width - 16))}`}
120
- </Text>
121
- ))}
122
- </Box>
123
- )}
124
- </Box>
125
- );
126
- }
127
-
128
- export interface ChatViewRef {
129
- scrollBy: (offset: number) => void;
130
- }
131
-
132
- export const ChatView = forwardRef<ChatViewRef, ChatViewProps>(function ChatView(
133
- { project, messages, loading, contentWidth: _contentWidth, viewportHeight, threadId, keyboardScrollEnabled = true },
134
- ref,
135
- ): React.ReactElement {
136
- const scrollRef = useRef<ScrollViewRef>(null);
137
- const { stdout } = useStdout();
138
-
139
- // Compute responsive content width from terminal size (shared with App.tsx)
140
- const termWidth = stdout?.columns || 80;
141
- const { contentWidth } = computeLayoutWidths(termWidth);
142
-
143
- // Expose scrollBy to parent for centralized mouse handling
144
- useImperativeHandle(ref, () => ({
145
- scrollBy: (offset: number) => scrollRef.current?.scrollBy(offset),
146
- }), []);
147
-
148
- // Auto-scroll to bottom when new messages arrive
149
- useEffect(() => {
150
- if (scrollRef.current && messages.length > 0) {
151
- // Small delay to let ink-scroll-view measure new content
152
- const timer = setTimeout(() => {
153
- scrollRef.current?.remeasure();
154
- // Scroll to bottom: use getBottomOffset
155
- const bottom = scrollRef.current?.getBottomOffset?.() ?? 0;
156
- if (bottom > 0) {
157
- scrollRef.current?.scrollTo?.(bottom);
158
- }
159
- }, 50);
160
- return () => clearTimeout(timer);
161
- }
162
- }, [messages.length, loading]);
163
-
164
- // Handle terminal resize
165
- useEffect(() => {
166
- const handleResize = () => scrollRef.current?.remeasure();
167
- stdout?.on('resize', handleResize);
168
- return () => { stdout?.off('resize', handleResize); };
169
- }, [stdout]);
170
-
171
- // Capture up/down keys for scrolling when in chat view
172
- useInput(useCallback((_ch: string, key: any) => {
173
- if (!scrollRef.current) return;
174
- if (key.upArrow) {
175
- scrollRef.current.scrollBy(-3);
176
- } else if (key.downArrow) {
177
- scrollRef.current.scrollBy(3);
178
- } else if (key.pageUp) {
179
- const vh = scrollRef.current.getViewportHeight?.() ?? 10;
180
- scrollRef.current.scrollBy(-(vh - 2));
181
- } else if (key.pageDown) {
182
- const vh = scrollRef.current.getViewportHeight?.() ?? 10;
183
- scrollRef.current.scrollBy(vh - 2);
184
- }
185
- }, []), { isActive: keyboardScrollEnabled });
186
-
187
- // Reserve 2 rows for header ("Conversation" + separator), 1 for loading
188
- const headerRows = 2;
189
- const loadingRows = loading ? 1 : 0;
190
- const scrollHeight = viewportHeight ? Math.max(4, viewportHeight - headerRows - loadingRows) : 20;
191
-
192
- const threadLabel = threadId && threadId !== 'default' ? ` (${threadId})` : '';
193
-
194
- if (messages.length === 0) {
195
- return (
196
- <Box flexDirection="column" paddingX={1}>
197
- <Text color={COLORS.brand} bold>Memory Chat{threadLabel}</Text>
198
- <Text color={COLORS.border}>{separator(contentWidth - 4)}</Text>
199
- <Text color={COLORS.text}>Ask Memorix about project decisions, bugs, rationale, or recent changes.</Text>
200
- <Text color={COLORS.textDim}>{project ? `Project: ${project.name}` : 'Open inside a git project to query its memory graph.'}</Text>
201
- <Box marginTop={1} flexDirection="column">
202
- <Text color={COLORS.muted}>Try asking</Text>
203
- <Text color={COLORS.textDim}> {SYMBOLS.arrow} Why did we choose this architecture?</Text>
204
- <Text color={COLORS.textDim}> {SYMBOLS.arrow} What changed in auth recently?</Text>
205
- <Text color={COLORS.textDim}> {SYMBOLS.arrow} Summarize known issues around embeddings</Text>
206
- <Text color={COLORS.textDim}> {SYMBOLS.arrow} /search timeout bug</Text>
207
- </Box>
208
- </Box>
209
- );
210
- }
211
-
212
- return (
213
- <Box flexDirection="column" paddingX={1}>
214
- <Text color={COLORS.brand} bold>Conversation{threadLabel}</Text>
215
- <Text color={COLORS.border}>{separator(contentWidth - 4)}</Text>
216
- <Box height={scrollHeight} flexDirection="column">
217
- <ScrollView ref={scrollRef}>
218
- {messages.map((message, index) => (
219
- <MessageBlock
220
- key={`${message.role}-${index}-${message.timestamp || index}`}
221
- message={message}
222
- width={contentWidth}
223
- />
224
- ))}
225
- {loading && (
226
- <Box>
227
- <Text color={COLORS.brand}><Spinner type="dots" />{' '}Thinking…</Text>
228
- </Box>
229
- )}
230
- </ScrollView>
231
- </Box>
232
- </Box>
233
- );
234
- });
1
+ import React, { useRef, useEffect, useCallback, useImperativeHandle, forwardRef } from 'react';
2
+ import { Box, Text, useInput, useStdout } from 'ink';
3
+ import { ScrollView, type ScrollViewRef } from 'ink-scroll-view';
4
+ import Spinner from 'ink-spinner';
5
+ import { COLORS, SEP, SYMBOLS, computeLayoutWidths } from './theme.js';
6
+ import type { ProjectInfo } from './data.js';
7
+ import type { ChatSource } from './chat-service.js';
8
+
9
+ export interface ChatTranscriptMessage {
10
+ role: 'user' | 'assistant';
11
+ content: string;
12
+ sources?: ChatSource[];
13
+ error?: boolean;
14
+ timestamp?: string;
15
+ meta?: {
16
+ usedLLM?: boolean;
17
+ searchMode?: string;
18
+ llmModel?: string;
19
+ warning?: string;
20
+ };
21
+ }
22
+
23
+ interface ChatViewProps {
24
+ project: ProjectInfo | null;
25
+ messages: ChatTranscriptMessage[];
26
+ loading: boolean;
27
+ contentWidth?: number;
28
+ viewportHeight?: number;
29
+ threadId?: string;
30
+ keyboardScrollEnabled?: boolean;
31
+ }
32
+
33
+ function separator(width: number): string {
34
+ return SEP.thin.repeat(Math.max(12, Math.min(width, 72)));
35
+ }
36
+
37
+ function truncate(text: string, max: number): string {
38
+ if (text.length <= max) return text;
39
+ return `${text.slice(0, max - 1)}…`;
40
+ }
41
+
42
+ /** Measure the display width of a string, counting CJK characters as 2 columns. */
43
+ function displayWidth(str: string): number {
44
+ let w = 0;
45
+ for (const ch of str) {
46
+ const cp = ch.codePointAt(0)!;
47
+ // CJK Unified Ideographs + CJK Extension + Katakana + Hangul + fullwidth forms
48
+ if ((cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3040 && cp <= 0x30FF) ||
49
+ (cp >= 0xAC00 && cp <= 0xD7AF) || (cp >= 0xFF01 && cp <= 0xFF60) ||
50
+ (cp >= 0x3400 && cp <= 0x4DBF)) {
51
+ w += 2;
52
+ } else {
53
+ w += 1;
54
+ }
55
+ }
56
+ return w;
57
+ }
58
+
59
+ /** Split text into lines respecting CJK double-width characters.
60
+ * Ink's wrap="wrap" doesn't understand CJK width, causing garbled text. */
61
+ function cjkAwareLines(text: string, maxCols: number): string[] {
62
+ if (maxCols < 4) return [text];
63
+ const lines: string[] = [];
64
+ let line = '';
65
+ let lineW = 0;
66
+ for (const ch of text) {
67
+ if (ch === '\n') {
68
+ lines.push(line);
69
+ line = '';
70
+ lineW = 0;
71
+ continue;
72
+ }
73
+ const cw = displayWidth(ch);
74
+ if (lineW + cw > maxCols && line.length > 0) {
75
+ lines.push(line);
76
+ line = ch;
77
+ lineW = cw;
78
+ } else {
79
+ line += ch;
80
+ lineW += cw;
81
+ }
82
+ }
83
+ if (line) lines.push(line);
84
+ return lines.length > 0 ? lines : [''];
85
+ }
86
+
87
+ function MessageBlock({ message, width }: { message: ChatTranscriptMessage; width: number }): React.ReactElement {
88
+ const heading = message.role === 'user' ? 'You' : 'Memorix';
89
+ const headingColor = message.role === 'user'
90
+ ? COLORS.brand
91
+ : message.error
92
+ ? COLORS.error
93
+ : COLORS.assistant;
94
+
95
+ return (
96
+ <Box flexDirection="column" marginBottom={0}>
97
+ <Box>
98
+ <Text color={headingColor} bold wrap="truncate-end">{heading}</Text>
99
+ {message.meta?.usedLLM && message.role === 'assistant' && (
100
+ <Text color={COLORS.muted} wrap="truncate-end"> {SYMBOLS.pill(message.meta.llmModel || 'LLM')}</Text>
101
+ )}
102
+ {message.meta?.searchMode && message.role === 'assistant' && (
103
+ <Text color={COLORS.textDim} wrap="truncate-end">{` ${SYMBOLS.pill(message.meta.searchMode)}`}</Text>
104
+ )}
105
+ </Box>
106
+ <Box flexDirection="column">
107
+ {cjkAwareLines(message.content, Math.max(20, width - 4)).map((ln, i) => (
108
+ <Text key={i} color={message.error ? COLORS.error : COLORS.text}>{ln}</Text>
109
+ ))}
110
+ </Box>
111
+ {message.meta?.warning && !message.error && (
112
+ <Text color={COLORS.warning} wrap="truncate-end">{message.meta.warning}</Text>
113
+ )}
114
+ {message.sources && message.sources.length > 0 && (
115
+ <Box flexDirection="column">
116
+ <Text color={COLORS.muted} wrap="truncate-end">Sources</Text>
117
+ {message.sources.slice(0, 3).map((source) => (
118
+ <Text key={source.id} color={COLORS.textDim} wrap="truncate-end">
119
+ {` [obs:${source.id}] ${truncate(source.title, Math.max(20, width - 16))}`}
120
+ </Text>
121
+ ))}
122
+ </Box>
123
+ )}
124
+ </Box>
125
+ );
126
+ }
127
+
128
+ export interface ChatViewRef {
129
+ scrollBy: (offset: number) => void;
130
+ }
131
+
132
+ export const ChatView = forwardRef<ChatViewRef, ChatViewProps>(function ChatView(
133
+ { project, messages, loading, contentWidth: _contentWidth, viewportHeight, threadId, keyboardScrollEnabled = true },
134
+ ref,
135
+ ): React.ReactElement {
136
+ const scrollRef = useRef<ScrollViewRef>(null);
137
+ const { stdout } = useStdout();
138
+
139
+ // Compute responsive content width from terminal size (shared with App.tsx)
140
+ const termWidth = stdout?.columns || 80;
141
+ const { contentWidth } = computeLayoutWidths(termWidth);
142
+
143
+ // Expose scrollBy to parent for centralized mouse handling
144
+ useImperativeHandle(ref, () => ({
145
+ scrollBy: (offset: number) => scrollRef.current?.scrollBy(offset),
146
+ }), []);
147
+
148
+ // Auto-scroll to bottom when new messages arrive
149
+ useEffect(() => {
150
+ if (scrollRef.current && messages.length > 0) {
151
+ // Small delay to let ink-scroll-view measure new content
152
+ const timer = setTimeout(() => {
153
+ scrollRef.current?.remeasure();
154
+ // Scroll to bottom: use getBottomOffset
155
+ const bottom = scrollRef.current?.getBottomOffset?.() ?? 0;
156
+ if (bottom > 0) {
157
+ scrollRef.current?.scrollTo?.(bottom);
158
+ }
159
+ }, 50);
160
+ return () => clearTimeout(timer);
161
+ }
162
+ }, [messages.length, loading]);
163
+
164
+ // Handle terminal resize
165
+ useEffect(() => {
166
+ const handleResize = () => scrollRef.current?.remeasure();
167
+ stdout?.on('resize', handleResize);
168
+ return () => { stdout?.off('resize', handleResize); };
169
+ }, [stdout]);
170
+
171
+ // Capture up/down keys for scrolling when in chat view
172
+ useInput(useCallback((_ch: string, key: any) => {
173
+ if (!scrollRef.current) return;
174
+ if (key.upArrow) {
175
+ scrollRef.current.scrollBy(-3);
176
+ } else if (key.downArrow) {
177
+ scrollRef.current.scrollBy(3);
178
+ } else if (key.pageUp) {
179
+ const vh = scrollRef.current.getViewportHeight?.() ?? 10;
180
+ scrollRef.current.scrollBy(-(vh - 2));
181
+ } else if (key.pageDown) {
182
+ const vh = scrollRef.current.getViewportHeight?.() ?? 10;
183
+ scrollRef.current.scrollBy(vh - 2);
184
+ }
185
+ }, []), { isActive: keyboardScrollEnabled });
186
+
187
+ // Reserve 2 rows for header ("Conversation" + separator), 1 for loading
188
+ const headerRows = 2;
189
+ const loadingRows = loading ? 1 : 0;
190
+ const scrollHeight = viewportHeight ? Math.max(4, viewportHeight - headerRows - loadingRows) : 20;
191
+
192
+ const threadLabel = threadId && threadId !== 'default' ? ` (${threadId})` : '';
193
+
194
+ if (messages.length === 0) {
195
+ return (
196
+ <Box flexDirection="column" paddingX={1}>
197
+ <Text color={COLORS.brand} bold>Memory Chat{threadLabel}</Text>
198
+ <Text color={COLORS.border}>{separator(contentWidth - 4)}</Text>
199
+ <Text color={COLORS.text}>Ask Memorix about project decisions, bugs, rationale, or recent changes.</Text>
200
+ <Text color={COLORS.textDim}>{project ? `Project: ${project.name}` : 'Open inside a git project to query its memory graph.'}</Text>
201
+ <Box marginTop={1} flexDirection="column">
202
+ <Text color={COLORS.muted}>Try asking</Text>
203
+ <Text color={COLORS.textDim}> {SYMBOLS.arrow} Why did we choose this architecture?</Text>
204
+ <Text color={COLORS.textDim}> {SYMBOLS.arrow} What changed in auth recently?</Text>
205
+ <Text color={COLORS.textDim}> {SYMBOLS.arrow} Summarize known issues around embeddings</Text>
206
+ <Text color={COLORS.textDim}> {SYMBOLS.arrow} /search timeout bug</Text>
207
+ </Box>
208
+ </Box>
209
+ );
210
+ }
211
+
212
+ return (
213
+ <Box flexDirection="column" paddingX={1}>
214
+ <Text color={COLORS.brand} bold>Conversation{threadLabel}</Text>
215
+ <Text color={COLORS.border}>{separator(contentWidth - 4)}</Text>
216
+ <Box height={scrollHeight} flexDirection="column">
217
+ <ScrollView ref={scrollRef}>
218
+ {messages.map((message, index) => (
219
+ <MessageBlock
220
+ key={`${message.role}-${index}-${message.timestamp || index}`}
221
+ message={message}
222
+ width={contentWidth}
223
+ />
224
+ ))}
225
+ {loading && (
226
+ <Box>
227
+ <Text color={COLORS.brand}><Spinner type="dots" />{' '}Thinking…</Text>
228
+ </Box>
229
+ )}
230
+ </ScrollView>
231
+ </Box>
232
+ </Box>
233
+ );
234
+ });