mu-coding 0.1.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 (40) hide show
  1. package/README.md +81 -0
  2. package/bin/mu.js +2 -0
  3. package/package.json +19 -0
  4. package/src/cli.ts +90 -0
  5. package/src/clipboard.ts +62 -0
  6. package/src/config.ts +116 -0
  7. package/src/diff.ts +81 -0
  8. package/src/main.tsx +80 -0
  9. package/src/project.ts +32 -0
  10. package/src/session.ts +95 -0
  11. package/src/singleShot.ts +42 -0
  12. package/src/tui/commands.ts +19 -0
  13. package/src/tui/components/chat/ChatPanel.tsx +55 -0
  14. package/src/tui/components/chat/ChatPanelBody.tsx +67 -0
  15. package/src/tui/components/chat/Pickers.tsx +44 -0
  16. package/src/tui/components/chatLayout.tsx +192 -0
  17. package/src/tui/components/inputBox.tsx +152 -0
  18. package/src/tui/components/messages/EditOutput.tsx +89 -0
  19. package/src/tui/components/messages/ReadOutput.tsx +43 -0
  20. package/src/tui/components/messages/WriteOutput.tsx +68 -0
  21. package/src/tui/components/messages/assistantMessage.tsx +24 -0
  22. package/src/tui/components/messages/messageItem.tsx +36 -0
  23. package/src/tui/components/messages/reasoningBlock.tsx +14 -0
  24. package/src/tui/components/messages/streamingOutput.tsx +14 -0
  25. package/src/tui/components/messages/toolCallBlock.tsx +99 -0
  26. package/src/tui/components/messages/userMessage.tsx +29 -0
  27. package/src/tui/components/ui/dropdown.tsx +96 -0
  28. package/src/tui/components/ui/modal.tsx +45 -0
  29. package/src/tui/components/ui/toast.tsx +45 -0
  30. package/src/tui/context/chat.ts +10 -0
  31. package/src/tui/hooks/useInputHandler.ts +257 -0
  32. package/src/tui/hooks/useScroll.ts +56 -0
  33. package/src/tui/hooks/useTerminal.ts +40 -0
  34. package/src/tui/hooks/useUI.ts +15 -0
  35. package/src/tui/useAbort.ts +68 -0
  36. package/src/tui/useChat.ts +52 -0
  37. package/src/tui/useChatSession.ts +155 -0
  38. package/src/tui/useChatUI.ts +51 -0
  39. package/src/tui/useModelList.ts +49 -0
  40. package/tsconfig.json +10 -0
