icopilot 2.2.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.
- package/CHANGELOG.md +250 -0
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/icopilot.js +6 -0
- package/dist/acp/router.js +123 -0
- package/dist/acp/schema.js +53 -0
- package/dist/agents/aggregator.js +187 -0
- package/dist/agents/custom-agents.js +97 -0
- package/dist/agents/goal-driven.js +411 -0
- package/dist/agents/multi-repo.js +350 -0
- package/dist/agents/parallel-runner.js +181 -0
- package/dist/agents/router.js +144 -0
- package/dist/agents/self-heal.js +481 -0
- package/dist/agents/tdd-agent.js +278 -0
- package/dist/api/github-models.js +158 -0
- package/dist/bridge/ide-bridge.js +479 -0
- package/dist/cloud/routine-executor.js +34 -0
- package/dist/cloud/routine-scheduler.js +67 -0
- package/dist/cloud/routine-storage.js +297 -0
- package/dist/commands/acp-cmd.js +143 -0
- package/dist/commands/actions-cmd.js +624 -0
- package/dist/commands/agent-cmd.js +144 -0
- package/dist/commands/alias-cmd.js +132 -0
- package/dist/commands/bookmark-cmd.js +77 -0
- package/dist/commands/changelog-cmd.js +99 -0
- package/dist/commands/changes-cmd.js +120 -0
- package/dist/commands/clipboard-cmd.js +217 -0
- package/dist/commands/cloud-routine-cmd.js +265 -0
- package/dist/commands/codegen-cmd.js +544 -0
- package/dist/commands/compare-cmd.js +116 -0
- package/dist/commands/context-cmd.js +247 -0
- package/dist/commands/context-viz-cmd.js +43 -0
- package/dist/commands/conventions-cmd.js +116 -0
- package/dist/commands/cost-cmd.js +51 -0
- package/dist/commands/deps-cmd.js +294 -0
- package/dist/commands/diagram-cmd.js +658 -0
- package/dist/commands/diff-review-cmd.js +92 -0
- package/dist/commands/doc-cmd.js +412 -0
- package/dist/commands/doctor-cmd.js +152 -0
- package/dist/commands/editor-cmd.js +49 -0
- package/dist/commands/env-cmd.js +86 -0
- package/dist/commands/explain-cmd.js +78 -0
- package/dist/commands/explain-shell-cmd.js +22 -0
- package/dist/commands/explore-cmd.js +231 -0
- package/dist/commands/feedback-cmd.js +98 -0
- package/dist/commands/fix-cmd.js +17 -0
- package/dist/commands/generate-cmd.js +38 -0
- package/dist/commands/git-extra.js +197 -0
- package/dist/commands/git-log-cmd.js +98 -0
- package/dist/commands/git-undo-cmd.js +137 -0
- package/dist/commands/git.js +155 -0
- package/dist/commands/history-cmd.js +122 -0
- package/dist/commands/index-cmd.js +65 -0
- package/dist/commands/init-cmd.js +73 -0
- package/dist/commands/lint-cmd.js +133 -0
- package/dist/commands/memory-cmd.js +98 -0
- package/dist/commands/metrics-cmd.js +97 -0
- package/dist/commands/mode-prefix.js +30 -0
- package/dist/commands/multi-cmd.js +44 -0
- package/dist/commands/notify-cmd.js +204 -0
- package/dist/commands/profile-cmd.js +101 -0
- package/dist/commands/prompts.js +17 -0
- package/dist/commands/rag-cmd.js +60 -0
- package/dist/commands/readme-cmd.js +564 -0
- package/dist/commands/reasoning-cmd.js +34 -0
- package/dist/commands/refactor-cmd.js +96 -0
- package/dist/commands/release-cmd.js +450 -0
- package/dist/commands/repo-cmd.js +195 -0
- package/dist/commands/route-cmd.js +21 -0
- package/dist/commands/schedule-cmd.js +109 -0
- package/dist/commands/search-cmd.js +47 -0
- package/dist/commands/security-cmd.js +156 -0
- package/dist/commands/settings-cmd.js +238 -0
- package/dist/commands/skill-cmd.js +338 -0
- package/dist/commands/slash.js +2721 -0
- package/dist/commands/snippets-cmd.js +83 -0
- package/dist/commands/space-cmd.js +92 -0
- package/dist/commands/stash-cmd.js +156 -0
- package/dist/commands/stats-cmd.js +36 -0
- package/dist/commands/style-cmd.js +85 -0
- package/dist/commands/suggest-cmd.js +40 -0
- package/dist/commands/summary-cmd.js +138 -0
- package/dist/commands/task-cmd.js +58 -0
- package/dist/commands/team-memory-cmd.js +97 -0
- package/dist/commands/template-cmd.js +475 -0
- package/dist/commands/test-cmd.js +146 -0
- package/dist/commands/todo-cmd.js +172 -0
- package/dist/commands/tokens-cmd.js +277 -0
- package/dist/commands/trigger-cmd.js +147 -0
- package/dist/commands/undo-cmd.js +18 -0
- package/dist/commands/voice-cmd.js +89 -0
- package/dist/commands/watch-cmd.js +110 -0
- package/dist/commands/web-cmd.js +183 -0
- package/dist/commands/worktree-cmd.js +119 -0
- package/dist/config-profile.js +66 -0
- package/dist/config.js +288 -0
- package/dist/context/compactor.js +53 -0
- package/dist/context/dep-context.js +329 -0
- package/dist/context/file-refs.js +54 -0
- package/dist/context/git-context.js +229 -0
- package/dist/context/image-input.js +66 -0
- package/dist/context/memory.js +55 -0
- package/dist/context/persistent-memory.js +104 -0
- package/dist/context/pinned.js +96 -0
- package/dist/context/priority.js +150 -0
- package/dist/context/read-only.js +48 -0
- package/dist/context/smart-files.js +286 -0
- package/dist/context/team-memory.js +156 -0
- package/dist/extensions/loader.js +149 -0
- package/dist/extensions/marketplace.js +49 -0
- package/dist/extensions/slack-provider.js +181 -0
- package/dist/extensions/team.js +56 -0
- package/dist/extensions/teams-provider.js +222 -0
- package/dist/extensions/voice.js +18 -0
- package/dist/hooks/lifecycle.js +215 -0
- package/dist/hooks/precommit.js +463 -0
- package/dist/index/embeddings.js +23 -0
- package/dist/index/indexer.js +86 -0
- package/dist/index/retrieve.js +20 -0
- package/dist/index/store.js +95 -0
- package/dist/index.js +286 -0
- package/dist/intelligence/dead-code.js +457 -0
- package/dist/intelligence/error-watch.js +263 -0
- package/dist/intelligence/navigation.js +141 -0
- package/dist/intelligence/stack-trace.js +210 -0
- package/dist/intelligence/symbol-index.js +410 -0
- package/dist/knowledge/auto-memory.js +412 -0
- package/dist/knowledge/conventions.js +475 -0
- package/dist/knowledge/corrections.js +213 -0
- package/dist/knowledge/rag.js +450 -0
- package/dist/knowledge/style-learner.js +324 -0
- package/dist/logger.js +35 -0
- package/dist/mcp/client.js +144 -0
- package/dist/mcp/config.js +24 -0
- package/dist/mcp/index.js +89 -0
- package/dist/modes/auto-compact.js +20 -0
- package/dist/modes/autopilot.js +157 -0
- package/dist/modes/background.js +82 -0
- package/dist/modes/interactive.js +187 -0
- package/dist/modes/oneshot.js +36 -0
- package/dist/modes/tui.js +265 -0
- package/dist/modes/turn.js +342 -0
- package/dist/notifications/manager.js +107 -0
- package/dist/plugins/marketplace.js +244 -0
- package/dist/providers/custom-provider.js +298 -0
- package/dist/providers/local-model.js +121 -0
- package/dist/routing/profiles.js +44 -0
- package/dist/routing/router.js +18 -0
- package/dist/sandbox/container.js +151 -0
- package/dist/security/audit.js +237 -0
- package/dist/security/content-filter.js +449 -0
- package/dist/security/proxy.js +301 -0
- package/dist/security/retention.js +281 -0
- package/dist/security/roles.js +252 -0
- package/dist/server/api-server.js +679 -0
- package/dist/session/bookmarks.js +72 -0
- package/dist/session/cloud-session.js +291 -0
- package/dist/session/handoff.js +405 -0
- package/dist/session/manager.js +35 -0
- package/dist/session/session.js +296 -0
- package/dist/session/share.js +313 -0
- package/dist/session/undo-journal.js +91 -0
- package/dist/snippets/store.js +60 -0
- package/dist/spaces/space-config.js +156 -0
- package/dist/spaces/space.js +220 -0
- package/dist/stats/store.js +101 -0
- package/dist/tools/apply-patch.js +134 -0
- package/dist/tools/auto-check.js +218 -0
- package/dist/tools/diff-edit.js +150 -0
- package/dist/tools/diff-prompt.js +36 -0
- package/dist/tools/edit-file.js +66 -0
- package/dist/tools/file-ops.js +205 -0
- package/dist/tools/glob.js +17 -0
- package/dist/tools/grep.js +56 -0
- package/dist/tools/image.js +194 -0
- package/dist/tools/list-directory.js +228 -0
- package/dist/tools/memory.js +17 -0
- package/dist/tools/multi-edit.js +299 -0
- package/dist/tools/policy.js +95 -0
- package/dist/tools/registry.js +484 -0
- package/dist/tools/retry.js +74 -0
- package/dist/tools/run-in-terminal.js +162 -0
- package/dist/tools/safety.js +64 -0
- package/dist/tools/sandbox.js +15 -0
- package/dist/tools/search-symbols.js +212 -0
- package/dist/tools/shell.js +118 -0
- package/dist/tools/web.js +167 -0
- package/dist/ui/prompt.js +37 -0
- package/dist/ui/render.js +96 -0
- package/dist/ui/screen.js +13 -0
- package/dist/ui/theme.js +56 -0
- package/dist/util/browser.js +34 -0
- package/dist/util/completion.js +350 -0
- package/dist/util/cost.js +28 -0
- package/dist/util/keybindings.js +113 -0
- package/dist/util/lazy.js +26 -0
- package/dist/util/perf.js +25 -0
- package/dist/util/token-worker.js +11 -0
- package/dist/util/tokens.js +50 -0
- package/dist/workflows/builtins.js +128 -0
- package/dist/workflows/engine.js +496 -0
- package/dist/workflows/file-trigger.js +197 -0
- package/package.json +79 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { countTokensSync } from '../util/tokens.js';
|
|
2
|
+
import { theme } from '../ui/theme.js';
|
|
3
|
+
const PREVIEW_LIMIT = 80;
|
|
4
|
+
export function historyCommand(args, session) {
|
|
5
|
+
const subcommand = args[0]?.toLowerCase();
|
|
6
|
+
const entries = buildEntries(session);
|
|
7
|
+
if (!subcommand) {
|
|
8
|
+
return formatEntries(entries.slice(-20), 'History', `(${Math.min(20, entries.length)} of ${entries.length})`);
|
|
9
|
+
}
|
|
10
|
+
if (subcommand === 'search') {
|
|
11
|
+
const query = args.slice(1).join(' ').trim();
|
|
12
|
+
if (!query)
|
|
13
|
+
return usage();
|
|
14
|
+
const queryLower = query.toLowerCase();
|
|
15
|
+
const matches = entries.filter((entry) => contentToText(session.state.messages[entry.index].content).toLowerCase().includes(queryLower));
|
|
16
|
+
if (matches.length === 0)
|
|
17
|
+
return `${theme.warn(`No messages matched "${query}".`)}\n`;
|
|
18
|
+
return formatEntries(matches, `History search ${theme.hl(query)}`, `(${matches.length} match${matches.length === 1 ? '' : 'es'})`);
|
|
19
|
+
}
|
|
20
|
+
if (subcommand === 'show') {
|
|
21
|
+
const rawIndex = args[1];
|
|
22
|
+
const index = Number.parseInt(rawIndex ?? '', 10);
|
|
23
|
+
if (!rawIndex || Number.isNaN(index))
|
|
24
|
+
return usage();
|
|
25
|
+
const message = session.state.messages[index];
|
|
26
|
+
if (!message)
|
|
27
|
+
return `${theme.warn(`Message not found: ${rawIndex}`)}\n`;
|
|
28
|
+
const content = contentToText(message.content);
|
|
29
|
+
return [
|
|
30
|
+
theme.brand(`Message ${index}`),
|
|
31
|
+
` role: ${formatRole(String(message.role || 'message'))}`,
|
|
32
|
+
` tokens: ${theme.hl(String(tokensForContent(content)))}`,
|
|
33
|
+
'',
|
|
34
|
+
content || theme.dim('(empty)'),
|
|
35
|
+
'',
|
|
36
|
+
].join('\n');
|
|
37
|
+
}
|
|
38
|
+
if (subcommand === 'count') {
|
|
39
|
+
return [
|
|
40
|
+
theme.brand('History count'),
|
|
41
|
+
` messages: ${theme.hl(String(session.state.messages.length))}`,
|
|
42
|
+
` tokens: ${theme.hl(String(session.tokenUsage()))}`,
|
|
43
|
+
'',
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
46
|
+
return usage();
|
|
47
|
+
}
|
|
48
|
+
function buildEntries(session) {
|
|
49
|
+
return session.state.messages.map((message, index) => buildEntry(message, index));
|
|
50
|
+
}
|
|
51
|
+
function buildEntry(message, index) {
|
|
52
|
+
const content = contentToText(message.content);
|
|
53
|
+
return {
|
|
54
|
+
index,
|
|
55
|
+
role: String(message.role || 'message'),
|
|
56
|
+
preview: truncatePreview(content),
|
|
57
|
+
tokens: tokensForContent(content),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function formatEntries(entries, heading, detail) {
|
|
61
|
+
if (entries.length === 0)
|
|
62
|
+
return `${theme.warn('No messages in session history.')}\n`;
|
|
63
|
+
const indexWidth = Math.max(2, String(entries[entries.length - 1]?.index ?? 0).length);
|
|
64
|
+
const roleWidth = Math.max(...entries.map((entry) => entry.role.length), 4);
|
|
65
|
+
const lines = entries.map((entry) => {
|
|
66
|
+
const preview = entry.preview || theme.dim('(empty)');
|
|
67
|
+
return ` ${theme.dim(String(entry.index).padStart(indexWidth))} ${formatRole(entry.role.padEnd(roleWidth))} ${preview}`;
|
|
68
|
+
});
|
|
69
|
+
const header = detail ? `${theme.brand(heading)} ${theme.dim(detail)}` : theme.brand(heading);
|
|
70
|
+
return `${header}\n${lines.join('\n')}\n`;
|
|
71
|
+
}
|
|
72
|
+
function formatRole(role) {
|
|
73
|
+
const normalized = role.trim().toLowerCase();
|
|
74
|
+
if (normalized === 'user')
|
|
75
|
+
return theme.user(role);
|
|
76
|
+
if (normalized === 'assistant')
|
|
77
|
+
return theme.assistant(role);
|
|
78
|
+
if (normalized === 'system')
|
|
79
|
+
return theme.system(role);
|
|
80
|
+
return theme.dim(role);
|
|
81
|
+
}
|
|
82
|
+
function truncatePreview(content) {
|
|
83
|
+
const normalized = content.replace(/\s+/g, ' ').trim();
|
|
84
|
+
if (normalized.length <= PREVIEW_LIMIT)
|
|
85
|
+
return normalized;
|
|
86
|
+
return `${normalized.slice(0, PREVIEW_LIMIT - 1)}…`;
|
|
87
|
+
}
|
|
88
|
+
function tokensForContent(content) {
|
|
89
|
+
if (!content)
|
|
90
|
+
return 0;
|
|
91
|
+
try {
|
|
92
|
+
return countTokensSync(content);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return Math.ceil(content.length / 4);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function contentToText(content) {
|
|
99
|
+
if (typeof content === 'string')
|
|
100
|
+
return content;
|
|
101
|
+
if (Array.isArray(content)) {
|
|
102
|
+
return content
|
|
103
|
+
.map((part) => {
|
|
104
|
+
if (!part || typeof part !== 'object')
|
|
105
|
+
return '';
|
|
106
|
+
const record = part;
|
|
107
|
+
if (typeof record.text === 'string')
|
|
108
|
+
return record.text;
|
|
109
|
+
if (typeof record.type === 'string')
|
|
110
|
+
return JSON.stringify(record);
|
|
111
|
+
return '';
|
|
112
|
+
})
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.join('\n');
|
|
115
|
+
}
|
|
116
|
+
if (content == null)
|
|
117
|
+
return '';
|
|
118
|
+
return JSON.stringify(content, null, 2);
|
|
119
|
+
}
|
|
120
|
+
function usage() {
|
|
121
|
+
return `Usage: /history | /history search <query> | /history show <index> | /history count\n`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { theme } from '../ui/theme.js';
|
|
5
|
+
import { buildIndex } from '../index/indexer.js';
|
|
6
|
+
import { retrieve } from '../index/retrieve.js';
|
|
7
|
+
export async function indexCommand(rest) {
|
|
8
|
+
const [sub = 'status', ...args] = rest;
|
|
9
|
+
switch (sub.toLowerCase()) {
|
|
10
|
+
case 'build': {
|
|
11
|
+
process.stdout.write(theme.dim('Indexing repository (this requires GITHUB_TOKEN)…\n'));
|
|
12
|
+
try {
|
|
13
|
+
const r = await buildIndex(config.cwd);
|
|
14
|
+
process.stdout.write(theme.ok(`✔ indexed ${r.files} files, ${r.chunks} new chunks in ${r.ms}ms\n`) +
|
|
15
|
+
theme.dim(` ${r.outPath}\n`));
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
process.stdout.write(theme.err(`index build failed: ${e?.message || e}\n`));
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
case 'status': {
|
|
23
|
+
const idx = path.join(config.cwd, '.icopilot', 'index.json');
|
|
24
|
+
if (!fs.existsSync(idx)) {
|
|
25
|
+
process.stdout.write(theme.warn('No index found. Run /index build first.\n'));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(fs.readFileSync(idx, 'utf8'));
|
|
30
|
+
process.stdout.write(`\n model: ${theme.hl(parsed.model || '?')}\n` +
|
|
31
|
+
` built: ${parsed.createdAt || '?'}\n` +
|
|
32
|
+
` entries: ${Array.isArray(parsed.entries) ? parsed.entries.length : 0}\n\n`);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
process.stdout.write(theme.err(`failed to read index: ${e?.message || e}\n`));
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
case 'search': {
|
|
40
|
+
const query = args.join(' ').trim();
|
|
41
|
+
if (!query) {
|
|
42
|
+
process.stdout.write(theme.warn('usage: /index search <query>\n'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const hits = await retrieve(config.cwd, query, 6);
|
|
47
|
+
if (!hits.length) {
|
|
48
|
+
process.stdout.write(theme.dim('no matches (or index missing).\n'));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const h of hits) {
|
|
52
|
+
const snippet = h.text.replace(/\s+/g, ' ').slice(0, 200);
|
|
53
|
+
process.stdout.write(`${theme.hl(h.file)} chunk ${h.chunk} ${theme.dim(`(score ${h.score.toFixed(3)})`)}\n` +
|
|
54
|
+
` ${snippet}\n`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
process.stdout.write(theme.err(`search failed: ${e?.message || e}\n`));
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
default:
|
|
63
|
+
process.stdout.write(theme.warn('usage: /index build | /index status | /index search <q>\n'));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { theme } from '../ui/theme.js';
|
|
4
|
+
import { ROLES_CONFIG_FILE, renderRolesConfig } from '../security/roles.js';
|
|
5
|
+
const POLICY_FILE = '.icopilot/policy.json';
|
|
6
|
+
const MEMORY_FILE = '.icopilot/memory.md';
|
|
7
|
+
const TEAM_MEMORY_FILE = '.icopilot/team-memory.md';
|
|
8
|
+
const ROLES_FILE = ROLES_CONFIG_FILE;
|
|
9
|
+
const AGENTS_DIR = '.icopilot/agents/';
|
|
10
|
+
const CONFIG_DIR = '.icopilot/';
|
|
11
|
+
const DEFAULT_POLICY = {
|
|
12
|
+
allowShell: true,
|
|
13
|
+
allowWrite: true,
|
|
14
|
+
denyTools: [],
|
|
15
|
+
};
|
|
16
|
+
const MEMORY_TEMPLATE = '<!-- Project memory: add notes the AI should always know about this project -->\n';
|
|
17
|
+
const TEAM_MEMORY_TEMPLATE = `# Team memory
|
|
18
|
+
|
|
19
|
+
<!-- Shared team memory for conventions, decisions, tips, and warnings. -->
|
|
20
|
+
`;
|
|
21
|
+
export function initProject(cwd, opts) {
|
|
22
|
+
const force = opts?.force === true;
|
|
23
|
+
const configDir = path.join(cwd, '.icopilot');
|
|
24
|
+
const agentsDir = path.join(configDir, 'agents');
|
|
25
|
+
const policyPath = path.join(configDir, 'policy.json');
|
|
26
|
+
const memoryPath = path.join(configDir, 'memory.md');
|
|
27
|
+
const teamMemoryPath = path.join(configDir, 'team-memory.md');
|
|
28
|
+
const rolesPath = path.join(configDir, 'roles.yaml');
|
|
29
|
+
const created = [];
|
|
30
|
+
const skipped = [];
|
|
31
|
+
if (!fs.existsSync(configDir)) {
|
|
32
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
33
|
+
created.push(CONFIG_DIR);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
skipped.push(CONFIG_DIR);
|
|
37
|
+
}
|
|
38
|
+
if (!fs.existsSync(agentsDir)) {
|
|
39
|
+
fs.mkdirSync(agentsDir, { recursive: true });
|
|
40
|
+
created.push(AGENTS_DIR);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
skipped.push(AGENTS_DIR);
|
|
44
|
+
}
|
|
45
|
+
writeProjectFile(policyPath, `${JSON.stringify(DEFAULT_POLICY, null, 2)}\n`, POLICY_FILE, force, created, skipped);
|
|
46
|
+
writeProjectFile(memoryPath, MEMORY_TEMPLATE, MEMORY_FILE, force, created, skipped);
|
|
47
|
+
writeProjectFile(teamMemoryPath, TEAM_MEMORY_TEMPLATE, TEAM_MEMORY_FILE, force, created, skipped);
|
|
48
|
+
writeProjectFile(rolesPath, renderRolesConfig(), ROLES_FILE, force, created, skipped);
|
|
49
|
+
return { created, skipped, cwd };
|
|
50
|
+
}
|
|
51
|
+
export function formatInitResult(result) {
|
|
52
|
+
const lines = [
|
|
53
|
+
`${theme.brand('Project initialized')} ${theme.dim(result.cwd)}`,
|
|
54
|
+
'',
|
|
55
|
+
formatSection('Created', result.created, theme.ok),
|
|
56
|
+
formatSection('Skipped', result.skipped, theme.warn),
|
|
57
|
+
];
|
|
58
|
+
return `${lines.join('\n')}\n`;
|
|
59
|
+
}
|
|
60
|
+
function writeProjectFile(filePath, contents, label, force, created, skipped) {
|
|
61
|
+
if (fs.existsSync(filePath) && !force) {
|
|
62
|
+
skipped.push(label);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
fs.writeFileSync(filePath, contents, 'utf8');
|
|
66
|
+
created.push(label);
|
|
67
|
+
}
|
|
68
|
+
function formatSection(title, entries, color) {
|
|
69
|
+
if (entries.length === 0) {
|
|
70
|
+
return ` ${color(title)} ${theme.dim('none')}`;
|
|
71
|
+
}
|
|
72
|
+
return [` ${color(title)}`, ...entries.map((entry) => ` ${theme.hl(entry)}`)].join('\n');
|
|
73
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { theme } from '../ui/theme.js';
|
|
4
|
+
function readText(filePath) {
|
|
5
|
+
try {
|
|
6
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function readPackageJson(cwd) {
|
|
13
|
+
const text = readText(path.join(cwd, 'package.json'));
|
|
14
|
+
if (!text)
|
|
15
|
+
return undefined;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function fileExists(cwd, fileName) {
|
|
24
|
+
return fs.existsSync(path.join(cwd, fileName));
|
|
25
|
+
}
|
|
26
|
+
function hasAnyFile(cwd, fileNames) {
|
|
27
|
+
return fileNames.some((fileName) => fileExists(cwd, fileName));
|
|
28
|
+
}
|
|
29
|
+
function hasEslintConfig(cwd) {
|
|
30
|
+
try {
|
|
31
|
+
return fs.readdirSync(cwd).some((entry) => entry.startsWith('.eslintrc'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function hasPackageDependency(pkg, dependency) {
|
|
38
|
+
return Boolean(pkg?.dependencies?.[dependency] || pkg?.devDependencies?.[dependency]);
|
|
39
|
+
}
|
|
40
|
+
function hasComposerPackage(cwd, packageNamePart) {
|
|
41
|
+
const text = readText(path.join(cwd, 'composer.json'));
|
|
42
|
+
if (!text)
|
|
43
|
+
return false;
|
|
44
|
+
try {
|
|
45
|
+
const composer = JSON.parse(text);
|
|
46
|
+
const deps = { ...composer.require, ...composer['require-dev'] };
|
|
47
|
+
return Object.keys(deps).some((name) => name.includes(packageNamePart));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function fileContains(cwd, fileName, needle) {
|
|
54
|
+
return readText(path.join(cwd, fileName))?.includes(needle) ?? false;
|
|
55
|
+
}
|
|
56
|
+
export function detectLinters(cwd) {
|
|
57
|
+
const matches = [];
|
|
58
|
+
const pkg = readPackageJson(cwd);
|
|
59
|
+
const hasNpmLint = typeof pkg?.scripts?.lint === 'string';
|
|
60
|
+
const hasEslintDep = hasPackageDependency(pkg, 'eslint');
|
|
61
|
+
if (hasNpmLint) {
|
|
62
|
+
matches.push({
|
|
63
|
+
name: 'npm-lint',
|
|
64
|
+
command: 'npm run lint',
|
|
65
|
+
reason: 'package.json has "scripts.lint"',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (hasEslintDep) {
|
|
69
|
+
matches.push({
|
|
70
|
+
name: 'eslint',
|
|
71
|
+
command: 'eslint .',
|
|
72
|
+
reason: 'package.json has eslint as a dependency',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (!hasNpmLint && !hasEslintDep && hasEslintConfig(cwd)) {
|
|
76
|
+
matches.push({
|
|
77
|
+
name: 'eslint-config',
|
|
78
|
+
command: 'npx eslint .',
|
|
79
|
+
reason: '.eslintrc* is present',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (fileExists(cwd, 'ruff.toml') || fileContains(cwd, 'pyproject.toml', '[tool.ruff]')) {
|
|
83
|
+
matches.push({
|
|
84
|
+
name: 'ruff',
|
|
85
|
+
command: 'ruff check .',
|
|
86
|
+
reason: 'ruff configuration is present',
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (fileExists(cwd, '.flake8') || fileContains(cwd, 'setup.cfg', '[flake8]')) {
|
|
90
|
+
matches.push({
|
|
91
|
+
name: 'flake8',
|
|
92
|
+
command: 'flake8 .',
|
|
93
|
+
reason: 'flake8 configuration is present',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (fileContains(cwd, 'pyproject.toml', '[tool.black]')) {
|
|
97
|
+
matches.push({
|
|
98
|
+
name: 'black',
|
|
99
|
+
command: 'black --check .',
|
|
100
|
+
reason: 'pyproject.toml contains [tool.black]',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
if (hasAnyFile(cwd, ['golangci.yml', '.golangci.yml', '.golangci.yaml'])) {
|
|
104
|
+
matches.push({
|
|
105
|
+
name: 'golangci-lint',
|
|
106
|
+
command: 'golangci-lint run',
|
|
107
|
+
reason: 'golangci-lint configuration is present',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (fileExists(cwd, 'Cargo.toml')) {
|
|
111
|
+
matches.push({
|
|
112
|
+
name: 'cargo-clippy',
|
|
113
|
+
command: 'cargo clippy --all-targets -- -D warnings',
|
|
114
|
+
reason: 'Cargo.toml is present',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
if (hasComposerPackage(cwd, 'phpstan')) {
|
|
118
|
+
matches.push({
|
|
119
|
+
name: 'phpstan',
|
|
120
|
+
command: 'vendor/bin/phpstan',
|
|
121
|
+
reason: 'composer.json has phpstan',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return matches;
|
|
125
|
+
}
|
|
126
|
+
export function lintCommand(cwd) {
|
|
127
|
+
const matches = detectLinters(cwd);
|
|
128
|
+
if (matches.length === 0) {
|
|
129
|
+
return `${theme.warn('No supported linters detected.')}\n${theme.dim('Add a lint script or linter configuration, then try /lint again.')}\n`;
|
|
130
|
+
}
|
|
131
|
+
const lines = matches.map((match) => ` ${theme.ok(match.name)} ${theme.hl(match.command)} ${theme.dim(`(${match.reason})`)}`);
|
|
132
|
+
return `${theme.brand('Detected linters')}\n${lines.join('\n')}\n`;
|
|
133
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { AutoMemory } from '../knowledge/auto-memory.js';
|
|
2
|
+
import { PersistentMemory } from '../context/persistent-memory.js';
|
|
3
|
+
import { theme } from '../ui/theme.js';
|
|
4
|
+
export function memoryCommand(args, cwd) {
|
|
5
|
+
const [rawSubcommand = 'list', ...rest] = args;
|
|
6
|
+
const subcommand = rawSubcommand.toLowerCase();
|
|
7
|
+
if (subcommand === 'auto') {
|
|
8
|
+
return autoMemoryCommand(rest);
|
|
9
|
+
}
|
|
10
|
+
const memory = new PersistentMemory();
|
|
11
|
+
const projectId = memory.getProjectId(cwd);
|
|
12
|
+
memory.load(projectId);
|
|
13
|
+
if (subcommand === 'list') {
|
|
14
|
+
return formatEntries(memory.recall(), 'Project memory');
|
|
15
|
+
}
|
|
16
|
+
if (subcommand === 'add') {
|
|
17
|
+
const [key, ...valueParts] = rest;
|
|
18
|
+
const value = valueParts.join(' ').trim();
|
|
19
|
+
if (!key || !value)
|
|
20
|
+
return usage();
|
|
21
|
+
memory.remember(key, value, 'user');
|
|
22
|
+
memory.save(projectId);
|
|
23
|
+
return `${theme.ok('Remembered')} ${theme.hl(key)} ${theme.dim('→')} ${value}\n`;
|
|
24
|
+
}
|
|
25
|
+
if (subcommand === 'remove') {
|
|
26
|
+
const key = rest[0]?.trim();
|
|
27
|
+
if (!key)
|
|
28
|
+
return usage();
|
|
29
|
+
if (!memory.forget(key))
|
|
30
|
+
return `${theme.warn(`No memory entry found for "${key}".`)}\n`;
|
|
31
|
+
memory.save(projectId);
|
|
32
|
+
return `${theme.ok('Forgot')} ${theme.hl(key)}\n`;
|
|
33
|
+
}
|
|
34
|
+
if (subcommand === 'clear') {
|
|
35
|
+
const entries = memory.recall();
|
|
36
|
+
for (const entry of entries)
|
|
37
|
+
memory.forget(entry.key);
|
|
38
|
+
memory.save(projectId);
|
|
39
|
+
return `${theme.ok(`Cleared ${entries.length} memory entr${entries.length === 1 ? 'y' : 'ies'}.`)}\n`;
|
|
40
|
+
}
|
|
41
|
+
if (subcommand === 'search') {
|
|
42
|
+
const query = rest.join(' ').trim();
|
|
43
|
+
if (!query)
|
|
44
|
+
return usage();
|
|
45
|
+
return formatEntries(memory.recall(query), `Project memory ${theme.dim(`(search: ${query})`)}`);
|
|
46
|
+
}
|
|
47
|
+
return usage();
|
|
48
|
+
}
|
|
49
|
+
function autoMemoryCommand(args) {
|
|
50
|
+
const memory = new AutoMemory();
|
|
51
|
+
memory.load();
|
|
52
|
+
const [rawAction = 'list', ...rest] = args;
|
|
53
|
+
const action = rawAction.toLowerCase();
|
|
54
|
+
if (action === 'list') {
|
|
55
|
+
return formatAutoMemories(memory.memories);
|
|
56
|
+
}
|
|
57
|
+
if (action === 'clear') {
|
|
58
|
+
const total = memory.memories.length;
|
|
59
|
+
memory.clear();
|
|
60
|
+
memory.save();
|
|
61
|
+
return `${theme.ok(`Cleared ${total} auto-memor${total === 1 ? 'y' : 'ies'}.`)}\n`;
|
|
62
|
+
}
|
|
63
|
+
if (action === 'forget') {
|
|
64
|
+
const id = rest.join(' ').trim();
|
|
65
|
+
if (!id)
|
|
66
|
+
return autoUsage();
|
|
67
|
+
if (!memory.forget(id))
|
|
68
|
+
return `${theme.warn(`No auto-memory found for "${id}".`)}\n`;
|
|
69
|
+
memory.save();
|
|
70
|
+
return `${theme.ok('Forgot auto-memory')} ${theme.hl(id)}\n`;
|
|
71
|
+
}
|
|
72
|
+
return autoUsage();
|
|
73
|
+
}
|
|
74
|
+
function formatEntries(entries, header) {
|
|
75
|
+
if (entries.length === 0)
|
|
76
|
+
return `${theme.brand(header)}\n ${theme.dim('No remembered facts.')}\n`;
|
|
77
|
+
const lines = entries.map((entry) => ` ${theme.hl(entry.key)} = ${entry.value} ${theme.dim(`(${entry.source}, ${entry.addedAt})`)}`);
|
|
78
|
+
return `${theme.brand(header)}\n${lines.join('\n')}\n`;
|
|
79
|
+
}
|
|
80
|
+
function formatAutoMemories(entries) {
|
|
81
|
+
if (entries.length === 0) {
|
|
82
|
+
return `${theme.brand('Auto memory')}\n ${theme.dim('No auto-learned memories.')}\n`;
|
|
83
|
+
}
|
|
84
|
+
const lines = [...entries]
|
|
85
|
+
.sort((left, right) => right.lastUsedAt.getTime() - left.lastUsedAt.getTime())
|
|
86
|
+
.map((entry) => {
|
|
87
|
+
const details = `${entry.source}, conf=${entry.confidence.toFixed(2)}, used ${entry.usageCount}x`;
|
|
88
|
+
return ` ${theme.hl(entry.id)} ${theme.dim(`(${details})`)}\n ${entry.fact}`;
|
|
89
|
+
});
|
|
90
|
+
return `${theme.brand('Auto memory')}\n${lines.join('\n')}\n`;
|
|
91
|
+
}
|
|
92
|
+
function usage() {
|
|
93
|
+
return ('Usage: /memory [list] | /memory add <key> <value> | /memory remove <key> | /memory clear | /memory search <query>\n' +
|
|
94
|
+
' /memory auto [list|clear|forget <id>]\n');
|
|
95
|
+
}
|
|
96
|
+
function autoUsage() {
|
|
97
|
+
return 'Usage: /memory auto [list|clear|forget <id>]\n';
|
|
98
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { theme } from '../ui/theme.js';
|
|
2
|
+
export class MetricsCollector {
|
|
3
|
+
metrics = [];
|
|
4
|
+
responseTimes = [];
|
|
5
|
+
tokenThroughputs = [];
|
|
6
|
+
toolCalls = [];
|
|
7
|
+
sessionStartedAt = Date.now();
|
|
8
|
+
record(name, value, unit) {
|
|
9
|
+
const metric = {
|
|
10
|
+
name,
|
|
11
|
+
value,
|
|
12
|
+
unit,
|
|
13
|
+
timestamp: Date.now(),
|
|
14
|
+
};
|
|
15
|
+
this.metrics.push(metric);
|
|
16
|
+
}
|
|
17
|
+
recordResponseTime(ms) {
|
|
18
|
+
const metric = this.createMetric('response', ms, 'ms');
|
|
19
|
+
this.responseTimes.push(metric);
|
|
20
|
+
this.metrics.push(metric);
|
|
21
|
+
}
|
|
22
|
+
recordTokenThroughput(tokensPerSec) {
|
|
23
|
+
const metric = this.createMetric('throughput', tokensPerSec, 'tokens/s');
|
|
24
|
+
this.tokenThroughputs.push(metric);
|
|
25
|
+
this.metrics.push(metric);
|
|
26
|
+
}
|
|
27
|
+
recordToolCall(name, ms) {
|
|
28
|
+
const metric = this.createMetric(name, ms, 'ms');
|
|
29
|
+
this.toolCalls.push(metric);
|
|
30
|
+
this.metrics.push(metric);
|
|
31
|
+
}
|
|
32
|
+
summary() {
|
|
33
|
+
return {
|
|
34
|
+
avgResponseMs: average(this.responseTimes),
|
|
35
|
+
avgTokensPerSec: average(this.tokenThroughputs),
|
|
36
|
+
totalTurns: this.responseTimes.length,
|
|
37
|
+
avgToolCallMs: average(this.toolCalls),
|
|
38
|
+
sessionDurationMs: Math.max(0, Date.now() - this.sessionStartedAt),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
topSlowestToolCalls(limit = 5) {
|
|
42
|
+
return [...this.toolCalls]
|
|
43
|
+
.sort((left, right) => right.value - left.value || left.name.localeCompare(right.name))
|
|
44
|
+
.slice(0, limit);
|
|
45
|
+
}
|
|
46
|
+
reset() {
|
|
47
|
+
this.metrics.length = 0;
|
|
48
|
+
this.responseTimes.length = 0;
|
|
49
|
+
this.tokenThroughputs.length = 0;
|
|
50
|
+
this.toolCalls.length = 0;
|
|
51
|
+
this.sessionStartedAt = Date.now();
|
|
52
|
+
}
|
|
53
|
+
createMetric(name, value, unit) {
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
value,
|
|
57
|
+
unit,
|
|
58
|
+
timestamp: Date.now(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function metricsCommand(collector) {
|
|
63
|
+
const summary = collector.summary();
|
|
64
|
+
const slowestToolCalls = collector.topSlowestToolCalls(5);
|
|
65
|
+
return [
|
|
66
|
+
theme.brand('Performance metrics'),
|
|
67
|
+
` avg response time: ${theme.hl(formatDuration(summary.avgResponseMs))}`,
|
|
68
|
+
` avg throughput: ${theme.hl(formatTokensPerSec(summary.avgTokensPerSec))}`,
|
|
69
|
+
` avg tool call: ${theme.hl(formatDuration(summary.avgToolCallMs))}`,
|
|
70
|
+
` total turns: ${theme.hl(String(summary.totalTurns))}`,
|
|
71
|
+
` session duration: ${theme.hl(formatDuration(summary.sessionDurationMs))}`,
|
|
72
|
+
'',
|
|
73
|
+
theme.brand('Top-5 slowest tool calls'),
|
|
74
|
+
slowestToolCalls.length > 0
|
|
75
|
+
? slowestToolCalls
|
|
76
|
+
.map((metric, index) => ` ${theme.dim(`${index + 1}.`)} ${metric.name} ${theme.hl(formatDuration(metric.value))}`)
|
|
77
|
+
.join('\n')
|
|
78
|
+
: ` ${theme.dim('none recorded yet')}`,
|
|
79
|
+
'',
|
|
80
|
+
].join('\n');
|
|
81
|
+
}
|
|
82
|
+
export function formatDuration(ms) {
|
|
83
|
+
const safeMs = Number.isFinite(ms) ? Math.max(0, ms) : 0;
|
|
84
|
+
if (safeMs < 1000)
|
|
85
|
+
return `${Math.round(safeMs)}ms`;
|
|
86
|
+
return `${(safeMs / 1000).toFixed(1).replace(/\.0$/, '')}s`;
|
|
87
|
+
}
|
|
88
|
+
function average(metrics) {
|
|
89
|
+
if (metrics.length === 0)
|
|
90
|
+
return 0;
|
|
91
|
+
const total = metrics.reduce((sum, metric) => sum + metric.value, 0);
|
|
92
|
+
return total / metrics.length;
|
|
93
|
+
}
|
|
94
|
+
function formatTokensPerSec(tokensPerSec) {
|
|
95
|
+
const safe = Number.isFinite(tokensPerSec) ? Math.max(0, tokensPerSec) : 0;
|
|
96
|
+
return `${safe.toFixed(1)} tokens/s`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function parseModePrefix(input) {
|
|
2
|
+
const trimmed = input.trim();
|
|
3
|
+
const match = trimmed.match(/^\/(ask|code|architect)(?:\s+(.*))?$/i);
|
|
4
|
+
if (!match) {
|
|
5
|
+
return { mode: null, message: input };
|
|
6
|
+
}
|
|
7
|
+
return {
|
|
8
|
+
mode: match[1].toLowerCase(),
|
|
9
|
+
message: (match[2] ?? '').trim(),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function resolveModePrefix(input) {
|
|
13
|
+
const parsed = parseModePrefix(input);
|
|
14
|
+
if (!parsed.mode) {
|
|
15
|
+
return { matched: false, consumed: false };
|
|
16
|
+
}
|
|
17
|
+
if (!parsed.message) {
|
|
18
|
+
return {
|
|
19
|
+
matched: true,
|
|
20
|
+
consumed: true,
|
|
21
|
+
usage: `usage: /${parsed.mode} <message>`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
matched: true,
|
|
26
|
+
consumed: false,
|
|
27
|
+
forwardInput: parsed.message,
|
|
28
|
+
turnMode: parsed.mode,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { theme } from '../ui/theme.js';
|
|
2
|
+
const DEFAULT_MAX_TOKENS = 2048;
|
|
3
|
+
const MAX_MODELS = 4;
|
|
4
|
+
const MODEL_STYLERS = [theme.brand, theme.user, theme.assistant, theme.hl];
|
|
5
|
+
export function buildMultiConfig(args) {
|
|
6
|
+
const raw = args.join(' ').trim();
|
|
7
|
+
if (!raw) {
|
|
8
|
+
return { error: multiUsage('Provide 1 to 4 comma-separated model names.') };
|
|
9
|
+
}
|
|
10
|
+
const models = raw
|
|
11
|
+
.split(',')
|
|
12
|
+
.map((model) => model.trim())
|
|
13
|
+
.filter((model) => model.length > 0);
|
|
14
|
+
if (models.length === 0) {
|
|
15
|
+
return { error: multiUsage('Provide 1 to 4 comma-separated model names.') };
|
|
16
|
+
}
|
|
17
|
+
if (models.length > MAX_MODELS) {
|
|
18
|
+
return { error: multiUsage(`You can compare at most ${MAX_MODELS} models at once.`) };
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
models,
|
|
22
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function formatMultiResponses(responses) {
|
|
26
|
+
if (responses.length === 0) {
|
|
27
|
+
return `${theme.warn('No model responses to compare.')}\n`;
|
|
28
|
+
}
|
|
29
|
+
const blocks = responses.map((response, index) => {
|
|
30
|
+
const style = MODEL_STYLERS[index % MODEL_STYLERS.length];
|
|
31
|
+
const header = style(`Model ${index + 1}: ${response.model}`);
|
|
32
|
+
const metrics = theme.dim(`tokens: ${response.tokens.toLocaleString()} • duration: ${formatDuration(response.durationMs)}`);
|
|
33
|
+
return [header, response.content.trim() || theme.dim('(empty response)'), metrics].join('\n');
|
|
34
|
+
});
|
|
35
|
+
return `${theme.brand('Multi-model comparison')}\n\n${blocks.join(`\n\n${theme.dim('─'.repeat(48))}\n\n`)}\n`;
|
|
36
|
+
}
|
|
37
|
+
function multiUsage(reason) {
|
|
38
|
+
return `${theme.warn(reason)}\nusage: /multi <model-a,model-b[,model-c,model-d]>\nexample: /multi gpt-4o,gpt-4o-mini\n`;
|
|
39
|
+
}
|
|
40
|
+
function formatDuration(durationMs) {
|
|
41
|
+
if (durationMs < 1000)
|
|
42
|
+
return `${durationMs}ms`;
|
|
43
|
+
return `${(durationMs / 1000).toFixed(2)}s`;
|
|
44
|
+
}
|