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,60 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
|
5
|
+
export function snippetsDir() {
|
|
6
|
+
return process.env.ICOPILOT_SNIPPETS_DIR || path.join(os.homedir(), '.icopilot', 'snippets');
|
|
7
|
+
}
|
|
8
|
+
export function listSnippets() {
|
|
9
|
+
const dir = snippetsDir();
|
|
10
|
+
if (!fs.existsSync(dir))
|
|
11
|
+
return [];
|
|
12
|
+
return fs
|
|
13
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
14
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
15
|
+
.map((entry) => readSnippet(path.basename(entry.name, '.md')))
|
|
16
|
+
.filter((snippet) => snippet !== null)
|
|
17
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
18
|
+
}
|
|
19
|
+
export function saveSnippet(name, body) {
|
|
20
|
+
assertValidName(name);
|
|
21
|
+
const dir = snippetsDir();
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
const file = snippetPath(name);
|
|
24
|
+
fs.writeFileSync(file, body, 'utf8');
|
|
25
|
+
return snippetFromFile(name, file);
|
|
26
|
+
}
|
|
27
|
+
export function readSnippet(name) {
|
|
28
|
+
assertValidName(name);
|
|
29
|
+
const file = snippetPath(name);
|
|
30
|
+
if (!fs.existsSync(file))
|
|
31
|
+
return null;
|
|
32
|
+
return snippetFromFile(name, file);
|
|
33
|
+
}
|
|
34
|
+
export function deleteSnippet(name) {
|
|
35
|
+
assertValidName(name);
|
|
36
|
+
const file = snippetPath(name);
|
|
37
|
+
if (!fs.existsSync(file))
|
|
38
|
+
return false;
|
|
39
|
+
fs.unlinkSync(file);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
export function expandSnippet(body, vars) {
|
|
43
|
+
return body.replace(/\{\{([a-zA-Z0-9_-]+)\}\}/g, (match, key) => vars[key] ?? match);
|
|
44
|
+
}
|
|
45
|
+
function assertValidName(name) {
|
|
46
|
+
if (!NAME_RE.test(name)) {
|
|
47
|
+
throw new Error('Invalid snippet name. Use 1-64 letters, numbers, underscores, or dashes; start with a letter or number.');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function snippetPath(name) {
|
|
51
|
+
return path.join(snippetsDir(), `${name}.md`);
|
|
52
|
+
}
|
|
53
|
+
function snippetFromFile(name, file) {
|
|
54
|
+
const stat = fs.statSync(file);
|
|
55
|
+
return {
|
|
56
|
+
name,
|
|
57
|
+
body: fs.readFileSync(file, 'utf8'),
|
|
58
|
+
updatedAt: stat.mtime.toISOString(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const DEFAULT_SPACE_CONFIG = {
|
|
4
|
+
tools: [],
|
|
5
|
+
contextFiles: [],
|
|
6
|
+
};
|
|
7
|
+
export const DEFAULT_SPACE_CONFIG_TEMPLATE = serializeSpaceConfig(DEFAULT_SPACE_CONFIG);
|
|
8
|
+
export function loadSpaceConfig(dir) {
|
|
9
|
+
const file = configPath(dir);
|
|
10
|
+
if (!fs.existsSync(file))
|
|
11
|
+
return cloneSpaceConfig(DEFAULT_SPACE_CONFIG);
|
|
12
|
+
try {
|
|
13
|
+
const parsed = parseSpaceConfig(fs.readFileSync(file, 'utf8'));
|
|
14
|
+
return {
|
|
15
|
+
model: parsed.model,
|
|
16
|
+
systemPrompt: parsed.systemPrompt,
|
|
17
|
+
tools: parsed.tools ?? [],
|
|
18
|
+
contextFiles: parsed.contextFiles ?? [],
|
|
19
|
+
maxTokens: parsed.maxTokens,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return cloneSpaceConfig(DEFAULT_SPACE_CONFIG);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function saveSpaceConfig(dir, config) {
|
|
27
|
+
const file = configPath(dir);
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
fs.writeFileSync(file, serializeSpaceConfig(config), 'utf8');
|
|
30
|
+
}
|
|
31
|
+
export function serializeSpaceConfig(config) {
|
|
32
|
+
const lines = [
|
|
33
|
+
`model: ${formatScalar(config.model)}`,
|
|
34
|
+
`systemPrompt: ${formatScalar(config.systemPrompt)}`,
|
|
35
|
+
...formatArray('tools', config.tools),
|
|
36
|
+
...formatArray('contextFiles', config.contextFiles),
|
|
37
|
+
`maxTokens: ${typeof config.maxTokens === 'number' ? String(config.maxTokens) : ''}`,
|
|
38
|
+
];
|
|
39
|
+
return `${lines.join('\n')}\n`;
|
|
40
|
+
}
|
|
41
|
+
function configPath(dir) {
|
|
42
|
+
return path.join(dir, '.icopilot', 'space.yaml');
|
|
43
|
+
}
|
|
44
|
+
function cloneSpaceConfig(config) {
|
|
45
|
+
return {
|
|
46
|
+
model: config.model,
|
|
47
|
+
systemPrompt: config.systemPrompt,
|
|
48
|
+
tools: [...(config.tools ?? [])],
|
|
49
|
+
contextFiles: [...(config.contextFiles ?? [])],
|
|
50
|
+
maxTokens: config.maxTokens,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function parseSpaceConfig(source) {
|
|
54
|
+
const result = cloneSpaceConfig(DEFAULT_SPACE_CONFIG);
|
|
55
|
+
const lines = source.split(/\r?\n/);
|
|
56
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
57
|
+
const rawLine = lines[index];
|
|
58
|
+
const line = rawLine.trim();
|
|
59
|
+
if (!line || line.startsWith('#'))
|
|
60
|
+
continue;
|
|
61
|
+
const match = /^([A-Za-z][A-Za-z0-9]*):\s*(.*)$/.exec(line);
|
|
62
|
+
if (!match)
|
|
63
|
+
continue;
|
|
64
|
+
const [, key, value] = match;
|
|
65
|
+
switch (key) {
|
|
66
|
+
case 'model':
|
|
67
|
+
result.model = parseString(value);
|
|
68
|
+
break;
|
|
69
|
+
case 'systemPrompt':
|
|
70
|
+
result.systemPrompt = parseString(value);
|
|
71
|
+
break;
|
|
72
|
+
case 'maxTokens':
|
|
73
|
+
result.maxTokens = parseNumber(value);
|
|
74
|
+
break;
|
|
75
|
+
case 'tools':
|
|
76
|
+
case 'contextFiles': {
|
|
77
|
+
const { items, nextIndex } = parseArray(lines, index, value);
|
|
78
|
+
result[key] = items;
|
|
79
|
+
index = nextIndex;
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
function parseArray(lines, index, inlineValue) {
|
|
89
|
+
const trimmed = inlineValue.trim();
|
|
90
|
+
if (!trimmed) {
|
|
91
|
+
const items = [];
|
|
92
|
+
let nextIndex = index;
|
|
93
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
94
|
+
const line = lines[cursor];
|
|
95
|
+
if (!line.trim() || line.trim().startsWith('#')) {
|
|
96
|
+
nextIndex = cursor;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!/^\s+-\s+/.test(line))
|
|
100
|
+
break;
|
|
101
|
+
items.push(parseItem(line.replace(/^\s+-\s+/, '')));
|
|
102
|
+
nextIndex = cursor;
|
|
103
|
+
}
|
|
104
|
+
return { items, nextIndex };
|
|
105
|
+
}
|
|
106
|
+
if (trimmed === '[]')
|
|
107
|
+
return { items: [], nextIndex: index };
|
|
108
|
+
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
|
109
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
110
|
+
if (!inner)
|
|
111
|
+
return { items: [], nextIndex: index };
|
|
112
|
+
return {
|
|
113
|
+
items: inner
|
|
114
|
+
.split(',')
|
|
115
|
+
.map((part) => parseItem(part))
|
|
116
|
+
.filter((part) => part.length > 0),
|
|
117
|
+
nextIndex: index,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { items: [parseItem(trimmed)], nextIndex: index };
|
|
121
|
+
}
|
|
122
|
+
function parseItem(value) {
|
|
123
|
+
return parseString(value) ?? '';
|
|
124
|
+
}
|
|
125
|
+
function parseString(value) {
|
|
126
|
+
const trimmed = value.trim();
|
|
127
|
+
if (!trimmed || trimmed === 'null')
|
|
128
|
+
return undefined;
|
|
129
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(trimmed);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return trimmed.slice(1, -1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
|
|
138
|
+
return trimmed.slice(1, -1).replace(/''/g, "'");
|
|
139
|
+
}
|
|
140
|
+
return trimmed;
|
|
141
|
+
}
|
|
142
|
+
function parseNumber(value) {
|
|
143
|
+
const trimmed = value.trim();
|
|
144
|
+
if (!trimmed)
|
|
145
|
+
return undefined;
|
|
146
|
+
const parsed = Number(trimmed);
|
|
147
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
148
|
+
}
|
|
149
|
+
function formatScalar(value) {
|
|
150
|
+
return typeof value === 'string' ? JSON.stringify(value) : '';
|
|
151
|
+
}
|
|
152
|
+
function formatArray(name, values) {
|
|
153
|
+
if (!values || values.length === 0)
|
|
154
|
+
return [`${name}: []`];
|
|
155
|
+
return [name + ':', ...values.map((value) => ` - ${JSON.stringify(value)}`)];
|
|
156
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
import { DEFAULT_SPACE_CONFIG, loadSpaceConfig, saveSpaceConfig } from './space-config.js';
|
|
6
|
+
const SPACES_ENV = 'ICOPILOT_SPACES_DIR';
|
|
7
|
+
const CURRENT_FILE = 'current.txt';
|
|
8
|
+
export class SpaceManager {
|
|
9
|
+
cwdProvider;
|
|
10
|
+
storeDir;
|
|
11
|
+
constructor(cwdProvider = () => config.cwd, storeDir = spacesDir()) {
|
|
12
|
+
this.cwdProvider = cwdProvider;
|
|
13
|
+
this.storeDir = storeDir;
|
|
14
|
+
}
|
|
15
|
+
loadSpace(name) {
|
|
16
|
+
const file = this.spaceFile(name);
|
|
17
|
+
if (!fs.existsSync(file)) {
|
|
18
|
+
throw new Error(`space not found: ${name}`);
|
|
19
|
+
}
|
|
20
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
21
|
+
return normalizeSpace(parsed, name);
|
|
22
|
+
}
|
|
23
|
+
createSpace(name, rootPath) {
|
|
24
|
+
const normalizedName = validateName(name);
|
|
25
|
+
const normalizedRootPath = path.resolve(rootPath);
|
|
26
|
+
const file = this.spaceFile(normalizedName);
|
|
27
|
+
if (fs.existsSync(file)) {
|
|
28
|
+
throw new Error(`space already exists: ${normalizedName}`);
|
|
29
|
+
}
|
|
30
|
+
if (!fs.existsSync(normalizedRootPath) || !fs.statSync(normalizedRootPath).isDirectory()) {
|
|
31
|
+
throw new Error(`space root is not a directory: ${normalizedRootPath}`);
|
|
32
|
+
}
|
|
33
|
+
const space = {
|
|
34
|
+
name: normalizedName,
|
|
35
|
+
rootPath: normalizedRootPath,
|
|
36
|
+
branch: detectGitBranch(normalizedRootPath),
|
|
37
|
+
config: loadSpaceConfig(normalizedRootPath),
|
|
38
|
+
sessions: [],
|
|
39
|
+
};
|
|
40
|
+
fs.mkdirSync(this.storeDir, { recursive: true });
|
|
41
|
+
saveSpaceConfig(normalizedRootPath, space.config);
|
|
42
|
+
this.persistSpace(space);
|
|
43
|
+
this.writeCurrentName(space.name);
|
|
44
|
+
return space;
|
|
45
|
+
}
|
|
46
|
+
switchSpace(name) {
|
|
47
|
+
const space = this.loadSpace(name);
|
|
48
|
+
this.writeCurrentName(space.name);
|
|
49
|
+
config.cwd = space.rootPath;
|
|
50
|
+
}
|
|
51
|
+
listSpaces() {
|
|
52
|
+
if (!fs.existsSync(this.storeDir))
|
|
53
|
+
return [];
|
|
54
|
+
return fs
|
|
55
|
+
.readdirSync(this.storeDir)
|
|
56
|
+
.filter((entry) => entry.endsWith('.json'))
|
|
57
|
+
.map((entry) => {
|
|
58
|
+
const file = path.join(this.storeDir, entry);
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
61
|
+
return normalizeSpace(parsed, decodeURIComponent(path.basename(entry, '.json')));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
.filter((space) => space !== null)
|
|
68
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
69
|
+
}
|
|
70
|
+
currentSpace() {
|
|
71
|
+
const detected = this.detectSpaceFromCwd(this.cwdProvider());
|
|
72
|
+
if (detected)
|
|
73
|
+
return detected;
|
|
74
|
+
const name = this.readCurrentName();
|
|
75
|
+
if (!name)
|
|
76
|
+
return null;
|
|
77
|
+
try {
|
|
78
|
+
return this.loadSpace(name);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
deleteSpace(name) {
|
|
85
|
+
const normalizedName = validateName(name);
|
|
86
|
+
const file = this.spaceFile(normalizedName);
|
|
87
|
+
if (!fs.existsSync(file)) {
|
|
88
|
+
throw new Error(`space not found: ${normalizedName}`);
|
|
89
|
+
}
|
|
90
|
+
fs.rmSync(file, { force: true });
|
|
91
|
+
if (this.readCurrentName() === normalizedName) {
|
|
92
|
+
const currentFile = this.currentFile();
|
|
93
|
+
if (fs.existsSync(currentFile))
|
|
94
|
+
fs.rmSync(currentFile, { force: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
detectSpaceFromCwd(cwd) {
|
|
98
|
+
const normalizedCwd = path.resolve(cwd);
|
|
99
|
+
const matches = this.listSpaces().filter((space) => isWithinRoot(normalizedCwd, space.rootPath));
|
|
100
|
+
if (!matches.length)
|
|
101
|
+
return null;
|
|
102
|
+
return matches.sort((left, right) => right.rootPath.length - left.rootPath.length)[0] ?? null;
|
|
103
|
+
}
|
|
104
|
+
persistSpace(space) {
|
|
105
|
+
fs.mkdirSync(this.storeDir, { recursive: true });
|
|
106
|
+
fs.writeFileSync(this.spaceFile(space.name), `${JSON.stringify(space, null, 2)}\n`, 'utf8');
|
|
107
|
+
}
|
|
108
|
+
spaceFile(name) {
|
|
109
|
+
return path.join(this.storeDir, `${encodeURIComponent(validateName(name))}.json`);
|
|
110
|
+
}
|
|
111
|
+
currentFile() {
|
|
112
|
+
return path.join(this.storeDir, CURRENT_FILE);
|
|
113
|
+
}
|
|
114
|
+
readCurrentName() {
|
|
115
|
+
const file = this.currentFile();
|
|
116
|
+
if (!fs.existsSync(file))
|
|
117
|
+
return null;
|
|
118
|
+
const name = fs.readFileSync(file, 'utf8').trim();
|
|
119
|
+
return name || null;
|
|
120
|
+
}
|
|
121
|
+
writeCurrentName(name) {
|
|
122
|
+
fs.mkdirSync(this.storeDir, { recursive: true });
|
|
123
|
+
fs.writeFileSync(this.currentFile(), `${name}\n`, 'utf8');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export function spacesDir() {
|
|
127
|
+
return process.env[SPACES_ENV] || path.join(os.homedir(), '.icopilot', 'spaces');
|
|
128
|
+
}
|
|
129
|
+
function validateName(name) {
|
|
130
|
+
const trimmed = name.trim();
|
|
131
|
+
if (!trimmed) {
|
|
132
|
+
throw new Error('space name is required');
|
|
133
|
+
}
|
|
134
|
+
return trimmed;
|
|
135
|
+
}
|
|
136
|
+
function normalizeSpace(raw, fallbackName) {
|
|
137
|
+
if (!raw || typeof raw !== 'object') {
|
|
138
|
+
throw new Error(`invalid space definition: ${fallbackName ?? 'unknown'}`);
|
|
139
|
+
}
|
|
140
|
+
const record = raw;
|
|
141
|
+
const name = typeof record.name === 'string' && record.name.trim() ? record.name.trim() : fallbackName;
|
|
142
|
+
const rootPath = typeof record.rootPath === 'string' ? path.resolve(record.rootPath) : '';
|
|
143
|
+
if (!name || !rootPath) {
|
|
144
|
+
throw new Error(`invalid space definition: ${fallbackName ?? 'unknown'}`);
|
|
145
|
+
}
|
|
146
|
+
const savedConfig = readConfig(record.config);
|
|
147
|
+
const fileConfig = loadSpaceConfig(rootPath);
|
|
148
|
+
return {
|
|
149
|
+
name,
|
|
150
|
+
rootPath,
|
|
151
|
+
branch: typeof record.branch === 'string' && record.branch.trim()
|
|
152
|
+
? record.branch
|
|
153
|
+
: detectGitBranch(rootPath),
|
|
154
|
+
config: {
|
|
155
|
+
model: fileConfig.model ?? savedConfig.model,
|
|
156
|
+
systemPrompt: fileConfig.systemPrompt ?? savedConfig.systemPrompt,
|
|
157
|
+
tools: fileConfig.tools ?? savedConfig.tools,
|
|
158
|
+
contextFiles: fileConfig.contextFiles ?? savedConfig.contextFiles,
|
|
159
|
+
maxTokens: fileConfig.maxTokens ?? savedConfig.maxTokens,
|
|
160
|
+
},
|
|
161
|
+
sessions: Array.isArray(record.sessions)
|
|
162
|
+
? record.sessions.filter((value) => typeof value === 'string')
|
|
163
|
+
: [],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function readConfig(raw) {
|
|
167
|
+
if (!raw || typeof raw !== 'object') {
|
|
168
|
+
return {
|
|
169
|
+
...DEFAULT_SPACE_CONFIG,
|
|
170
|
+
tools: [...(DEFAULT_SPACE_CONFIG.tools ?? [])],
|
|
171
|
+
contextFiles: [...(DEFAULT_SPACE_CONFIG.contextFiles ?? [])],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const record = raw;
|
|
175
|
+
return {
|
|
176
|
+
model: typeof record.model === 'string' ? record.model : undefined,
|
|
177
|
+
systemPrompt: typeof record.systemPrompt === 'string' ? record.systemPrompt : undefined,
|
|
178
|
+
tools: Array.isArray(record.tools)
|
|
179
|
+
? record.tools.filter((value) => typeof value === 'string')
|
|
180
|
+
: [],
|
|
181
|
+
contextFiles: Array.isArray(record.contextFiles)
|
|
182
|
+
? record.contextFiles.filter((value) => typeof value === 'string')
|
|
183
|
+
: [],
|
|
184
|
+
maxTokens: typeof record.maxTokens === 'number' ? record.maxTokens : undefined,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function isWithinRoot(cwd, rootPath) {
|
|
188
|
+
const relative = path.relative(rootPath, cwd);
|
|
189
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
190
|
+
}
|
|
191
|
+
function detectGitBranch(rootPath) {
|
|
192
|
+
const gitDir = resolveGitDir(rootPath);
|
|
193
|
+
if (!gitDir)
|
|
194
|
+
return undefined;
|
|
195
|
+
try {
|
|
196
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
|
|
197
|
+
const match = /^ref:\s+refs\/heads\/(.+)$/.exec(head);
|
|
198
|
+
return match?.[1];
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function resolveGitDir(rootPath) {
|
|
205
|
+
const gitPath = path.join(rootPath, '.git');
|
|
206
|
+
if (!fs.existsSync(gitPath))
|
|
207
|
+
return null;
|
|
208
|
+
if (fs.statSync(gitPath).isDirectory())
|
|
209
|
+
return gitPath;
|
|
210
|
+
try {
|
|
211
|
+
const content = fs.readFileSync(gitPath, 'utf8').trim();
|
|
212
|
+
const match = /^gitdir:\s+(.+)$/i.exec(content);
|
|
213
|
+
if (!match)
|
|
214
|
+
return null;
|
|
215
|
+
return path.resolve(rootPath, match[1].trim());
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
let defaultFirstSeen;
|
|
5
|
+
export function statsPath() {
|
|
6
|
+
return process.env.ICOPILOT_STATS_PATH || path.join(os.homedir(), '.icopilot', 'stats.json');
|
|
7
|
+
}
|
|
8
|
+
export function loadStats() {
|
|
9
|
+
const file = statsPath();
|
|
10
|
+
if (fs.existsSync(file)) {
|
|
11
|
+
try {
|
|
12
|
+
return normalizeStats(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return defaultStats();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return defaultStats();
|
|
19
|
+
}
|
|
20
|
+
export function saveStats(s) {
|
|
21
|
+
const file = statsPath();
|
|
22
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
23
|
+
fs.writeFileSync(file, `${JSON.stringify(normalizeStats(s), null, 2)}\n`, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
export function recordTokens(input, output) {
|
|
26
|
+
const s = touch(loadStats());
|
|
27
|
+
s.tokensIn += count(input);
|
|
28
|
+
s.tokensOut += count(output);
|
|
29
|
+
saveStats(s);
|
|
30
|
+
return s;
|
|
31
|
+
}
|
|
32
|
+
export function recordToolCall(name) {
|
|
33
|
+
const s = touch(loadStats());
|
|
34
|
+
increment(s.toolCalls, name);
|
|
35
|
+
saveStats(s);
|
|
36
|
+
return s;
|
|
37
|
+
}
|
|
38
|
+
export function recordCommand(name) {
|
|
39
|
+
const s = touch(loadStats());
|
|
40
|
+
increment(s.commands, name);
|
|
41
|
+
saveStats(s);
|
|
42
|
+
return s;
|
|
43
|
+
}
|
|
44
|
+
export function recordSession() {
|
|
45
|
+
const s = touch(loadStats());
|
|
46
|
+
s.sessions += 1;
|
|
47
|
+
saveStats(s);
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
export function resetStats() {
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
saveStats(zeroStats(now));
|
|
53
|
+
}
|
|
54
|
+
function defaultStats() {
|
|
55
|
+
defaultFirstSeen ??= new Date().toISOString();
|
|
56
|
+
return zeroStats(defaultFirstSeen);
|
|
57
|
+
}
|
|
58
|
+
function zeroStats(now) {
|
|
59
|
+
return {
|
|
60
|
+
firstSeen: now,
|
|
61
|
+
lastUpdate: now,
|
|
62
|
+
tokensIn: 0,
|
|
63
|
+
tokensOut: 0,
|
|
64
|
+
toolCalls: {},
|
|
65
|
+
commands: {},
|
|
66
|
+
sessions: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function normalizeStats(value) {
|
|
70
|
+
const source = typeof value === 'object' && value !== null ? value : {};
|
|
71
|
+
const fallback = defaultStats();
|
|
72
|
+
return {
|
|
73
|
+
firstSeen: typeof source.firstSeen === 'string' ? source.firstSeen : fallback.firstSeen,
|
|
74
|
+
lastUpdate: typeof source.lastUpdate === 'string' ? source.lastUpdate : fallback.lastUpdate,
|
|
75
|
+
tokensIn: count(source.tokensIn),
|
|
76
|
+
tokensOut: count(source.tokensOut),
|
|
77
|
+
toolCalls: normalizeCounter(source.toolCalls),
|
|
78
|
+
commands: normalizeCounter(source.commands),
|
|
79
|
+
sessions: count(source.sessions),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function normalizeCounter(value) {
|
|
83
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
84
|
+
return {};
|
|
85
|
+
return Object.fromEntries(Object.entries(value)
|
|
86
|
+
.filter(([key]) => key.length > 0)
|
|
87
|
+
.map(([key, val]) => [key, count(val)]));
|
|
88
|
+
}
|
|
89
|
+
function touch(s) {
|
|
90
|
+
s.lastUpdate = new Date().toISOString();
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
function increment(target, name) {
|
|
94
|
+
const key = name.trim();
|
|
95
|
+
if (!key)
|
|
96
|
+
return;
|
|
97
|
+
target[key] = (target[key] || 0) + 1;
|
|
98
|
+
}
|
|
99
|
+
function count(value) {
|
|
100
|
+
return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
|
101
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { checkbox } from '@inquirer/prompts';
|
|
4
|
+
import { applyPatch, parsePatch } from 'diff';
|
|
5
|
+
import { config } from '../config.js';
|
|
6
|
+
import { hookManager } from '../hooks/lifecycle.js';
|
|
7
|
+
import { theme } from '../ui/theme.js';
|
|
8
|
+
import { formatAutoCheckResult, runAutoLint } from './auto-check.js';
|
|
9
|
+
import { loadPolicy, writePathAllowed } from './policy.js';
|
|
10
|
+
import { assertSandbox } from './sandbox.js';
|
|
11
|
+
import { toolMemory } from './memory.js';
|
|
12
|
+
import { ensureWriteAllowed } from './file-ops.js';
|
|
13
|
+
export async function applyPatchTool(args) {
|
|
14
|
+
const parsed = parsePatch(args.patch);
|
|
15
|
+
process.stdout.write('\n' + theme.badge('PATCH') + '\n');
|
|
16
|
+
process.stdout.write(colorizePatch(args.patch) + '\n');
|
|
17
|
+
const choices = parsed.flatMap((filePatch, fileIndex) => filePatch.hunks.map((hunk, hunkIndex) => ({
|
|
18
|
+
name: `${displayPath(filePatch)} @@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines}`,
|
|
19
|
+
value: `${fileIndex}:${hunkIndex}`,
|
|
20
|
+
checked: true,
|
|
21
|
+
})));
|
|
22
|
+
const selected = new Set(choices.length
|
|
23
|
+
? await checkbox({
|
|
24
|
+
message: 'Select hunks to apply',
|
|
25
|
+
choices,
|
|
26
|
+
}).catch(() => [])
|
|
27
|
+
: []);
|
|
28
|
+
const policy = loadPolicy(config.cwd);
|
|
29
|
+
const applied = [];
|
|
30
|
+
const skipped = [];
|
|
31
|
+
const errors = [];
|
|
32
|
+
for (let fileIndex = 0; fileIndex < parsed.length; fileIndex += 1) {
|
|
33
|
+
const filePatch = parsed[fileIndex];
|
|
34
|
+
if (!filePatch)
|
|
35
|
+
continue;
|
|
36
|
+
const selectedHunks = filePatch.hunks
|
|
37
|
+
.map((hunk, hunkIndex) => ({ hunk, hunkIndex }))
|
|
38
|
+
.filter(({ hunkIndex }) => selected.has(`${fileIndex}:${hunkIndex}`));
|
|
39
|
+
const unselected = filePatch.hunks
|
|
40
|
+
.map((_, hunkIndex) => hunkIndex)
|
|
41
|
+
.filter((hunkIndex) => !selected.has(`${fileIndex}:${hunkIndex}`));
|
|
42
|
+
if (unselected.length) {
|
|
43
|
+
skipped.push({ path: displayPath(filePatch), hunks: unselected, reason: 'not selected' });
|
|
44
|
+
}
|
|
45
|
+
if (!selectedHunks.length)
|
|
46
|
+
continue;
|
|
47
|
+
const relPath = normalizePatchPath(displayPath(filePatch));
|
|
48
|
+
const abs = path.resolve(config.cwd, relPath);
|
|
49
|
+
try {
|
|
50
|
+
const denied = ensureWriteAllowed(abs);
|
|
51
|
+
if (denied) {
|
|
52
|
+
skipped.push({
|
|
53
|
+
path: relPath,
|
|
54
|
+
hunks: selectedHunks.map(({ hunkIndex }) => hunkIndex),
|
|
55
|
+
reason: denied,
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
assertSandbox(abs, config.cwd);
|
|
60
|
+
if (!toolMemory.isWriteRemembered(abs) && !writePathAllowed(abs, policy, config.cwd)) {
|
|
61
|
+
skipped.push({
|
|
62
|
+
path: relPath,
|
|
63
|
+
hunks: selectedHunks.map(({ hunkIndex }) => hunkIndex),
|
|
64
|
+
reason: 'policy denied',
|
|
65
|
+
});
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const oldContent = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
|
|
69
|
+
const partial = clonePatchWithHunks(filePatch, selectedHunks.map(({ hunk }) => hunk));
|
|
70
|
+
const next = applyPatch(oldContent, partial);
|
|
71
|
+
if (next === false) {
|
|
72
|
+
errors.push({ path: relPath, error: 'patch did not apply' });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
76
|
+
fs.writeFileSync(abs, next, 'utf8');
|
|
77
|
+
await hookManager.emit('fileChanged', {
|
|
78
|
+
cwd: config.cwd,
|
|
79
|
+
path: relPath,
|
|
80
|
+
absolutePath: abs,
|
|
81
|
+
});
|
|
82
|
+
applied.push({ path: relPath, hunks: selectedHunks.map(({ hunkIndex }) => hunkIndex) });
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
errors.push({ path: relPath, error: e?.message || String(e) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const changedFiles = applied.map((entry) => entry.path);
|
|
89
|
+
const autoLint = errors.length === 0 ? await maybeRunAutoLint(changedFiles) : undefined;
|
|
90
|
+
return JSON.stringify({ applied, skipped, errors, ...(autoLint ? { autoLint } : {}) });
|
|
91
|
+
}
|
|
92
|
+
function clonePatchWithHunks(filePatch, hunks) {
|
|
93
|
+
return {
|
|
94
|
+
...filePatch,
|
|
95
|
+
hunks: hunks.map((hunk) => ({
|
|
96
|
+
...hunk,
|
|
97
|
+
lines: [...hunk.lines],
|
|
98
|
+
linedelimiters: hunk.linedelimiters ? [...hunk.linedelimiters] : undefined,
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function displayPath(filePatch) {
|
|
103
|
+
return filePatch.newFileName && filePatch.newFileName !== '/dev/null'
|
|
104
|
+
? filePatch.newFileName
|
|
105
|
+
: filePatch.oldFileName || '';
|
|
106
|
+
}
|
|
107
|
+
function normalizePatchPath(file) {
|
|
108
|
+
return file.replace(/\\/g, '/').replace(/^(a|b)\//, '');
|
|
109
|
+
}
|
|
110
|
+
function colorizePatch(p) {
|
|
111
|
+
return p
|
|
112
|
+
.split('\n')
|
|
113
|
+
.map((l) => {
|
|
114
|
+
if (l.startsWith('+++') || l.startsWith('---'))
|
|
115
|
+
return theme.dim(l);
|
|
116
|
+
if (l.startsWith('+'))
|
|
117
|
+
return theme.ok(l);
|
|
118
|
+
if (l.startsWith('-'))
|
|
119
|
+
return theme.err(l);
|
|
120
|
+
if (l.startsWith('@@'))
|
|
121
|
+
return theme.hl(l);
|
|
122
|
+
return l;
|
|
123
|
+
})
|
|
124
|
+
.join('\n');
|
|
125
|
+
}
|
|
126
|
+
async function maybeRunAutoLint(changedFiles) {
|
|
127
|
+
if (!config.autoLint || changedFiles.length === 0)
|
|
128
|
+
return undefined;
|
|
129
|
+
const result = await runAutoLint(changedFiles);
|
|
130
|
+
if (!config.quiet && !config.jsonOutput) {
|
|
131
|
+
process.stdout.write(`${theme.dim(formatAutoCheckResult('lint', result, changedFiles))}\n`);
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
}
|