memorix 1.2.2 → 1.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/TEAM.md +86 -86
- package/dist/cli/index.js +5199 -4726
- package/dist/cli/index.js.map +1 -1
- package/dist/index.js +428 -49
- package/dist/index.js.map +1 -1
- package/dist/maintenance-runner.js +97 -18
- package/dist/maintenance-runner.js.map +1 -1
- package/dist/memcode-runtime/CHANGELOG.md +27 -0
- package/dist/sdk.js +428 -49
- package/dist/sdk.js.map +1 -1
- package/docs/1.2.4-PERSISTENT-MEMORY-DELIVERY.md +86 -0
- package/docs/AGENT_OPERATOR_PLAYBOOK.md +13 -1
- package/docs/API_REFERENCE.md +13 -3
- package/docs/DESIGN_DECISIONS.md +357 -357
- package/docs/dev-log/progress.txt +60 -9
- package/package.json +1 -1
- package/plugins/codex/memorix/.codex-plugin/plugin.json +1 -1
- package/src/audit/index.ts +156 -156
- package/src/cli/capability-map.ts +1 -1
- package/src/cli/command-guide.ts +4 -1
- package/src/cli/commands/agent-integrations.ts +5 -1
- package/src/cli/commands/audit-list.ts +89 -89
- package/src/cli/commands/background.ts +659 -659
- package/src/cli/commands/codegraph.ts +1 -1
- package/src/cli/commands/context.ts +9 -1
- package/src/cli/commands/formation.ts +48 -48
- package/src/cli/commands/git-hook-install.ts +111 -111
- package/src/cli/commands/handoff.ts +54 -54
- package/src/cli/commands/hooks-status.ts +63 -63
- package/src/cli/commands/ingest-commit.ts +153 -153
- package/src/cli/commands/ingest-image.ts +66 -66
- package/src/cli/commands/ingest-log.ts +180 -180
- package/src/cli/commands/ingest.ts +44 -44
- package/src/cli/commands/integrate-shared.ts +15 -15
- package/src/cli/commands/lock.ts +82 -82
- package/src/cli/commands/message.ts +104 -104
- package/src/cli/commands/poll.ts +58 -58
- package/src/cli/commands/purge-all-memory.ts +85 -85
- package/src/cli/commands/purge-project-memory.ts +83 -83
- package/src/cli/commands/reasoning.ts +118 -118
- package/src/cli/commands/resume.ts +31 -0
- package/src/cli/commands/serve-shared.ts +118 -118
- package/src/cli/commands/session.ts +15 -7
- package/src/cli/commands/skills.ts +114 -114
- package/src/cli/commands/task.ts +167 -167
- package/src/cli/commands/transfer.ts +47 -47
- package/src/cli/commands/uninstall-project-artifacts.ts +85 -85
- package/src/cli/index.ts +3 -1
- package/src/cli/tui/ChatView.tsx +234 -234
- package/src/cli/tui/CommandBar.tsx +312 -312
- package/src/cli/tui/ContextRail.tsx +118 -118
- package/src/cli/tui/HeaderBar.tsx +72 -72
- package/src/cli/tui/LogoBanner.tsx +51 -51
- package/src/cli/tui/Sidebar.tsx +179 -179
- package/src/cli/tui/index.ts +41 -41
- package/src/cli/tui/markdown-render.tsx +371 -371
- package/src/cli/tui/session-service.ts +3 -2
- package/src/cli/tui/use-mouse.ts +157 -157
- package/src/cli/tui/useNavigation.ts +56 -56
- package/src/cli/update-checker.ts +211 -211
- package/src/cli/version.ts +7 -7
- package/src/cli/workbench.ts +1 -1
- package/src/codegraph/auto-context.ts +54 -1
- package/src/codegraph/task-lens.ts +29 -0
- package/src/compact/token-budget.ts +89 -74
- package/src/config/toml-loader.ts +9 -5
- package/src/dashboard/project-classification.ts +64 -64
- package/src/embedding/fastembed-provider.ts +142 -142
- package/src/embedding/transformers-provider.ts +111 -111
- package/src/git/extractor.ts +209 -209
- package/src/git/hooks-path.ts +85 -85
- package/src/hooks/handler.ts +127 -66
- package/src/hooks/installers/index.ts +5 -4
- package/src/hooks/official-skills.ts +6 -4
- package/src/hooks/pattern-detector.ts +173 -173
- package/src/hooks/rules/memorix-agent-rules.md +9 -7
- package/src/hooks/significance-filter.ts +250 -250
- package/src/knowledge/context-assembly.ts +4 -1
- package/src/knowledge/workset.ts +89 -1
- package/src/llm/memory-manager.ts +328 -328
- package/src/llm/provider.ts +885 -885
- package/src/llm/quality.ts +248 -248
- package/src/memory/attribution-guard.ts +249 -249
- package/src/memory/disclosure-policy.ts +135 -135
- package/src/memory/entity-extractor.ts +197 -197
- package/src/memory/formation/evaluate.ts +217 -217
- package/src/memory/formation/extract.ts +361 -361
- package/src/memory/formation/index.ts +417 -417
- package/src/memory/formation/resolve.ts +344 -344
- package/src/memory/formation/types.ts +315 -315
- package/src/memory/freshness.ts +122 -122
- package/src/memory/graph.ts +197 -197
- package/src/memory/refs.ts +94 -94
- package/src/memory/secret-filter.ts +79 -79
- package/src/memory/session.ts +158 -9
- package/src/multimodal/image-loader.ts +143 -143
- package/src/orchestrate/adapters/claude-stream.ts +192 -192
- package/src/orchestrate/adapters/claude.ts +111 -111
- package/src/orchestrate/adapters/codex-stream.ts +134 -134
- package/src/orchestrate/adapters/codex.ts +41 -41
- package/src/orchestrate/adapters/gemini-stream.ts +166 -166
- package/src/orchestrate/adapters/gemini.ts +42 -42
- package/src/orchestrate/adapters/index.ts +73 -73
- package/src/orchestrate/adapters/opencode-stream.ts +143 -143
- package/src/orchestrate/adapters/opencode.ts +47 -47
- package/src/orchestrate/adapters/spawn-helper.ts +286 -286
- package/src/orchestrate/adapters/types.ts +77 -77
- package/src/orchestrate/capability-router.ts +284 -284
- package/src/orchestrate/context-compact.ts +188 -188
- package/src/orchestrate/cost-tracker.ts +219 -219
- package/src/orchestrate/error-recovery.ts +191 -191
- package/src/orchestrate/evidence.ts +140 -140
- package/src/orchestrate/ledger.ts +110 -110
- package/src/orchestrate/memorix-bridge.ts +343 -343
- package/src/orchestrate/output-budget.ts +80 -80
- package/src/orchestrate/permission.ts +152 -152
- package/src/orchestrate/pipeline-trace.ts +131 -131
- package/src/orchestrate/prompt-builder.ts +155 -155
- package/src/orchestrate/ring-buffer.ts +37 -37
- package/src/orchestrate/task-graph.ts +389 -389
- package/src/orchestrate/worktree.ts +232 -232
- package/src/project/aliases.ts +374 -374
- package/src/project/detector.ts +268 -268
- package/src/rules/adapters/claude-code.ts +99 -99
- package/src/rules/adapters/codex.ts +97 -97
- package/src/rules/adapters/copilot.ts +124 -124
- package/src/rules/adapters/cursor.ts +114 -114
- package/src/rules/adapters/kiro.ts +126 -126
- package/src/rules/adapters/trae.ts +56 -56
- package/src/rules/adapters/windsurf.ts +83 -83
- package/src/rules/syncer.ts +235 -235
- package/src/sdk.ts +299 -299
- package/src/search/intent-detector.ts +289 -289
- package/src/search/query-expansion.ts +52 -52
- package/src/server/formation-timeout.ts +27 -27
- package/src/server.ts +144 -10
- package/src/skills/mini-skills.ts +386 -386
- package/src/store/bun-sqlite-compat.ts +118 -15
- package/src/store/chat-store.ts +119 -119
- package/src/store/graph-store.ts +249 -249
- package/src/store/mini-skill-store.ts +349 -349
- package/src/store/persistence-json.ts +212 -212
- package/src/store/persistence.ts +291 -291
- package/src/store/project-affinity.ts +195 -195
- package/src/store/sqlite-db.ts +3 -3
- package/src/team/event-bus.ts +76 -76
- package/src/team/file-locks.ts +173 -173
- package/src/team/handoff.ts +161 -161
- package/src/team/messages.ts +203 -203
- package/src/team/poll.ts +132 -132
- package/src/team/tasks.ts +211 -211
- package/src/workspace/mcp-adapters/codex.ts +191 -191
- package/src/workspace/mcp-adapters/copilot.ts +105 -105
- package/src/workspace/mcp-adapters/cursor.ts +53 -53
- package/src/workspace/mcp-adapters/kiro.ts +64 -64
- package/src/workspace/mcp-adapters/opencode.ts +123 -123
- package/src/workspace/mcp-adapters/trae.ts +134 -134
- package/src/workspace/mcp-adapters/windsurf.ts +91 -91
- package/src/workspace/sanitizer.ts +60 -60
- package/src/workspace/workflow-sync.ts +131 -131
package/src/cli/index.ts
CHANGED
|
@@ -1045,6 +1045,7 @@ const main = defineCommand({
|
|
|
1045
1045
|
integrate: () => import('./commands/integrate.js').then(m => m.default),
|
|
1046
1046
|
memory: () => import('./commands/memory.js').then(m => m.default),
|
|
1047
1047
|
context: () => import('./commands/context.js').then(m => m.default),
|
|
1048
|
+
resume: () => import('./commands/resume.js').then(m => m.default),
|
|
1048
1049
|
explain: () => import('./commands/explain.js').then(m => m.default),
|
|
1049
1050
|
codegraph: () => import('./commands/codegraph.js').then(m => m.default),
|
|
1050
1051
|
knowledge: () => import('./commands/knowledge.js').then(m => m.default),
|
|
@@ -1115,7 +1116,7 @@ const main = defineCommand({
|
|
|
1115
1116
|
// Detect by checking if the first CLI arg matches a registered subcommand name.
|
|
1116
1117
|
const firstArg = process.argv[2];
|
|
1117
1118
|
const knownSubs = ['ask', 'search', 'remember', 'recent', 'help', 'workbench', 'memcode', 'config',
|
|
1118
|
-
'init', 'setup', 'integrate', 'memory', 'context', 'explain', 'codegraph', 'knowledge', 'reasoning', 'retention', 'formation', 'audit', 'transfer', 'skills', 'identity',
|
|
1119
|
+
'init', 'setup', 'integrate', 'memory', 'context', 'resume', 'explain', 'codegraph', 'knowledge', 'reasoning', 'retention', 'formation', 'audit', 'transfer', 'skills', 'identity',
|
|
1119
1120
|
'session', 'team', 'task', 'message', 'lock', 'handoff', 'poll',
|
|
1120
1121
|
'receipt',
|
|
1121
1122
|
'serve', 'serve-http', 'status', 'sync',
|
|
@@ -1155,6 +1156,7 @@ const main = defineCommand({
|
|
|
1155
1156
|
console.error(' session Start/end/context for coding sessions');
|
|
1156
1157
|
console.error(' memory Search/store/detail/timeline/resolve observations');
|
|
1157
1158
|
console.error(' context Show the Memory Autopilot brief for this project');
|
|
1159
|
+
console.error(' resume Resume prior work with one bounded project brief');
|
|
1158
1160
|
console.error(' explain Explain where Memorix project context comes from');
|
|
1159
1161
|
console.error(' codegraph Refresh/status/context-pack for CodeGraph Memory');
|
|
1160
1162
|
console.error(' knowledge Review source-backed knowledge pages and project workflows');
|
package/src/cli/tui/ChatView.tsx
CHANGED
|
@@ -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
|
+
});
|