mu-coding 0.15.0 → 0.16.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.
- package/README.md +9 -123
- package/bin/coding-agent.ts +95 -0
- package/package.json +10 -21
- package/src/config.ts +122 -0
- package/src/harness.test.ts +159 -0
- package/src/main.ts +53 -3
- package/src/plugins.ts +49 -0
- package/src/systemPrompt.ts +22 -0
- package/src/ui/ChatApp.ts +959 -0
- package/src/ui/commands.ts +35 -0
- package/src/ui/editor.ts +166 -0
- package/src/ui/markdown.ts +363 -0
- package/src/ui/picker.ts +126 -0
- package/src/ui/status.ts +61 -0
- package/src/ui/theme.ts +241 -0
- package/src/ui/transcript.test.ts +121 -0
- package/src/ui/transcript.ts +399 -0
- package/tsconfig.json +8 -0
- package/bin/mu.js +0 -2
- package/prompts/SYSTEM.md +0 -16
- package/src/app/shutdown.ts +0 -94
- package/src/app/startApp.ts +0 -49
- package/src/cli/args.ts +0 -133
- package/src/cli/install.ts +0 -107
- package/src/cli/subcommands.ts +0 -29
- package/src/cli/update.ts +0 -205
- package/src/config/index.test.ts +0 -77
- package/src/config/index.ts +0 -199
- package/src/plugin.ts +0 -124
- package/src/runtime/codingTools/bash.ts +0 -114
- package/src/runtime/codingTools/edit-file.ts +0 -60
- package/src/runtime/codingTools/index.ts +0 -39
- package/src/runtime/codingTools/read-file.ts +0 -83
- package/src/runtime/codingTools/utils.ts +0 -21
- package/src/runtime/codingTools/write-file.ts +0 -42
- package/src/runtime/createRegistry.test.ts +0 -147
- package/src/runtime/createRegistry.ts +0 -195
- package/src/runtime/fileMentionProvider.ts +0 -117
- package/src/runtime/messageBus.test.ts +0 -62
- package/src/runtime/messageBus.ts +0 -78
- package/src/runtime/pluginLoader.ts +0 -153
- package/src/runtime/startupUpdateCheck.ts +0 -163
- package/src/runtime/updateCheck.ts +0 -136
- package/src/sessions/index.test.ts +0 -66
- package/src/sessions/index.ts +0 -183
- package/src/sessions/peek.test.ts +0 -88
- package/src/sessions/project.ts +0 -51
- package/src/tui/channel/tuiChannel.test.ts +0 -107
- package/src/tui/channel/tuiChannel.ts +0 -62
- package/src/tui/chat/ChatContext.ts +0 -10
- package/src/tui/chat/MessageRendererContext.ts +0 -44
- package/src/tui/chat/ToolDisplayContext.ts +0 -33
- package/src/tui/chat/useAbort.ts +0 -85
- package/src/tui/chat/useAttachment.ts +0 -74
- package/src/tui/chat/useChat.ts +0 -113
- package/src/tui/chat/useChatPanel.ts +0 -120
- package/src/tui/chat/useChatSession.ts +0 -384
- package/src/tui/chat/useModels.ts +0 -83
- package/src/tui/chat/usePluginStatus.ts +0 -44
- package/src/tui/chat/useSessionPersistence.ts +0 -84
- package/src/tui/chat/useStatusSegments.ts +0 -85
- package/src/tui/chat/useSubagentBrowser.ts +0 -133
- package/src/tui/components/chat/ChatPanel.tsx +0 -54
- package/src/tui/components/chat/ChatPanelBody.tsx +0 -86
- package/src/tui/components/chat/Pickers.tsx +0 -44
- package/src/tui/components/chat/SubagentBrowserPanel.tsx +0 -145
- package/src/tui/components/messageView.tsx +0 -72
- package/src/tui/components/messages/EditOutput.tsx +0 -112
- package/src/tui/components/messages/ReadOutput.tsx +0 -48
- package/src/tui/components/messages/ToolHeader.tsx +0 -30
- package/src/tui/components/messages/WebFetchOutput.tsx +0 -30
- package/src/tui/components/messages/WriteOutput.tsx +0 -64
- package/src/tui/components/messages/assistantMessage.tsx +0 -72
- package/src/tui/components/messages/markdown.tsx +0 -407
- package/src/tui/components/messages/messageItem.tsx +0 -43
- package/src/tui/components/messages/reasoningBlock.tsx +0 -18
- package/src/tui/components/messages/streamingOutput.tsx +0 -18
- package/src/tui/components/messages/toolCallBlock.tsx +0 -125
- package/src/tui/components/messages/userMessage.tsx +0 -44
- package/src/tui/components/primitives/dropdown.tsx +0 -125
- package/src/tui/components/primitives/modal.tsx +0 -47
- package/src/tui/components/primitives/pickerModal.tsx +0 -47
- package/src/tui/components/primitives/scrollbar.tsx +0 -27
- package/src/tui/components/primitives/toast.tsx +0 -100
- package/src/tui/components/statusBar.tsx +0 -41
- package/src/tui/components/ui/dialogLayer.tsx +0 -175
- package/src/tui/context/ThemeContext.tsx +0 -18
- package/src/tui/hooks/useChordKeyboard.ts +0 -87
- package/src/tui/hooks/useInputInfoSegments.ts +0 -22
- package/src/tui/hooks/useScroll.ts +0 -64
- package/src/tui/hooks/useTerminal.ts +0 -40
- package/src/tui/hooks/useUI.ts +0 -15
- package/src/tui/input/InputBox.tsx +0 -6
- package/src/tui/input/InputBoxView.tsx +0 -293
- package/src/tui/input/commands.test.ts +0 -71
- package/src/tui/input/commands.ts +0 -55
- package/src/tui/input/cursor.test.ts +0 -136
- package/src/tui/input/cursor.ts +0 -214
- package/src/tui/input/dumpContext.ts +0 -107
- package/src/tui/input/sanitize.ts +0 -33
- package/src/tui/input/useCommandExecutor.ts +0 -32
- package/src/tui/input/useInputBox.ts +0 -265
- package/src/tui/input/useInputHandler.ts +0 -455
- package/src/tui/input/useMentionPicker.ts +0 -133
- package/src/tui/input/usePluginShortcuts.ts +0 -29
- package/src/tui/plugins/InkApprovalChannel.test.ts +0 -51
- package/src/tui/plugins/InkApprovalChannel.ts +0 -30
- package/src/tui/plugins/InkUIService.ts +0 -188
- package/src/tui/renderApp.tsx +0 -66
- package/src/tui/theme/index.ts +0 -1
- package/src/tui/theme/merge.test.ts +0 -49
- package/src/tui/theme/merge.ts +0 -43
- package/src/tui/theme/presets.ts +0 -90
- package/src/tui/theme/types.ts +0 -138
- package/src/tui/update/runUpdateInTui.ts +0 -127
- package/src/utils/clipboard.ts +0 -97
- package/src/utils/diff.test.ts +0 -56
- package/src/utils/diff.ts +0 -81
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { readdirSync } from 'node:fs';
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
3
|
-
import { join, resolve } from 'node:path';
|
|
4
|
-
import type { Plugin, PluginRegistry } from 'mu-core';
|
|
5
|
-
import { installNpmPackage } from '../cli/install';
|
|
6
|
-
import { getDataDir, getPluginsDir, parseBareNpmSpec } from '../config/index';
|
|
7
|
-
import type { InkUIService } from '../tui/plugins/InkUIService';
|
|
8
|
-
|
|
9
|
-
export function discoverPluginFiles(): string[] {
|
|
10
|
-
const dir = getPluginsDir();
|
|
11
|
-
try {
|
|
12
|
-
return readdirSync(dir)
|
|
13
|
-
.filter((f) => f.endsWith('.ts'))
|
|
14
|
-
.map((f) => join(dir, f));
|
|
15
|
-
} catch {
|
|
16
|
-
return [];
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function formatPluginError(name: string, err: unknown): string {
|
|
21
|
-
const parts: string[] = [`Plugin "${name}" failed`];
|
|
22
|
-
let current: unknown = err;
|
|
23
|
-
while (current) {
|
|
24
|
-
if (current instanceof Error) {
|
|
25
|
-
parts.push(current.message);
|
|
26
|
-
current = current.cause;
|
|
27
|
-
} else {
|
|
28
|
-
parts.push(String(current));
|
|
29
|
-
break;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return parts.join(': ');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Resolve an `npm:<spec>` plugin specifier to an absolute path on disk.
|
|
37
|
-
*
|
|
38
|
-
* If the package isn't installed yet, runs `bun add <spec>` against the mu
|
|
39
|
-
* data dir and retries — so users can list a plugin in `config.plugins`
|
|
40
|
-
* without having to invoke `mu install` first.
|
|
41
|
-
*
|
|
42
|
-
* `uiService` is optional: when provided, surface "Installing …" / failure
|
|
43
|
-
* messages through the TUI; otherwise fall back to stderr so the host's
|
|
44
|
-
* boot log still shows what happened.
|
|
45
|
-
*/
|
|
46
|
-
async function resolveNpmPlugin(specifier: string, uiService?: InkUIService): Promise<string> {
|
|
47
|
-
const bare = specifier.slice(4);
|
|
48
|
-
const { name } = parseBareNpmSpec(bare);
|
|
49
|
-
const dataDir = getDataDir();
|
|
50
|
-
const require = createRequire(resolve(dataDir, 'package.json'));
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
return require.resolve(name);
|
|
54
|
-
} catch (_firstErr) {
|
|
55
|
-
const installMsg = `Installing ${name}…`;
|
|
56
|
-
if (uiService) uiService.notify(installMsg, 'info');
|
|
57
|
-
else console.error(`[mu] ${installMsg}`);
|
|
58
|
-
|
|
59
|
-
try {
|
|
60
|
-
installNpmPackage(bare, { silent: true });
|
|
61
|
-
} catch (installErr) {
|
|
62
|
-
throw new Error(`Failed to auto-install "${name}" into ${dataDir}/node_modules`, { cause: installErr });
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
try {
|
|
66
|
-
return require.resolve(name);
|
|
67
|
-
} catch (retryErr) {
|
|
68
|
-
throw new Error(
|
|
69
|
-
`Auto-installed "${name}" but cannot resolve it from ${dataDir}/node_modules — install may have failed silently`,
|
|
70
|
-
{ cause: retryErr },
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function isPluginShape(value: unknown): value is Plugin {
|
|
77
|
-
return typeof value === 'object' && value !== null && 'name' in value && typeof (value as Plugin).name === 'string';
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Extract a plugin from a loaded module. Tries (in order):
|
|
82
|
-
* 1. `module.default` as a factory function
|
|
83
|
-
* 2. `module.createPlugin` as a factory function
|
|
84
|
-
* 3. `module.default` as a Plugin object
|
|
85
|
-
* 4. `module` as a Plugin object
|
|
86
|
-
*
|
|
87
|
-
* Returns `null` if no plugin shape matches; the caller should report this
|
|
88
|
-
* with the list of available exports for debugging.
|
|
89
|
-
*/
|
|
90
|
-
function extractPlugin(mod: Record<string, unknown>, pluginConfig: Record<string, unknown>): Plugin | null {
|
|
91
|
-
const factory = (mod.default ?? mod.createPlugin) as unknown;
|
|
92
|
-
if (typeof factory === 'function') {
|
|
93
|
-
const result = (factory as (cfg: Record<string, unknown>) => unknown)(pluginConfig);
|
|
94
|
-
return isPluginShape(result) ? result : null;
|
|
95
|
-
}
|
|
96
|
-
if (isPluginShape(mod.default)) {
|
|
97
|
-
return mod.default;
|
|
98
|
-
}
|
|
99
|
-
if (isPluginShape(mod)) {
|
|
100
|
-
return mod;
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Loader variant that *resolves* (imports + extracts) a plugin without
|
|
107
|
-
* registering it. Used by hosts driving `startMu({ resolvePlugin })`. Errors
|
|
108
|
-
* surface via `uiService` (or are swallowed when omitted) so the host's
|
|
109
|
-
* boot log behaviour matches `loadConfiguredPlugin`.
|
|
110
|
-
*/
|
|
111
|
-
export async function resolveConfiguredPlugin(
|
|
112
|
-
name: string,
|
|
113
|
-
pluginConfig?: Record<string, unknown>,
|
|
114
|
-
uiService?: InkUIService,
|
|
115
|
-
): Promise<Plugin | null> {
|
|
116
|
-
const config = pluginConfig ?? {};
|
|
117
|
-
let target: string;
|
|
118
|
-
try {
|
|
119
|
-
target = name.startsWith('npm:') ? await resolveNpmPlugin(name, uiService) : name;
|
|
120
|
-
} catch (err) {
|
|
121
|
-
uiService?.notify(formatPluginError(name, err), 'error');
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
let mod: Record<string, unknown>;
|
|
125
|
-
try {
|
|
126
|
-
mod = (await import(target)) as Record<string, unknown>;
|
|
127
|
-
} catch (err) {
|
|
128
|
-
uiService?.notify(formatPluginError(name, err), 'error');
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
const plugin = extractPlugin(mod, config);
|
|
132
|
-
if (!plugin) {
|
|
133
|
-
const exportKeys = Object.keys(mod).join(', ') || '(none)';
|
|
134
|
-
uiService?.notify(`Plugin "${name}": no plugin export found. Exports: [${exportKeys}]`, 'error');
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
return plugin;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export async function loadConfiguredPlugin(
|
|
141
|
-
registry: PluginRegistry,
|
|
142
|
-
name: string,
|
|
143
|
-
pluginConfig?: Record<string, unknown>,
|
|
144
|
-
uiService?: InkUIService,
|
|
145
|
-
): Promise<void> {
|
|
146
|
-
const plugin = await resolveConfiguredPlugin(name, pluginConfig, uiService);
|
|
147
|
-
if (!plugin) return;
|
|
148
|
-
try {
|
|
149
|
-
await registry.register(plugin);
|
|
150
|
-
} catch (err) {
|
|
151
|
-
uiService?.notify(formatPluginError(name, err), 'error');
|
|
152
|
-
}
|
|
153
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Startup update check — fires off `npm view` probes in the background after
|
|
3
|
-
* the TUI is up, then surfaces a toast through `uiService.notify` when mu or
|
|
4
|
-
* any installed npm plugin has a newer version on the registry.
|
|
5
|
-
*
|
|
6
|
-
* Design constraints:
|
|
7
|
-
* - Must never block startup. Caller fire-and-forgets the returned promise.
|
|
8
|
-
* - Must never crash the host on network / DNS errors. Every probe is wrapped.
|
|
9
|
-
* - Must not hammer npm on every boot. Results are cached in
|
|
10
|
-
* `<cacheDir>/update-check.json` for `CACHE_TTL_MS` (1h).
|
|
11
|
-
* - Disable with `MU_NO_UPDATE_CHECK=1` (kill switch for offline / CI / tests).
|
|
12
|
-
*
|
|
13
|
-
* Toasts are routed through the existing `InkUIService.notify` queue, which
|
|
14
|
-
* buffers messages emitted before any toast listener attaches — so even if
|
|
15
|
-
* the probe finishes before the TUI mounts (rare; see fast `npm view` runs)
|
|
16
|
-
* the alert still surfaces.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
-
import { join } from 'node:path';
|
|
21
|
-
import { getCacheDir, getDataDir } from '../config/index';
|
|
22
|
-
import type { InkUIService } from '../tui/plugins/InkUIService';
|
|
23
|
-
import {
|
|
24
|
-
listConfiguredNpmPlugins,
|
|
25
|
-
type NpmRegistryView,
|
|
26
|
-
PACKAGE_NAME,
|
|
27
|
-
probePluginAsync,
|
|
28
|
-
probeSelfAsync,
|
|
29
|
-
} from './updateCheck';
|
|
30
|
-
|
|
31
|
-
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 h
|
|
32
|
-
const CACHE_FILENAME = 'update-check.json';
|
|
33
|
-
|
|
34
|
-
interface CacheShape {
|
|
35
|
-
ts: number;
|
|
36
|
-
results: Record<string, NpmRegistryView | null>;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function cachePath(): string {
|
|
40
|
-
return join(getCacheDir(), CACHE_FILENAME);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function readCache(): CacheShape | null {
|
|
44
|
-
try {
|
|
45
|
-
const raw = readFileSync(cachePath(), 'utf-8');
|
|
46
|
-
const parsed = JSON.parse(raw) as CacheShape;
|
|
47
|
-
if (typeof parsed?.ts !== 'number' || typeof parsed?.results !== 'object') return null;
|
|
48
|
-
if (Date.now() - parsed.ts > CACHE_TTL_MS) return null;
|
|
49
|
-
return parsed;
|
|
50
|
-
} catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function writeCache(cache: CacheShape): void {
|
|
56
|
-
try {
|
|
57
|
-
mkdirSync(getCacheDir(), { recursive: true });
|
|
58
|
-
writeFileSync(cachePath(), JSON.stringify(cache), 'utf-8');
|
|
59
|
-
} catch {
|
|
60
|
-
// Cache writes are best-effort; ignore disk errors.
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Discard the cached probe result. Called after a `/update` so the next
|
|
66
|
-
* boot re-probes against fresh registry data instead of replaying the
|
|
67
|
-
* pre-update state from cache.
|
|
68
|
-
*/
|
|
69
|
-
export function invalidateUpdateCheckCache(): void {
|
|
70
|
-
try {
|
|
71
|
-
rmSync(cachePath(), { force: true });
|
|
72
|
-
} catch {
|
|
73
|
-
// best-effort
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function isDisabled(): boolean {
|
|
78
|
-
const v = process.env.MU_NO_UPDATE_CHECK;
|
|
79
|
-
return v === '1' || v === 'true' || v === 'yes';
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
interface ProbeOutcome {
|
|
83
|
-
self: NpmRegistryView | null;
|
|
84
|
-
plugins: Map<string, NpmRegistryView | null>;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
async function runProbes(): Promise<ProbeOutcome> {
|
|
88
|
-
const dataDir = getDataDir();
|
|
89
|
-
const pluginNames = listConfiguredNpmPlugins();
|
|
90
|
-
|
|
91
|
-
const [self, ...plugins] = await Promise.all([
|
|
92
|
-
probeSelfAsync().catch(() => null),
|
|
93
|
-
...pluginNames.map((name) => probePluginAsync(name, dataDir).catch(() => null)),
|
|
94
|
-
]);
|
|
95
|
-
|
|
96
|
-
const pluginMap = new Map<string, NpmRegistryView | null>();
|
|
97
|
-
pluginNames.forEach((name, i) => {
|
|
98
|
-
pluginMap.set(name, plugins[i] ?? null);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
return { self, plugins: pluginMap };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function outcomeFromCache(cache: CacheShape, pluginNames: string[]): ProbeOutcome {
|
|
105
|
-
const self = cache.results[PACKAGE_NAME] ?? null;
|
|
106
|
-
const plugins = new Map<string, NpmRegistryView | null>();
|
|
107
|
-
for (const name of pluginNames) {
|
|
108
|
-
plugins.set(name, cache.results[name] ?? null);
|
|
109
|
-
}
|
|
110
|
-
return { self, plugins };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function outcomeToCache(outcome: ProbeOutcome): CacheShape {
|
|
114
|
-
const results: Record<string, NpmRegistryView | null> = {};
|
|
115
|
-
results[PACKAGE_NAME] = outcome.self;
|
|
116
|
-
for (const [name, view] of outcome.plugins) {
|
|
117
|
-
results[name] = view;
|
|
118
|
-
}
|
|
119
|
-
return { ts: Date.now(), results };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function notifyOutcome(outcome: ProbeOutcome, ui: InkUIService): void {
|
|
123
|
-
const stale: string[] = [];
|
|
124
|
-
if (outcome.self?.hasUpdate) {
|
|
125
|
-
stale.push(`mu ${outcome.self.current} → ${outcome.self.latest}`);
|
|
126
|
-
}
|
|
127
|
-
for (const [name, view] of outcome.plugins) {
|
|
128
|
-
if (view?.hasUpdate) {
|
|
129
|
-
stale.push(`${name} ${view.current} → ${view.latest}`);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
if (stale.length === 0) return;
|
|
133
|
-
|
|
134
|
-
const header = stale.length === 1 ? 'Update available' : `${stale.length} updates available`;
|
|
135
|
-
const body = stale.join(', ');
|
|
136
|
-
ui.notify(`${header}: ${body}. Run \`mu update\` to apply.`, 'info');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Background entry point. Resolves once the probe (or cache lookup) has
|
|
141
|
-
* completed and any toast has been emitted. Callers should fire-and-forget.
|
|
142
|
-
*/
|
|
143
|
-
export async function checkForUpdatesInBackground(ui: InkUIService): Promise<void> {
|
|
144
|
-
if (isDisabled()) return;
|
|
145
|
-
|
|
146
|
-
const pluginNames = listConfiguredNpmPlugins();
|
|
147
|
-
|
|
148
|
-
const cached = readCache();
|
|
149
|
-
if (cached) {
|
|
150
|
-
notifyOutcome(outcomeFromCache(cached, pluginNames), ui);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
let outcome: ProbeOutcome;
|
|
155
|
-
try {
|
|
156
|
-
outcome = await runProbes();
|
|
157
|
-
} catch {
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
writeCache(outcomeToCache(outcome));
|
|
162
|
-
notifyOutcome(outcome, ui);
|
|
163
|
-
}
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared "is there a newer version?" probes used by both the `mu update` /
|
|
3
|
-
* `mu outdated` CLI subcommands (synchronous, blocking) and the in-TUI
|
|
4
|
-
* startup alert (asynchronous, cached). Pure & side-effect free except for
|
|
5
|
-
* the actual `npm view` exec — no toast / IO concerns live here.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { execFile, execFileSync } from 'node:child_process';
|
|
9
|
-
import { readFileSync } from 'node:fs';
|
|
10
|
-
import { dirname, join } from 'node:path';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { promisify } from 'node:util';
|
|
13
|
-
import { loadConfig, parseBareNpmSpec } from '../config/index';
|
|
14
|
-
|
|
15
|
-
const execFileAsync = promisify(execFile);
|
|
16
|
-
|
|
17
|
-
export const PACKAGE_NAME = 'mu-coding';
|
|
18
|
-
|
|
19
|
-
export interface NpmRegistryView {
|
|
20
|
-
current: string;
|
|
21
|
-
latest: string;
|
|
22
|
-
hasUpdate: boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function npmViewLatestSync(name: string): string | undefined {
|
|
26
|
-
try {
|
|
27
|
-
const out = execFileSync('npm', ['view', name, 'version'], {
|
|
28
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
29
|
-
encoding: 'utf-8',
|
|
30
|
-
});
|
|
31
|
-
return out.trim() || undefined;
|
|
32
|
-
} catch {
|
|
33
|
-
return undefined;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export async function npmViewLatest(name: string, timeoutMs = 8000): Promise<string | undefined> {
|
|
38
|
-
try {
|
|
39
|
-
const { stdout } = await execFileAsync('npm', ['view', name, 'version'], {
|
|
40
|
-
timeout: timeoutMs,
|
|
41
|
-
encoding: 'utf-8',
|
|
42
|
-
});
|
|
43
|
-
return stdout.trim() || undefined;
|
|
44
|
-
} catch {
|
|
45
|
-
return undefined;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function readInstalledVersion(name: string, cwd: string): string | undefined {
|
|
50
|
-
try {
|
|
51
|
-
const path = join(cwd, 'node_modules', name, 'package.json');
|
|
52
|
-
const pkg = JSON.parse(readFileSync(path, 'utf-8'));
|
|
53
|
-
return typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
54
|
-
} catch {
|
|
55
|
-
return undefined;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function readSelfVersion(): string | undefined {
|
|
60
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
61
|
-
const candidates = [
|
|
62
|
-
join(here, '..', '..', 'package.json'),
|
|
63
|
-
join(here, '..', 'package.json'),
|
|
64
|
-
join(here, '..', '..', '..', 'package.json'),
|
|
65
|
-
];
|
|
66
|
-
for (const path of candidates) {
|
|
67
|
-
try {
|
|
68
|
-
const pkg = JSON.parse(readFileSync(path, 'utf-8'));
|
|
69
|
-
if (pkg?.name === PACKAGE_NAME && typeof pkg.version === 'string') return pkg.version;
|
|
70
|
-
} catch {
|
|
71
|
-
// try next
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return undefined;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function listConfiguredNpmPlugins(): string[] {
|
|
78
|
-
const config = loadConfig();
|
|
79
|
-
const out: string[] = [];
|
|
80
|
-
for (const entry of config.plugins ?? []) {
|
|
81
|
-
const spec = typeof entry === 'string' ? entry : entry.name;
|
|
82
|
-
if (!spec.startsWith('npm:')) continue;
|
|
83
|
-
const { name } = parseBareNpmSpec(spec.slice(4));
|
|
84
|
-
out.push(name);
|
|
85
|
-
}
|
|
86
|
-
return out;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Best-effort "is `latest` newer than `current`?". Splits on `.+-` and
|
|
91
|
-
* compares numeric segments left-to-right. Returns `true` when `current` is
|
|
92
|
-
* unknown so a missing local install reads as "needs install / update".
|
|
93
|
-
*/
|
|
94
|
-
export function isVersionNewer(current: string | undefined, latest: string): boolean {
|
|
95
|
-
if (!current) return true;
|
|
96
|
-
if (current === latest) return false;
|
|
97
|
-
const cur = current.split(/[.+-]/).map((p) => Number.parseInt(p, 10));
|
|
98
|
-
const lat = latest.split(/[.+-]/).map((p) => Number.parseInt(p, 10));
|
|
99
|
-
const len = Math.max(cur.length, lat.length);
|
|
100
|
-
for (let i = 0; i < len; i++) {
|
|
101
|
-
const a = cur[i];
|
|
102
|
-
const b = lat[i];
|
|
103
|
-
if (Number.isNaN(a ?? Number.NaN) || Number.isNaN(b ?? Number.NaN)) return current !== latest;
|
|
104
|
-
if ((a ?? 0) < (b ?? 0)) return true;
|
|
105
|
-
if ((a ?? 0) > (b ?? 0)) return false;
|
|
106
|
-
}
|
|
107
|
-
return false;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export function probePluginSync(name: string, dataDir: string): NpmRegistryView | null {
|
|
111
|
-
const latest = npmViewLatestSync(name);
|
|
112
|
-
if (!latest) return null;
|
|
113
|
-
const current = readInstalledVersion(name, dataDir);
|
|
114
|
-
return { current: current ?? '(not installed)', latest, hasUpdate: isVersionNewer(current, latest) };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function probeSelfSync(): NpmRegistryView | null {
|
|
118
|
-
const latest = npmViewLatestSync(PACKAGE_NAME);
|
|
119
|
-
if (!latest) return null;
|
|
120
|
-
const current = readSelfVersion();
|
|
121
|
-
return { current: current ?? '(unknown)', latest, hasUpdate: isVersionNewer(current, latest) };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export async function probePluginAsync(name: string, dataDir: string): Promise<NpmRegistryView | null> {
|
|
125
|
-
const latest = await npmViewLatest(name);
|
|
126
|
-
if (!latest) return null;
|
|
127
|
-
const current = readInstalledVersion(name, dataDir);
|
|
128
|
-
return { current: current ?? '(not installed)', latest, hasUpdate: isVersionNewer(current, latest) };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export async function probeSelfAsync(): Promise<NpmRegistryView | null> {
|
|
132
|
-
const latest = await npmViewLatest(PACKAGE_NAME);
|
|
133
|
-
if (!latest) return null;
|
|
134
|
-
const current = readSelfVersion();
|
|
135
|
-
return { current: current ?? '(unknown)', latest, hasUpdate: isVersionNewer(current, latest) };
|
|
136
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
|
|
2
|
-
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { clearSessionCache, listSessionsAsync, loadSession, saveSession } from './index';
|
|
6
|
-
|
|
7
|
-
let tmpRoot: string;
|
|
8
|
-
let originalDataHome: string | undefined;
|
|
9
|
-
|
|
10
|
-
beforeAll(() => {
|
|
11
|
-
tmpRoot = mkdtempSync(join(tmpdir(), 'mu-sessions-'));
|
|
12
|
-
originalDataHome = process.env.XDG_DATA_HOME;
|
|
13
|
-
process.env.XDG_DATA_HOME = tmpRoot;
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterAll(() => {
|
|
17
|
-
if (originalDataHome === undefined) {
|
|
18
|
-
delete process.env.XDG_DATA_HOME;
|
|
19
|
-
} else {
|
|
20
|
-
process.env.XDG_DATA_HOME = originalDataHome;
|
|
21
|
-
}
|
|
22
|
-
clearSessionCache();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe('saveSession / loadSession', () => {
|
|
26
|
-
it('round-trips messages as JSONL', async () => {
|
|
27
|
-
const path = join(tmpRoot, 'roundtrip.jsonl');
|
|
28
|
-
const messages = [
|
|
29
|
-
{ role: 'user' as const, content: 'hi' },
|
|
30
|
-
{ role: 'assistant' as const, content: 'hello' },
|
|
31
|
-
];
|
|
32
|
-
await saveSession(path, messages);
|
|
33
|
-
|
|
34
|
-
const raw = readFileSync(path, 'utf-8');
|
|
35
|
-
expect(raw.split('\n').filter(Boolean)).toHaveLength(2);
|
|
36
|
-
|
|
37
|
-
const loaded = loadSession(path);
|
|
38
|
-
expect(loaded).toEqual(messages);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('returns [] for missing files', () => {
|
|
42
|
-
expect(loadSession(join(tmpRoot, 'does-not-exist.jsonl'))).toEqual([]);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('skips malformed JSONL lines', () => {
|
|
46
|
-
const path = join(tmpRoot, 'partial.jsonl');
|
|
47
|
-
writeFileSync(path, '{"role":"user","content":"ok"}\n{not json}\n{"role":"assistant","content":"yo"}\n');
|
|
48
|
-
const loaded = loadSession(path);
|
|
49
|
-
expect(loaded).toHaveLength(2);
|
|
50
|
-
expect(loaded[0].role).toBe('user');
|
|
51
|
-
expect(loaded[1].role).toBe('assistant');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('writes empty content for empty arrays', async () => {
|
|
55
|
-
const path = join(tmpRoot, 'empty.jsonl');
|
|
56
|
-
await saveSession(path, []);
|
|
57
|
-
expect(readFileSync(path, 'utf-8')).toBe('');
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('listSessionsAsync', () => {
|
|
62
|
-
it('returns [] when no project sessions exist', async () => {
|
|
63
|
-
const list = await listSessionsAsync();
|
|
64
|
-
expect(Array.isArray(list)).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
});
|
package/src/sessions/index.ts
DELETED
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import { createReadStream, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { stat, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { createInterface } from 'node:readline';
|
|
5
|
-
import type { ChatMessage } from 'mu-core';
|
|
6
|
-
import { getDataDir } from '../config/index';
|
|
7
|
-
import { getProjectId, getProjectName } from './project';
|
|
8
|
-
|
|
9
|
-
function getProjectSessionsDir(): string {
|
|
10
|
-
return join(getDataDir(), 'sessions', getProjectId());
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function getSortedSessionFiles(): string[] {
|
|
14
|
-
try {
|
|
15
|
-
const dir = getProjectSessionsDir();
|
|
16
|
-
return readdirSync(dir)
|
|
17
|
-
.filter((f) => f.endsWith('.jsonl'))
|
|
18
|
-
.sort()
|
|
19
|
-
.reverse();
|
|
20
|
-
} catch {
|
|
21
|
-
return [];
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export interface SessionInfo {
|
|
26
|
-
path: string;
|
|
27
|
-
name: string;
|
|
28
|
-
date: Date;
|
|
29
|
-
messageCount: number;
|
|
30
|
-
preview: string;
|
|
31
|
-
project: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function generateSessionPath(): string {
|
|
35
|
-
const dir = getProjectSessionsDir();
|
|
36
|
-
mkdirSync(dir, { recursive: true });
|
|
37
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
38
|
-
return join(dir, `${ts}.jsonl`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Persist `messages` as JSONL. Async to avoid blocking the event loop on
|
|
43
|
-
* large sessions; callers should `await` to apply backpressure.
|
|
44
|
-
*/
|
|
45
|
-
export async function saveSession(path: string, messages: ChatMessage[]): Promise<void> {
|
|
46
|
-
const content = messages.length > 0 ? `${messages.map((m) => JSON.stringify(m)).join('\n')}\n` : '';
|
|
47
|
-
await writeFile(path, content, 'utf-8');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function loadSession(path: string): ChatMessage[] {
|
|
51
|
-
try {
|
|
52
|
-
const content = readFileSync(path, 'utf-8').trim();
|
|
53
|
-
if (!content) {
|
|
54
|
-
return [];
|
|
55
|
-
}
|
|
56
|
-
return content
|
|
57
|
-
.split('\n')
|
|
58
|
-
.map((line) => {
|
|
59
|
-
try {
|
|
60
|
-
return JSON.parse(line) as ChatMessage;
|
|
61
|
-
} catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
})
|
|
65
|
-
.filter((msg): msg is ChatMessage => msg !== null);
|
|
66
|
-
} catch {
|
|
67
|
-
return [];
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface SessionPeek {
|
|
72
|
-
messageCount: number;
|
|
73
|
-
preview: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const PREVIEW_LENGTH = 80;
|
|
77
|
-
const NO_USER_PREVIEW = '(no user message)';
|
|
78
|
-
const EMPTY_PEEK: SessionPeek = { messageCount: 0, preview: '(empty)' };
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* In-memory cache of session metadata, keyed by absolute path. Entries are
|
|
82
|
-
* invalidated when the file's mtime changes (a fresh `saveSession` after a
|
|
83
|
-
* new message bumps mtime). Lifetime is the process — no on-disk index.
|
|
84
|
-
*/
|
|
85
|
-
const peekCache = new Map<string, { mtimeMs: number; peek: SessionPeek }>();
|
|
86
|
-
|
|
87
|
-
function extractUserPreview(line: string): string | null {
|
|
88
|
-
try {
|
|
89
|
-
const msg = JSON.parse(line) as ChatMessage;
|
|
90
|
-
if (msg && msg.role === 'user' && typeof msg.content === 'string') {
|
|
91
|
-
return msg.content.slice(0, PREVIEW_LENGTH).replace(/\n/g, ' ');
|
|
92
|
-
}
|
|
93
|
-
} catch {
|
|
94
|
-
// Skip malformed lines.
|
|
95
|
-
}
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Stream a session file line-by-line so memory use is bounded regardless of
|
|
101
|
-
* file size, and we can stop expensive `JSON.parse` work as soon as we've
|
|
102
|
-
* captured the first user message.
|
|
103
|
-
*/
|
|
104
|
-
async function peekSessionStreaming(path: string): Promise<SessionPeek> {
|
|
105
|
-
return new Promise((resolve) => {
|
|
106
|
-
const stream = createReadStream(path, { encoding: 'utf-8', highWaterMark: 64 * 1024 });
|
|
107
|
-
const rl = createInterface({ input: stream });
|
|
108
|
-
let messageCount = 0;
|
|
109
|
-
let preview: string | null = null;
|
|
110
|
-
|
|
111
|
-
const finish = (): void => {
|
|
112
|
-
resolve({ messageCount, preview: preview ?? NO_USER_PREVIEW });
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
rl.on('line', (line) => {
|
|
116
|
-
if (!line) return;
|
|
117
|
-
messageCount++;
|
|
118
|
-
if (preview !== null) return;
|
|
119
|
-
preview = extractUserPreview(line);
|
|
120
|
-
});
|
|
121
|
-
rl.on('close', finish);
|
|
122
|
-
stream.on('error', () => resolve(EMPTY_PEEK));
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function peekSessionCached(path: string, mtimeMs: number): Promise<SessionPeek> {
|
|
127
|
-
const cached = peekCache.get(path);
|
|
128
|
-
if (cached && cached.mtimeMs === mtimeMs) {
|
|
129
|
-
return cached.peek;
|
|
130
|
-
}
|
|
131
|
-
const peek = await peekSessionStreaming(path);
|
|
132
|
-
peekCache.set(path, { mtimeMs, peek });
|
|
133
|
-
return peek;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Test/maintenance helper — drop the in-memory peek cache. */
|
|
137
|
-
export function clearSessionCache(): void {
|
|
138
|
-
peekCache.clear();
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function getLatestSession(): string | null {
|
|
142
|
-
const files = getSortedSessionFiles();
|
|
143
|
-
return files.length ? join(getProjectSessionsDir(), files[0]) : null;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Resolve session metadata for the picker. Each file is peeked concurrently
|
|
148
|
-
* (typically just a few hundred bytes per file), and successive picker opens
|
|
149
|
-
* hit the in-memory cache keyed by mtime.
|
|
150
|
-
*/
|
|
151
|
-
export async function listSessionsAsync(): Promise<SessionInfo[]> {
|
|
152
|
-
let dir: string;
|
|
153
|
-
try {
|
|
154
|
-
dir = getProjectSessionsDir();
|
|
155
|
-
mkdirSync(dir, { recursive: true });
|
|
156
|
-
} catch {
|
|
157
|
-
return [];
|
|
158
|
-
}
|
|
159
|
-
const files = getSortedSessionFiles();
|
|
160
|
-
const project = getProjectName();
|
|
161
|
-
|
|
162
|
-
const results = await Promise.all(
|
|
163
|
-
files.map(async (file) => {
|
|
164
|
-
const path = join(dir, file);
|
|
165
|
-
try {
|
|
166
|
-
const fileStat = await stat(path);
|
|
167
|
-
const peek = await peekSessionCached(path, fileStat.mtimeMs);
|
|
168
|
-
return {
|
|
169
|
-
path,
|
|
170
|
-
name: file.replace('.jsonl', ''),
|
|
171
|
-
date: fileStat.mtime,
|
|
172
|
-
messageCount: peek.messageCount,
|
|
173
|
-
preview: peek.preview,
|
|
174
|
-
project,
|
|
175
|
-
} satisfies SessionInfo;
|
|
176
|
-
} catch {
|
|
177
|
-
return null;
|
|
178
|
-
}
|
|
179
|
-
}),
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
return results.filter((s): s is SessionInfo => s !== null);
|
|
183
|
-
}
|