package/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # mu-coding
2
+
3
+ Minimal terminal AI assistant for local models. A TUI chat interface with tool-calling support, built with Ink and React.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g mu-coding
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ mu # Start interactive chat
15
+ mu -p "prompt" # Single-shot prompt, then exit
16
+ mu -m model -p "p" # Single-shot with specific model
17
+ mu -m model # Interactive with specific model
18
+ mu -c # Continue most recent session
19
+ mu --session <path> # Resume a specific session file
20
+ ```
21
+
22
+ ## Configuration
23
+
24
+ Config files follow XDG conventions:
25
+
26
+ | Path | Purpose |
27
+ |------|---------|
28
+ | `~/.config/mu/config.json` | Settings (baseUrl, model, maxTokens, temperature) |
29
+ | `~/.config/mu/SYSTEM.md` | System prompt |
30
+ | `~/.local/share/mu/sessions/` | Saved conversation sessions (JSONL) |
31
+ | `~/.cache/mu/repomap/` | Code index cache |
32
+
33
+ ### Example `config.json`
34
+
35
+ ```json
36
+ {
37
+ "baseUrl": "http://localhost:11434/v1",
38
+ "model": "qwen2.5",
39
+ "maxTokens": 4096,
40
+ "temperature": 0.7,
41
+ "streamTimeoutMs": 30000
42
+ }
43
+ ```
44
+
45
+ ## Keyboard Shortcuts
46
+
47
+ | Key | Action |
48
+ |-----|--------|
49
+ | `Enter` | Send message |
50
+ | `Shift+Enter` | New line |
51
+ | `Ctrl+S` | Send message |
52
+ | `Ctrl+C` | Abort / Quit (press twice) |
53
+ | `Esc` | Stop generation (press twice) |
54
+ | `↑` / `↓` | Navigate input history |
55
+ | `Ctrl+N` | New conversation |
56
+ | `Ctrl+M` | Cycle models |
57
+ | `Ctrl+O` | Model picker |
58
+ | `Ctrl+V` | Paste image from clipboard |
59
+ | `PageUp` / `PageDown` | Scroll |
60
+ | Mouse wheel | Scroll |
61
+
62
+ ## Slash Commands
63
+
64
+ | Command | Action |
65
+ |---------|--------|
66
+ | `/model` | Select a model |
67
+ | `/sessions` | List project sessions |
68
+ | `/new` | New conversation |
69
+
70
+ ## Features
71
+
72
+ - Streams responses with live token/s display
73
+ - Multi-turn tool calling (bash, read, write, edit files)
74
+ - Code indexing via `mu-repomap` plugin (auto-loaded)
75
+ - Image attachment support
76
+ - Session persistence and resume
77
+ - Mouse wheel scrolling
78
+
79
+ ## License
80
+
81
+ MIT
package/bin/mu.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import '../src/main.tsx';
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "mu-coding",
3
+ "version": "0.1.0",
4
+ "description": "Minimal terminal AI assistant for local models",
5
+ "type": "module",
6
+ "bin": {
7
+ "mu": "./bin/mu.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "bun --watch bin/mu.js",
11
+ "start": "bun bin/mu.js"
12
+ },
13
+ "dependencies": {
14
+ "ink": "^7.0.1",
15
+ "mu-agents": "0.1.0",
16
+ "mu-provider": "0.1.0",
17
+ "react": "^19.2.5"
18
+ }
19
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { ChatMessage } from 'mu-provider';
2
+ import { getLatestSession, loadSession } from './session';
3
+
4
+ interface CliArgs {
5
+ model?: string;
6
+ prompt?: string;
7
+ continueSession?: boolean;
8
+ sessionPath?: string;
9
+ }
10
+
11
+ function printHelp(): never {
12
+ console.log(`mu — minimal terminal AI assistant
13
+
14
+ Usage:
15
+ mu Start interactive chat
16
+ mu -p "prompt" Single-shot prompt, then exit
17
+ mu -m model -p "p" Single-shot with specific model
18
+ mu -m model Interactive with specific model
19
+ mu -c Continue most recent session
20
+ mu --session <path> Resume a specific session file
21
+
22
+ Config (XDG):
23
+ ~/.config/mu/config.json — configuration (baseUrl, model, streamTimeoutMs)
24
+ ~/.config/mu/SYSTEM.md — system prompt
25
+ ~/.local/share/mu/sessions/ — saved conversation sessions (JSONL)
26
+ ~/.cache/mu/repomap/ — code index cache
27
+
28
+ Keyboard shortcuts (interactive):
29
+ Ctrl+C Abort / Quit (press twice)
30
+ Esc Stop generation (press twice while streaming)
31
+ Enter Send message
32
+ Shift+Enter New line
33
+ Ctrl+S Send message
34
+ ↑ / ↓ Navigate input history
35
+ Ctrl+N New conversation
36
+ Ctrl+M Cycle models
37
+ Ctrl+O Model picker
38
+ Ctrl+V Paste image from clipboard`);
39
+ process.exit(0);
40
+ }
41
+
42
+ export function parseArgs(): CliArgs {
43
+ const args = process.argv.slice(2);
44
+ const result: CliArgs = {};
45
+
46
+ for (let i = 0; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg === '-m' && args[i + 1]) {
49
+ result.model = args[++i];
50
+ } else if (arg === '-p' && args[i + 1]) {
51
+ result.prompt = args[++i];
52
+ } else if (arg === '-c' || arg === '--continue') {
53
+ result.continueSession = true;
54
+ } else if (arg === '--session' && args[i + 1]) {
55
+ result.sessionPath = args[++i];
56
+ } else if (arg === '-h' || arg === '--help') {
57
+ printHelp();
58
+ } else if (!(result.prompt || arg.startsWith('-'))) {
59
+ result.prompt = arg;
60
+ }
61
+ }
62
+
63
+ return result;
64
+ }
65
+
66
+ export function resolveInitialMessages(cliArgs: CliArgs): ChatMessage[] | undefined {
67
+ if (cliArgs.sessionPath) {
68
+ const msgs = loadSession(cliArgs.sessionPath);
69
+ if (msgs.length === 0) {
70
+ console.error(`Error: session file is empty or not found: ${cliArgs.sessionPath}`);
71
+ process.exit(1);
72
+ }
73
+ return msgs;
74
+ }
75
+ if (cliArgs.continueSession) {
76
+ const latest = getLatestSession();
77
+ if (!latest) {
78
+ console.error('Error: no sessions found');
79
+ process.exit(1);
80
+ }
81
+ const msgs = loadSession(latest);
82
+ if (msgs.length === 0) {
83
+ console.error('Error: latest session is empty');
84
+ process.exit(1);
85
+ }
86
+ console.log(`Resuming session: ${latest}`);
87
+ return msgs;
88
+ }
89
+ return undefined;
90
+ }
@@ -0,0 +1,62 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, statSync, unlinkSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { ImageAttachment } from 'mu-provider';
6
+
7
+ const CLIPBOARD_TIMEOUT = 3000;
8
+
9
+ function tryExecSync(command: string): boolean {
10
+ try {
11
+ execSync(command, { stdio: 'pipe', timeout: CLIPBOARD_TIMEOUT });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ function extractPlatformImage(tmpFile: string): boolean {
19
+ if (process.platform === 'darwin') {
20
+ if (tryExecSync('which pngpaste')) {
21
+ return tryExecSync(`pngpaste "${tmpFile}"`);
22
+ }
23
+ return tryExecSync(
24
+ `osascript -e 'tell application "System Events" -e 'set imgData to the clipboard as «class PNGf»' -e 'set fp to open for access POSIX file "${tmpFile}" with write permission' -e 'write imgData to fp' -e 'close access fp' -e 'end tell'`,
25
+ );
26
+ }
27
+ if (process.platform === 'linux') {
28
+ if (tryExecSync(`xclip -selection clipboard -t image/png -o > "${tmpFile}"`)) {
29
+ return true;
30
+ }
31
+ return tryExecSync(`wl-paste --type image/png > "${tmpFile}"`);
32
+ }
33
+ return false;
34
+ }
35
+
36
+ function readTmpAsAttachment(tmpFile: string): ImageAttachment | null {
37
+ if (!existsSync(tmpFile)) {
38
+ return null;
39
+ }
40
+ if (statSync(tmpFile).size === 0) {
41
+ unlinkSync(tmpFile);
42
+ return null;
43
+ }
44
+ const buffer = readFileSync(tmpFile);
45
+ unlinkSync(tmpFile);
46
+ return { data: buffer.toString('base64'), mimeType: 'image/png', name: 'clipboard.png' };
47
+ }
48
+
49
+ export function readClipboardImage(): ImageAttachment | null {
50
+ const tmpFile = join(tmpdir(), `mu-clip-${Date.now()}.png`);
51
+ try {
52
+ if (!extractPlatformImage(tmpFile)) {
53
+ return null;
54
+ }
55
+ return readTmpAsAttachment(tmpFile);
56
+ } catch {
57
+ if (existsSync(tmpFile)) {
58
+ unlinkSync(tmpFile);
59
+ }
60
+ return null;
61
+ }
62
+ }
package/src/config.ts ADDED
@@ -0,0 +1,116 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { ProviderConfig } from 'mu-provider';
5
+
6
+ export interface AppConfig extends ProviderConfig {
7
+ plugins?: Array<string | { name: string; config?: Record<string, unknown> }>;
8
+ }
9
+
10
+ export function getPluginsDir(): string {
11
+ return join(CONFIG_DIR, 'plugins');
12
+ }
13
+
14
+ // XDG Base Directory paths
15
+ const HOME = homedir();
16
+ const CONFIG_DIR = process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'mu') : join(HOME, '.config', 'mu');
17
+ const DATA_DIR = process.env.XDG_DATA_HOME
18
+ ? join(process.env.XDG_DATA_HOME, 'mu')
19
+ : join(HOME, '.local', 'share', 'mu');
20
+ const CACHE_DIR = process.env.XDG_CACHE_HOME ? join(process.env.XDG_CACHE_HOME, 'mu') : join(HOME, '.cache', 'mu');
21
+
22
+ const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
23
+ const SYSTEM_PROMPT_PATH = join(CONFIG_DIR, 'SYSTEM.md');
24
+
25
+ export function getConfigDir(): string {
26
+ return CONFIG_DIR;
27
+ }
28
+
29
+ export function getDataDir(): string {
30
+ return DATA_DIR;
31
+ }
32
+
33
+ export function getCacheDir(): string {
34
+ return CACHE_DIR;
35
+ }
36
+
37
+ function tryRead(path: string): string | undefined {
38
+ try {
39
+ return readFileSync(path, 'utf-8').trim() || undefined;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ }
44
+
45
+ function tryParseJson(text: string | undefined): Partial<AppConfig> {
46
+ if (!text) {
47
+ return {};
48
+ }
49
+ try {
50
+ return JSON.parse(text);
51
+ } catch {
52
+ return {};
53
+ }
54
+ }
55
+
56
+ function envInt(key: string): number | undefined {
57
+ const v = process.env[key];
58
+ if (!v) {
59
+ return undefined;
60
+ }
61
+ const n = Number.parseInt(v, 10);
62
+ return Number.isNaN(n) ? undefined : n;
63
+ }
64
+
65
+ function envFloat(key: string): number | undefined {
66
+ const v = process.env[key];
67
+ if (!v) {
68
+ return undefined;
69
+ }
70
+ const n = Number.parseFloat(v);
71
+ return Number.isNaN(n) ? undefined : n;
72
+ }
73
+
74
+ export function loadConfig(cliModel?: string): AppConfig {
75
+ const file = tryParseJson(tryRead(CONFIG_PATH));
76
+
77
+ const config: AppConfig = {
78
+ baseUrl: process.env.MU_BASE_URL || file.baseUrl || 'http://localhost:8080/v1',
79
+ model: cliModel || process.env.MU_MODEL || file.model,
80
+ maxTokens: envInt('MU_MAX_TOKENS') ?? file.maxTokens ?? 4096,
81
+ temperature: envFloat('MU_TEMPERATURE') ?? file.temperature ?? 0.7,
82
+ streamTimeoutMs: envInt('MU_STREAM_TIMEOUT') ?? file.streamTimeoutMs ?? 60_000,
83
+ systemPrompt: process.env.MU_SYSTEM_PROMPT || file.systemPrompt || tryRead(SYSTEM_PROMPT_PATH),
84
+ plugins: file.plugins,
85
+ };
86
+
87
+ if (!existsSync(CONFIG_PATH)) {
88
+ mkdirSync(CONFIG_DIR, { recursive: true });
89
+ const KEYS = [
90
+ 'baseUrl',
91
+ 'model',
92
+ 'maxTokens',
93
+ 'temperature',
94
+ 'streamTimeoutMs',
95
+ 'systemPrompt',
96
+ 'plugins',
97
+ ] as const;
98
+ const fileConfig = Object.fromEntries(
99
+ KEYS.filter((k) => file[k] !== undefined).map((k) => [k, file[k]]),
100
+ ) as Partial<AppConfig>;
101
+ writeFileSync(CONFIG_PATH, JSON.stringify(fileConfig, null, 2), 'utf-8');
102
+ }
103
+
104
+ return config;
105
+ }
106
+
107
+ export function saveConfig(updates: Partial<AppConfig>): void {
108
+ const file = tryParseJson(tryRead(CONFIG_PATH));
109
+ const merged = { ...file, ...updates };
110
+ const KEYS = ['baseUrl', 'model', 'maxTokens', 'temperature', 'streamTimeoutMs', 'systemPrompt', 'plugins'] as const;
111
+ const fileConfig = Object.fromEntries(
112
+ KEYS.filter((k) => merged[k] !== undefined).map((k) => [k, merged[k]]),
113
+ ) as Partial<AppConfig>;
114
+ mkdirSync(CONFIG_DIR, { recursive: true });
115
+ writeFileSync(CONFIG_PATH, JSON.stringify(fileConfig, null, 2), 'utf-8');
116
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Lightweight diff for edit_file tool output.
2
+ // Uses prefix/suffix matching — sufficient for small, localized edits.
3
+
4
+ export interface DiffLine {
5
+ type: 'context' | 'old' | 'new';
6
+ value: string;
7
+ }
8
+
9
+ export interface DiffResult {
10
+ lines: DiffLine[];
11
+ totalOldLines: number;
12
+ totalNewLines: number;
13
+ }
14
+
15
+ const CONTEXT_LINES = 3;
16
+ const MAX_LINES = 500;
17
+
18
+ export function computeDiff(oldText: string, newText: string): DiffResult {
19
+ const oldLines = oldText.split('\n');
20
+ const newLines = newText.split('\n');
21
+
22
+ if (oldLines.length > MAX_LINES || newLines.length > MAX_LINES) {
23
+ return { lines: [], totalOldLines: oldLines.length, totalNewLines: newLines.length };
24
+ }
25
+
26
+ // Find common prefix
27
+ let prefixLen = 0;
28
+ const minLen = Math.min(oldLines.length, newLines.length);
29
+ while (prefixLen < minLen && oldLines[prefixLen] === newLines[prefixLen]) {
30
+ prefixLen++;
31
+ }
32
+
33
+ // Find common suffix (don't overlap with prefix)
34
+ let suffixLen = 0;
35
+ const maxSuffix = Math.min(oldLines.length - prefixLen, newLines.length - prefixLen);
36
+ while (
37
+ suffixLen < maxSuffix &&
38
+ oldLines[oldLines.length - 1 - suffixLen] === newLines[newLines.length - 1 - suffixLen]
39
+ ) {
40
+ suffixLen++;
41
+ }
42
+
43
+ const result: DiffLine[] = [];
44
+
45
+ // Context from prefix (last N lines)
46
+ const ctxStart = Math.max(0, prefixLen - CONTEXT_LINES);
47
+ for (let i = ctxStart; i < prefixLen; i++) {
48
+ result.push({ type: 'context', value: oldLines[i] });
49
+ }
50
+
51
+ // Removed lines
52
+ for (let i = prefixLen; i < oldLines.length - suffixLen; i++) {
53
+ result.push({ type: 'old', value: oldLines[i] });
54
+ }
55
+
56
+ // Added lines
57
+ for (let i = prefixLen; i < newLines.length - suffixLen; i++) {
58
+ result.push({ type: 'new', value: newLines[i] });
59
+ }
60
+
61
+ // Context from suffix (first N lines)
62
+ const ctxEnd = Math.min(suffixLen, CONTEXT_LINES);
63
+ for (let i = 0; i < ctxEnd; i++) {
64
+ const idx = oldLines.length - suffixLen + i;
65
+ result.push({ type: 'context', value: oldLines[idx] });
66
+ }
67
+
68
+ return { lines: result, totalOldLines: oldLines.length, totalNewLines: newLines.length };
69
+ }
70
+
71
+ export function renderDiff(diff: DiffResult, maxLines: number): { lines: string[]; truncated: boolean } {
72
+ const result: string[] = [];
73
+ const capped = diff.lines.slice(0, maxLines);
74
+
75
+ for (const line of capped) {
76
+ const prefix = line.type === 'old' ? '-' : line.type === 'new' ? '+' : ' ';
77
+ result.push(`${prefix} ${line.value}`);
78
+ }
79
+
80
+ return { lines: result, truncated: diff.lines.length > maxLines };
81
+ }
package/src/main.tsx ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bun
2
+ import { readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { render } from 'ink';
5
+ import { createBuiltinPlugin, PluginRegistry } from 'mu-agents';
6
+ import { parseArgs, resolveInitialMessages } from './cli';
7
+ import { type AppConfig, getPluginsDir, loadConfig } from './config';
8
+ import { runSingleShot } from './singleShot';
9
+ import { ChatPanel } from './tui/components/chat/ChatPanel';
10
+
11
+ function discoverPluginFiles(): string[] {
12
+ const dir = getPluginsDir();
13
+ try {
14
+ return readdirSync(dir)
15
+ .filter((f) => f.endsWith('.ts'))
16
+ .map((f) => join(dir, f));
17
+ } catch {
18
+ return [];
19
+ }
20
+ }
21
+
22
+ async function createRegistry(cwd: string, config: AppConfig) {
23
+ const registry = new PluginRegistry({ cwd, config: {} });
24
+
25
+ // Register built-in tools (read, write, edit, bash)
26
+ await registry.register(createBuiltinPlugin());
27
+
28
+ // Auto-load .ts plugin files from ~/.config/mu/plugins/
29
+ for (const filePath of discoverPluginFiles()) {
30
+ await registry.loadPlugin(filePath);
31
+ }
32
+
33
+ // Load npm package plugins from config
34
+ if (config.plugins?.length) {
35
+ for (const entry of config.plugins) {
36
+ const name = typeof entry === 'string' ? entry : entry.name;
37
+ const pluginConfig = typeof entry === 'string' ? undefined : entry.config;
38
+ await registry.loadPlugin(name, pluginConfig);
39
+ }
40
+ }
41
+
42
+ return registry;
43
+ }
44
+
45
+ async function main() {
46
+ const cliArgs = parseArgs();
47
+ const config = loadConfig(cliArgs.model);
48
+ const root = process.cwd();
49
+
50
+ const registry = await createRegistry(root, config);
51
+
52
+ if (cliArgs.prompt) {
53
+ try {
54
+ await runSingleShot(cliArgs.prompt, config, registry);
55
+ } catch (err: unknown) {
56
+ const msg = err instanceof Error ? err.message : 'Unknown error';
57
+ console.error(`Error: ${msg}`);
58
+ process.exit(1);
59
+ } finally {
60
+ await registry.shutdown();
61
+ }
62
+ return;
63
+ }
64
+
65
+ const initialMessages = resolveInitialMessages(cliArgs);
66
+
67
+ render(<ChatPanel config={config} initialMessages={initialMessages} registry={registry} />, {
68
+ exitOnCtrlC: false,
69
+ kittyKeyboard: { mode: 'enabled' },
70
+ });
71
+
72
+ process.on('exit', () => {
73
+ registry.shutdown();
74
+ });
75
+ }
76
+
77
+ main().catch((err) => {
78
+ console.error(err);
79
+ process.exit(1);
80
+ });
package/src/project.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+
4
+ function findGitRoot(from: string): string | null {
5
+ try {
6
+ const root = execSync('git rev-parse --show-toplevel', {
7
+ cwd: from,
8
+ encoding: 'utf-8',
9
+ stdio: ['pipe', 'pipe', 'pipe'],
10
+ }).trim();
11
+ return root || null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ function getProjectRoot(): string {
18
+ const cwd = process.cwd();
19
+ return findGitRoot(cwd) ?? cwd;
20
+ }
21
+
22
+ export function getProjectId(): string {
23
+ const root = getProjectRoot();
24
+ const hash = createHash('sha256').update(root).digest('hex').slice(0, 12);
25
+ const name = root.split('/').pop() || 'unknown';
26
+ return `${name}-${hash}`;
27
+ }
28
+
29
+ export function getProjectName(): string {
30
+ const root = getProjectRoot();
31
+ return root.split('/').pop() || root;
32
+ }
package/src/session.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { ChatMessage } from 'mu-provider';
4
+ import { getDataDir } from './config';
5
+ import { getProjectId, getProjectName } from './project';
6
+
7
+ function getProjectSessionsDir(): string {
8
+ return join(getDataDir(), 'sessions', getProjectId());
9
+ }
10
+
11
+ function getSortedSessionFiles(): string[] {
12
+ try {
13
+ const dir = getProjectSessionsDir();
14
+ return readdirSync(dir)
15
+ .filter((f) => f.endsWith('.jsonl'))
16
+ .sort()
17
+ .reverse();
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ export interface SessionInfo {
24
+ path: string;
25
+ name: string;
26
+ date: Date;
27
+ messageCount: number;
28
+ preview: string;
29
+ project: string;
30
+ }
31
+
32
+ export function generateSessionPath(): string {
33
+ const dir = getProjectSessionsDir();
34
+ mkdirSync(dir, { recursive: true });
35
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
36
+ return join(dir, `${ts}.jsonl`);
37
+ }
38
+
39
+ export function saveSession(path: string, messages: ChatMessage[]): void {
40
+ writeFileSync(path, `${messages.map((m) => JSON.stringify(m)).join('\n')}\n`, 'utf-8');
41
+ }
42
+
43
+ export function loadSession(path: string): ChatMessage[] {
44
+ try {
45
+ const content = readFileSync(path, 'utf-8').trim();
46
+ if (!content) {
47
+ return [];
48
+ }
49
+ return content
50
+ .split('\n')
51
+ .map((line) => {
52
+ try {
53
+ return JSON.parse(line) as ChatMessage;
54
+ } catch {
55
+ return null;
56
+ }
57
+ })
58
+ .filter((msg): msg is ChatMessage => msg !== null);
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ export function getLatestSession(): string | null {
65
+ const files = getSortedSessionFiles();
66
+ return files.length ? join(getProjectSessionsDir(), files[0]) : null;
67
+ }
68
+
69
+ export function listSessions(): SessionInfo[] {
70
+ try {
71
+ const dir = getProjectSessionsDir();
72
+ mkdirSync(dir, { recursive: true });
73
+ const files = getSortedSessionFiles();
74
+ const project = getProjectName();
75
+
76
+ return files.map((file) => {
77
+ const path = join(dir, file);
78
+ const stat = statSync(path);
79
+ const messages = loadSession(path);
80
+ const firstUserMsg = messages.find((m) => m.role === 'user');
81
+ const preview = firstUserMsg ? firstUserMsg.content.slice(0, 80).replace(/\n/g, ' ') : '(empty)';
82
+
83
+ return {
84
+ path,
85
+ name: file.replace('.jsonl', ''),
86
+ date: stat.mtime,
87
+ messageCount: messages.length,
88
+ preview,
89
+ project,
90
+ };
91
+ });
92
+ } catch {
93
+ return [];
94
+ }
95
+ }