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,296 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
import { GitContextProvider } from '../context/git-context.js';
|
|
6
|
+
import { hookManager } from '../hooks/lifecycle.js';
|
|
7
|
+
import { countTokensSync } from '../util/tokens.js';
|
|
8
|
+
export class Session {
|
|
9
|
+
state;
|
|
10
|
+
file;
|
|
11
|
+
constructor(init) {
|
|
12
|
+
const id = init?.id || randomUUID();
|
|
13
|
+
this.state = {
|
|
14
|
+
id,
|
|
15
|
+
createdAt: init?.createdAt || new Date().toISOString(),
|
|
16
|
+
model: init?.model || config.defaultModel,
|
|
17
|
+
mode: init?.mode || 'ask',
|
|
18
|
+
cwd: init?.cwd || config.cwd,
|
|
19
|
+
messages: init?.messages || [],
|
|
20
|
+
todos: Array.isArray(init?.todos) ? [...init.todos] : [],
|
|
21
|
+
autopilotEnabled: Boolean(init?.autopilotEnabled),
|
|
22
|
+
systemPrompt: typeof init?.systemPrompt === 'string' ? init.systemPrompt : undefined,
|
|
23
|
+
pinned: normalizePinnedFiles(init?.pinned),
|
|
24
|
+
gitContext: normalizeGitContext(init?.gitContext),
|
|
25
|
+
changeTracking: normalizeChangeTracking(init?.changeTracking),
|
|
26
|
+
};
|
|
27
|
+
fs.mkdirSync(config.sessionDir, { recursive: true });
|
|
28
|
+
this.file = path.join(config.sessionDir, `${id}.json`);
|
|
29
|
+
}
|
|
30
|
+
static list() {
|
|
31
|
+
try {
|
|
32
|
+
if (!fs.existsSync(config.sessionDir))
|
|
33
|
+
return [];
|
|
34
|
+
return fs
|
|
35
|
+
.readdirSync(config.sessionDir)
|
|
36
|
+
.filter((name) => name.endsWith('.json'))
|
|
37
|
+
.map((name) => {
|
|
38
|
+
const file = path.join(config.sessionDir, name);
|
|
39
|
+
const stat = fs.statSync(file);
|
|
40
|
+
let state = {};
|
|
41
|
+
try {
|
|
42
|
+
state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* keep best-effort listing */
|
|
46
|
+
}
|
|
47
|
+
const id = String(state.id || path.basename(name, '.json'));
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
file,
|
|
51
|
+
createdAt: String(state.createdAt || stat.birthtime.toISOString()),
|
|
52
|
+
mtime: stat.mtime,
|
|
53
|
+
model: String(state.model || config.defaultModel),
|
|
54
|
+
messageCount: Array.isArray(state.messages) ? state.messages.length : 0,
|
|
55
|
+
};
|
|
56
|
+
})
|
|
57
|
+
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
static load(id) {
|
|
64
|
+
const safeId = path.basename(id, '.json');
|
|
65
|
+
const file = path.join(config.sessionDir, `${safeId}.json`);
|
|
66
|
+
const state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
67
|
+
return new Session(state);
|
|
68
|
+
}
|
|
69
|
+
push(msg) {
|
|
70
|
+
this.state.messages.push(msg);
|
|
71
|
+
this.persist();
|
|
72
|
+
}
|
|
73
|
+
reset() {
|
|
74
|
+
this.state.messages = [];
|
|
75
|
+
this.persist();
|
|
76
|
+
}
|
|
77
|
+
setModel(m) {
|
|
78
|
+
this.state.model = m;
|
|
79
|
+
this.persist();
|
|
80
|
+
}
|
|
81
|
+
setMode(m) {
|
|
82
|
+
this.state.mode = m;
|
|
83
|
+
this.persist();
|
|
84
|
+
}
|
|
85
|
+
setCwd(p) {
|
|
86
|
+
const previousCwd = this.state.cwd;
|
|
87
|
+
this.state.cwd = p;
|
|
88
|
+
config.cwd = p;
|
|
89
|
+
this.persist();
|
|
90
|
+
if (previousCwd !== p) {
|
|
91
|
+
void hookManager.loadHooks(p).then(() => hookManager.emit('cwdChanged', {
|
|
92
|
+
sessionId: this.state.id,
|
|
93
|
+
previousCwd,
|
|
94
|
+
newCwd: p,
|
|
95
|
+
source: 'session',
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
setAutopilotEnabled(enabled) {
|
|
100
|
+
this.state.autopilotEnabled = enabled;
|
|
101
|
+
this.persist();
|
|
102
|
+
}
|
|
103
|
+
setSystemPrompt(prompt) {
|
|
104
|
+
this.state.systemPrompt = prompt?.trim() ? prompt : undefined;
|
|
105
|
+
this.persist();
|
|
106
|
+
}
|
|
107
|
+
setTodos(todos) {
|
|
108
|
+
this.state.todos = todos.map((todo) => ({ ...todo }));
|
|
109
|
+
this.persist();
|
|
110
|
+
}
|
|
111
|
+
setPinned(files) {
|
|
112
|
+
this.state.pinned = files.map((file) => ({ ...file }));
|
|
113
|
+
this.persist();
|
|
114
|
+
}
|
|
115
|
+
setGitContext(files) {
|
|
116
|
+
this.state.gitContext = files.map((file) => ({ ...file }));
|
|
117
|
+
this.persist();
|
|
118
|
+
}
|
|
119
|
+
async initializeGitContext(provider = new GitContextProvider(this.state.cwd)) {
|
|
120
|
+
const files = await provider.getSessionContextFiles().catch(() => []);
|
|
121
|
+
this.state.gitContext = files.map((file) => ({ ...file }));
|
|
122
|
+
this.persist();
|
|
123
|
+
return this.state.gitContext;
|
|
124
|
+
}
|
|
125
|
+
persist() {
|
|
126
|
+
try {
|
|
127
|
+
fs.writeFileSync(this.file, JSON.stringify(this.state, null, 2), 'utf8');
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* ignore persistence errors */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Total token usage estimate over current message history. */
|
|
134
|
+
tokenUsage() {
|
|
135
|
+
let total = 0;
|
|
136
|
+
for (const m of this.state.messages) {
|
|
137
|
+
const c = typeof m.content === 'string'
|
|
138
|
+
? m.content
|
|
139
|
+
: Array.isArray(m.content)
|
|
140
|
+
? m.content.map((p) => (typeof p?.text === 'string' ? p.text : '')).join('\n')
|
|
141
|
+
: '';
|
|
142
|
+
if (c) {
|
|
143
|
+
try {
|
|
144
|
+
total += countTokensSync(c);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
total += Math.ceil(c.length / 4);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return total;
|
|
152
|
+
}
|
|
153
|
+
toJSON() {
|
|
154
|
+
return JSON.stringify(this.state, null, 2);
|
|
155
|
+
}
|
|
156
|
+
toMarkdown() {
|
|
157
|
+
const lines = [
|
|
158
|
+
`# iCopilot session ${this.state.id}`,
|
|
159
|
+
'',
|
|
160
|
+
`- Created: ${this.state.createdAt}`,
|
|
161
|
+
`- Model: ${this.state.model}`,
|
|
162
|
+
`- CWD: ${this.state.cwd}`,
|
|
163
|
+
`- Messages: ${this.state.messages.length}`,
|
|
164
|
+
'',
|
|
165
|
+
];
|
|
166
|
+
for (const message of this.state.messages) {
|
|
167
|
+
const role = String(message.role || 'message');
|
|
168
|
+
const name = role === 'tool'
|
|
169
|
+
? ` ${message.tool_call_id || ''}`.trimEnd()
|
|
170
|
+
: message.name
|
|
171
|
+
? ` ${message.name}`
|
|
172
|
+
: '';
|
|
173
|
+
lines.push(`## ${role}${name}`, '');
|
|
174
|
+
const content = contentToText(message.content);
|
|
175
|
+
if (content.trim())
|
|
176
|
+
lines.push(content.trim());
|
|
177
|
+
const toolCalls = message.tool_calls;
|
|
178
|
+
if (Array.isArray(toolCalls) && toolCalls.length) {
|
|
179
|
+
lines.push('', '```json');
|
|
180
|
+
lines.push(JSON.stringify(toolCalls, null, 2));
|
|
181
|
+
lines.push('```');
|
|
182
|
+
}
|
|
183
|
+
lines.push('');
|
|
184
|
+
}
|
|
185
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
186
|
+
}
|
|
187
|
+
shouldAutoSummarize(threshold = 0.85) {
|
|
188
|
+
return this.tokenUsage() / config.contextWindow > threshold;
|
|
189
|
+
}
|
|
190
|
+
/** Replace history with a compacted summary message. */
|
|
191
|
+
compactInto(summary) {
|
|
192
|
+
this.state.messages = [
|
|
193
|
+
{
|
|
194
|
+
role: 'system',
|
|
195
|
+
content: `Conversation summary (auto-compacted to save tokens):\n\n${summary}`,
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
this.persist();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function contentToText(content) {
|
|
202
|
+
if (typeof content === 'string')
|
|
203
|
+
return content;
|
|
204
|
+
if (Array.isArray(content)) {
|
|
205
|
+
return content
|
|
206
|
+
.map((part) => {
|
|
207
|
+
if (typeof part?.text === 'string')
|
|
208
|
+
return part.text;
|
|
209
|
+
if (typeof part?.type === 'string')
|
|
210
|
+
return JSON.stringify(part);
|
|
211
|
+
return '';
|
|
212
|
+
})
|
|
213
|
+
.filter(Boolean)
|
|
214
|
+
.join('\n');
|
|
215
|
+
}
|
|
216
|
+
if (content == null)
|
|
217
|
+
return '';
|
|
218
|
+
return JSON.stringify(content, null, 2);
|
|
219
|
+
}
|
|
220
|
+
function normalizePinnedFiles(data) {
|
|
221
|
+
if (!Array.isArray(data))
|
|
222
|
+
return [];
|
|
223
|
+
return data.flatMap((entry) => {
|
|
224
|
+
if (!entry || typeof entry !== 'object')
|
|
225
|
+
return [];
|
|
226
|
+
const candidate = entry;
|
|
227
|
+
if (typeof candidate.path !== 'string' ||
|
|
228
|
+
typeof candidate.addedAt !== 'string' ||
|
|
229
|
+
typeof candidate.tokens !== 'number') {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
return [
|
|
233
|
+
{
|
|
234
|
+
path: candidate.path,
|
|
235
|
+
addedAt: candidate.addedAt,
|
|
236
|
+
tokens: candidate.tokens,
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function normalizeGitContext(data) {
|
|
242
|
+
if (!Array.isArray(data))
|
|
243
|
+
return [];
|
|
244
|
+
return data.flatMap((entry) => {
|
|
245
|
+
if (!entry || typeof entry !== 'object')
|
|
246
|
+
return [];
|
|
247
|
+
const candidate = entry;
|
|
248
|
+
if (typeof candidate.path !== 'string')
|
|
249
|
+
return [];
|
|
250
|
+
if (candidate.status !== 'added' &&
|
|
251
|
+
candidate.status !== 'modified' &&
|
|
252
|
+
candidate.status !== 'deleted') {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
return [
|
|
256
|
+
{
|
|
257
|
+
path: candidate.path,
|
|
258
|
+
status: candidate.status,
|
|
259
|
+
diff: typeof candidate.diff === 'string' ? candidate.diff : undefined,
|
|
260
|
+
},
|
|
261
|
+
];
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function normalizeChangeTracking(data) {
|
|
265
|
+
if (!data || typeof data !== 'object')
|
|
266
|
+
return undefined;
|
|
267
|
+
const candidate = data;
|
|
268
|
+
if (typeof candidate.sessionStartRef !== 'string' ||
|
|
269
|
+
typeof candidate.sessionStartAt !== 'string' ||
|
|
270
|
+
!Array.isArray(candidate.turnSnapshots)) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
const turnSnapshots = candidate.turnSnapshots.flatMap((entry) => {
|
|
274
|
+
if (!entry || typeof entry !== 'object')
|
|
275
|
+
return [];
|
|
276
|
+
const snapshot = entry;
|
|
277
|
+
if (typeof snapshot.turnIndex !== 'number' ||
|
|
278
|
+
!Number.isInteger(snapshot.turnIndex) ||
|
|
279
|
+
typeof snapshot.ref !== 'string' ||
|
|
280
|
+
typeof snapshot.createdAt !== 'string') {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
return [
|
|
284
|
+
{
|
|
285
|
+
turnIndex: snapshot.turnIndex,
|
|
286
|
+
ref: snapshot.ref,
|
|
287
|
+
createdAt: snapshot.createdAt,
|
|
288
|
+
},
|
|
289
|
+
];
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
sessionStartRef: candidate.sessionStartRef,
|
|
293
|
+
sessionStartAt: candidate.sessionStartAt,
|
|
294
|
+
turnSnapshots,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Session } from './session.js';
|
|
4
|
+
import { theme } from '../ui/theme.js';
|
|
5
|
+
const SHARE_VERSION = 1;
|
|
6
|
+
const DEFAULT_TITLE_PREFIX = 'Shared session';
|
|
7
|
+
export function exportSessionBundle(session) {
|
|
8
|
+
const bundle = {
|
|
9
|
+
version: SHARE_VERSION,
|
|
10
|
+
id: session.state.id,
|
|
11
|
+
title: deriveTitle(session.state),
|
|
12
|
+
model: session.state.model,
|
|
13
|
+
messages: clone(session.state.messages),
|
|
14
|
+
metadata: {
|
|
15
|
+
cwd: session.state.cwd,
|
|
16
|
+
tokensUsed: session.tokenUsage(),
|
|
17
|
+
messageCount: session.state.messages.length,
|
|
18
|
+
createdAt: session.state.createdAt,
|
|
19
|
+
duration: formatDuration(session.state.createdAt),
|
|
20
|
+
},
|
|
21
|
+
exportedAt: new Date().toISOString(),
|
|
22
|
+
mode: session.state.mode,
|
|
23
|
+
todos: cloneTodos(session.state.todos),
|
|
24
|
+
pinned: clonePinned(session.state.pinned),
|
|
25
|
+
};
|
|
26
|
+
return bundle;
|
|
27
|
+
}
|
|
28
|
+
export function importSessionBundle(data) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
|
|
31
|
+
const bundle = validateBundle(parsed);
|
|
32
|
+
const session = new Session({
|
|
33
|
+
createdAt: bundle.metadata.createdAt,
|
|
34
|
+
model: bundle.model,
|
|
35
|
+
mode: bundle.mode === 'plan' ? 'plan' : 'ask',
|
|
36
|
+
cwd: bundle.metadata.cwd,
|
|
37
|
+
messages: clone(bundle.messages),
|
|
38
|
+
todos: cloneTodos(bundle.todos),
|
|
39
|
+
pinned: clonePinned(bundle.pinned),
|
|
40
|
+
});
|
|
41
|
+
session.persist();
|
|
42
|
+
return { success: true, sessionId: session.state.id };
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
const message = error instanceof Error ? error.message : 'Invalid session bundle.';
|
|
46
|
+
return { success: false, error: message };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function sessionToMarkdown(session) {
|
|
50
|
+
const bundle = exportSessionBundle(session);
|
|
51
|
+
const lines = [
|
|
52
|
+
`# ${bundle.title}`,
|
|
53
|
+
'',
|
|
54
|
+
'## Metadata',
|
|
55
|
+
'',
|
|
56
|
+
`- Session ID: ${bundle.id}`,
|
|
57
|
+
`- Exported: ${bundle.exportedAt}`,
|
|
58
|
+
`- Created: ${bundle.metadata.createdAt}`,
|
|
59
|
+
`- Duration: ${bundle.metadata.duration}`,
|
|
60
|
+
`- Model: ${bundle.model}`,
|
|
61
|
+
`- Working directory: ${bundle.metadata.cwd}`,
|
|
62
|
+
`- Tokens used: ${bundle.metadata.tokensUsed}`,
|
|
63
|
+
`- Message count: ${bundle.metadata.messageCount}`,
|
|
64
|
+
'',
|
|
65
|
+
'## Conversation',
|
|
66
|
+
'',
|
|
67
|
+
];
|
|
68
|
+
bundle.messages.forEach((message, index) => {
|
|
69
|
+
const role = typeof message?.role === 'string' ? message.role : 'message';
|
|
70
|
+
const suffix = role === 'tool'
|
|
71
|
+
? ` ${typeof message?.tool_call_id === 'string' ? message.tool_call_id : ''}`.trimEnd()
|
|
72
|
+
: typeof message?.name === 'string'
|
|
73
|
+
? ` ${message.name}`
|
|
74
|
+
: '';
|
|
75
|
+
lines.push(`### ${index + 1}. ${role}${suffix}`, '');
|
|
76
|
+
const content = contentToText(message?.content).trim();
|
|
77
|
+
lines.push(content || '_[no content]_');
|
|
78
|
+
if (Array.isArray(message?.tool_calls) && message.tool_calls.length) {
|
|
79
|
+
lines.push('', '#### Tool calls', '', '```json', JSON.stringify(message.tool_calls, null, 2), '```');
|
|
80
|
+
}
|
|
81
|
+
lines.push('');
|
|
82
|
+
});
|
|
83
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
84
|
+
}
|
|
85
|
+
export function sessionToClipboard(session) {
|
|
86
|
+
const bundle = exportSessionBundle(session);
|
|
87
|
+
const lines = [
|
|
88
|
+
`[iCopilot] ${bundle.title}`,
|
|
89
|
+
`session=${bundle.id} model=${bundle.model} messages=${bundle.metadata.messageCount} tokens=${bundle.metadata.tokensUsed}`,
|
|
90
|
+
];
|
|
91
|
+
bundle.messages.forEach((message, index) => {
|
|
92
|
+
const role = typeof message?.role === 'string' ? message.role : 'message';
|
|
93
|
+
const text = truncate(singleLine(contentToText(message?.content)), 220) || '[no content]';
|
|
94
|
+
lines.push(`${index + 1}. ${role}: ${text}`);
|
|
95
|
+
if (Array.isArray(message?.tool_calls) && message.tool_calls.length) {
|
|
96
|
+
const toolNames = message.tool_calls
|
|
97
|
+
.map((call) => {
|
|
98
|
+
if (typeof call?.function?.name === 'string')
|
|
99
|
+
return call.function.name;
|
|
100
|
+
if (typeof call?.id === 'string')
|
|
101
|
+
return call.id;
|
|
102
|
+
return 'tool-call';
|
|
103
|
+
})
|
|
104
|
+
.join(', ');
|
|
105
|
+
lines.push(` tools: ${toolNames}`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return `${lines.join('\n')}\n`;
|
|
109
|
+
}
|
|
110
|
+
export function shareCommand(args, session) {
|
|
111
|
+
const [subcommand = '', ...rest] = args;
|
|
112
|
+
const action = subcommand.toLowerCase();
|
|
113
|
+
if (!action)
|
|
114
|
+
return shareUsage();
|
|
115
|
+
if (action === 'export') {
|
|
116
|
+
const requestedPath = rest.join(' ').trim();
|
|
117
|
+
const target = path.resolve(session.state.cwd, requestedPath || `session-${session.state.id}.json`);
|
|
118
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
119
|
+
fs.writeFileSync(target, `${JSON.stringify(exportSessionBundle(session), null, 2)}\n`, 'utf8');
|
|
120
|
+
return `${theme.ok('✔ exported shared session')} ${target}\n`;
|
|
121
|
+
}
|
|
122
|
+
if (action === 'import') {
|
|
123
|
+
const requestedPath = rest.join(' ').trim();
|
|
124
|
+
if (!requestedPath)
|
|
125
|
+
return shareUsage();
|
|
126
|
+
const target = path.resolve(session.state.cwd, requestedPath);
|
|
127
|
+
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
128
|
+
return `${theme.err(`file not found: ${target}`)}\n`;
|
|
129
|
+
}
|
|
130
|
+
const result = importSessionBundle(fs.readFileSync(target, 'utf8'));
|
|
131
|
+
if (!result.success) {
|
|
132
|
+
return `${theme.err(`import failed: ${result.error || 'unknown error'}`)}\n`;
|
|
133
|
+
}
|
|
134
|
+
return `${theme.ok(`✔ imported session as ${result.sessionId}`)}\n`;
|
|
135
|
+
}
|
|
136
|
+
if (action === 'clipboard') {
|
|
137
|
+
return sessionToClipboard(session);
|
|
138
|
+
}
|
|
139
|
+
return shareUsage();
|
|
140
|
+
}
|
|
141
|
+
function shareUsage() {
|
|
142
|
+
return [
|
|
143
|
+
'usage: /share export [path]',
|
|
144
|
+
' /share import <path>',
|
|
145
|
+
' /share clipboard',
|
|
146
|
+
'',
|
|
147
|
+
].join('\n');
|
|
148
|
+
}
|
|
149
|
+
function validateBundle(input) {
|
|
150
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
151
|
+
throw new Error('Session bundle must be a JSON object.');
|
|
152
|
+
}
|
|
153
|
+
const bundle = input;
|
|
154
|
+
if (typeof bundle.version !== 'number' || bundle.version < 1) {
|
|
155
|
+
throw new Error('Session bundle version is invalid.');
|
|
156
|
+
}
|
|
157
|
+
if (typeof bundle.id !== 'string' || !bundle.id.trim()) {
|
|
158
|
+
throw new Error('Session bundle id is missing.');
|
|
159
|
+
}
|
|
160
|
+
if (typeof bundle.title !== 'string' || !bundle.title.trim()) {
|
|
161
|
+
throw new Error('Session bundle title is missing.');
|
|
162
|
+
}
|
|
163
|
+
if (typeof bundle.model !== 'string' || !bundle.model.trim()) {
|
|
164
|
+
throw new Error('Session bundle model is missing.');
|
|
165
|
+
}
|
|
166
|
+
if (!Array.isArray(bundle.messages)) {
|
|
167
|
+
throw new Error('Session bundle messages must be an array.');
|
|
168
|
+
}
|
|
169
|
+
if (typeof bundle.exportedAt !== 'string' || !bundle.exportedAt.trim()) {
|
|
170
|
+
throw new Error('Session bundle export timestamp is missing.');
|
|
171
|
+
}
|
|
172
|
+
const metadata = validateMetadata(bundle.metadata);
|
|
173
|
+
const mode = bundle.mode === 'plan' ? 'plan' : 'ask';
|
|
174
|
+
return {
|
|
175
|
+
version: bundle.version,
|
|
176
|
+
id: bundle.id,
|
|
177
|
+
title: bundle.title,
|
|
178
|
+
model: bundle.model,
|
|
179
|
+
messages: clone(bundle.messages),
|
|
180
|
+
metadata,
|
|
181
|
+
exportedAt: bundle.exportedAt,
|
|
182
|
+
mode,
|
|
183
|
+
todos: cloneTodos(bundle.todos),
|
|
184
|
+
pinned: clonePinned(bundle.pinned),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function validateMetadata(metadata) {
|
|
188
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
189
|
+
throw new Error('Session bundle metadata is missing.');
|
|
190
|
+
}
|
|
191
|
+
const candidate = metadata;
|
|
192
|
+
if (typeof candidate.cwd !== 'string' || !candidate.cwd.trim()) {
|
|
193
|
+
throw new Error('Session bundle cwd is missing.');
|
|
194
|
+
}
|
|
195
|
+
if (typeof candidate.tokensUsed !== 'number' || Number.isNaN(candidate.tokensUsed)) {
|
|
196
|
+
throw new Error('Session bundle tokensUsed is invalid.');
|
|
197
|
+
}
|
|
198
|
+
if (typeof candidate.messageCount !== 'number' || Number.isNaN(candidate.messageCount)) {
|
|
199
|
+
throw new Error('Session bundle messageCount is invalid.');
|
|
200
|
+
}
|
|
201
|
+
if (typeof candidate.createdAt !== 'string' || !candidate.createdAt.trim()) {
|
|
202
|
+
throw new Error('Session bundle createdAt is missing.');
|
|
203
|
+
}
|
|
204
|
+
if (typeof candidate.duration !== 'string' || !candidate.duration.trim()) {
|
|
205
|
+
throw new Error('Session bundle duration is missing.');
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
cwd: candidate.cwd,
|
|
209
|
+
tokensUsed: candidate.tokensUsed,
|
|
210
|
+
messageCount: candidate.messageCount,
|
|
211
|
+
createdAt: candidate.createdAt,
|
|
212
|
+
duration: candidate.duration,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function deriveTitle(state) {
|
|
216
|
+
for (const message of state.messages) {
|
|
217
|
+
const text = singleLine(contentToText(message?.content));
|
|
218
|
+
if (text)
|
|
219
|
+
return truncate(text, 72);
|
|
220
|
+
}
|
|
221
|
+
return `${DEFAULT_TITLE_PREFIX} ${state.id.slice(0, 8)}`;
|
|
222
|
+
}
|
|
223
|
+
function formatDuration(createdAt) {
|
|
224
|
+
const created = new Date(createdAt).getTime();
|
|
225
|
+
if (Number.isNaN(created))
|
|
226
|
+
return 'unknown';
|
|
227
|
+
const totalSeconds = Math.max(0, Math.floor((Date.now() - created) / 1000));
|
|
228
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
229
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
230
|
+
const seconds = totalSeconds % 60;
|
|
231
|
+
if (hours > 0)
|
|
232
|
+
return `${hours}h ${minutes}m`;
|
|
233
|
+
if (minutes > 0)
|
|
234
|
+
return `${minutes}m ${seconds}s`;
|
|
235
|
+
return `${seconds}s`;
|
|
236
|
+
}
|
|
237
|
+
function contentToText(content) {
|
|
238
|
+
if (typeof content === 'string')
|
|
239
|
+
return content;
|
|
240
|
+
if (Array.isArray(content)) {
|
|
241
|
+
return content
|
|
242
|
+
.map((part) => {
|
|
243
|
+
if (typeof part?.text === 'string')
|
|
244
|
+
return part.text;
|
|
245
|
+
if (typeof part?.type === 'string')
|
|
246
|
+
return JSON.stringify(part);
|
|
247
|
+
return '';
|
|
248
|
+
})
|
|
249
|
+
.filter(Boolean)
|
|
250
|
+
.join('\n');
|
|
251
|
+
}
|
|
252
|
+
if (content == null)
|
|
253
|
+
return '';
|
|
254
|
+
return JSON.stringify(content, null, 2);
|
|
255
|
+
}
|
|
256
|
+
function clone(value) {
|
|
257
|
+
return JSON.parse(JSON.stringify(value));
|
|
258
|
+
}
|
|
259
|
+
function cloneTodos(value) {
|
|
260
|
+
if (!Array.isArray(value))
|
|
261
|
+
return [];
|
|
262
|
+
return value.flatMap((item) => {
|
|
263
|
+
if (!item || typeof item !== 'object')
|
|
264
|
+
return [];
|
|
265
|
+
const candidate = item;
|
|
266
|
+
if (typeof candidate.id !== 'string' ||
|
|
267
|
+
typeof candidate.text !== 'string' ||
|
|
268
|
+
typeof candidate.done !== 'boolean' ||
|
|
269
|
+
typeof candidate.createdAt !== 'string') {
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
return [
|
|
273
|
+
{
|
|
274
|
+
id: candidate.id,
|
|
275
|
+
text: candidate.text,
|
|
276
|
+
done: candidate.done,
|
|
277
|
+
createdAt: candidate.createdAt,
|
|
278
|
+
...(typeof candidate.completedAt === 'string'
|
|
279
|
+
? { completedAt: candidate.completedAt }
|
|
280
|
+
: {}),
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function clonePinned(value) {
|
|
286
|
+
if (!Array.isArray(value))
|
|
287
|
+
return [];
|
|
288
|
+
return value.flatMap((item) => {
|
|
289
|
+
if (!item || typeof item !== 'object')
|
|
290
|
+
return [];
|
|
291
|
+
const candidate = item;
|
|
292
|
+
if (typeof candidate.path !== 'string' ||
|
|
293
|
+
typeof candidate.addedAt !== 'string' ||
|
|
294
|
+
typeof candidate.tokens !== 'number') {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
return [
|
|
298
|
+
{
|
|
299
|
+
path: candidate.path,
|
|
300
|
+
addedAt: candidate.addedAt,
|
|
301
|
+
tokens: candidate.tokens,
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
function singleLine(value) {
|
|
307
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
308
|
+
}
|
|
309
|
+
function truncate(value, maxLength) {
|
|
310
|
+
if (value.length <= maxLength)
|
|
311
|
+
return value;
|
|
312
|
+
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
313
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
const STACK_LIMIT = 50;
|
|
6
|
+
const JOURNAL_FILE = 'undo-journal.json';
|
|
7
|
+
function journalPath() {
|
|
8
|
+
return path.join(config.sessionDir, JOURNAL_FILE);
|
|
9
|
+
}
|
|
10
|
+
function emptyState() {
|
|
11
|
+
return { undo: [], redo: [] };
|
|
12
|
+
}
|
|
13
|
+
function ensureSessionDir() {
|
|
14
|
+
fs.mkdirSync(config.sessionDir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
function loadJournal() {
|
|
17
|
+
ensureSessionDir();
|
|
18
|
+
try {
|
|
19
|
+
const raw = fs.readFileSync(journalPath(), 'utf8');
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
return {
|
|
22
|
+
undo: Array.isArray(parsed.undo) ? parsed.undo : [],
|
|
23
|
+
redo: Array.isArray(parsed.redo) ? parsed.redo : [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (err.code === 'ENOENT')
|
|
28
|
+
return emptyState();
|
|
29
|
+
return emptyState();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function capStack(stack) {
|
|
33
|
+
return stack.slice(Math.max(0, stack.length - STACK_LIMIT));
|
|
34
|
+
}
|
|
35
|
+
function saveJournal(state) {
|
|
36
|
+
ensureSessionDir();
|
|
37
|
+
const capped = { undo: capStack(state.undo), redo: capStack(state.redo) };
|
|
38
|
+
fs.writeFileSync(journalPath(), `${JSON.stringify(capped, null, 2)}\n`, 'utf8');
|
|
39
|
+
}
|
|
40
|
+
function restorePath(absPath, bytes) {
|
|
41
|
+
if (bytes === null) {
|
|
42
|
+
fs.rmSync(absPath, { force: true });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
46
|
+
fs.writeFileSync(absPath, bytes);
|
|
47
|
+
}
|
|
48
|
+
export function recordWrite(absPath, prevBytes, nextBytes) {
|
|
49
|
+
const state = loadJournal();
|
|
50
|
+
const entry = {
|
|
51
|
+
id: crypto.randomUUID(),
|
|
52
|
+
ts: Date.now(),
|
|
53
|
+
path: path.resolve(absPath),
|
|
54
|
+
prevBytes,
|
|
55
|
+
nextBytes,
|
|
56
|
+
};
|
|
57
|
+
state.undo.push(entry);
|
|
58
|
+
state.undo = capStack(state.undo);
|
|
59
|
+
state.redo = [];
|
|
60
|
+
saveJournal(state);
|
|
61
|
+
return entry;
|
|
62
|
+
}
|
|
63
|
+
export function undoLast() {
|
|
64
|
+
const state = loadJournal();
|
|
65
|
+
const entry = state.undo.pop();
|
|
66
|
+
if (!entry)
|
|
67
|
+
return null;
|
|
68
|
+
restorePath(entry.path, entry.prevBytes);
|
|
69
|
+
state.redo.push(entry);
|
|
70
|
+
state.redo = capStack(state.redo);
|
|
71
|
+
saveJournal(state);
|
|
72
|
+
return { entry, restored: 'prev' };
|
|
73
|
+
}
|
|
74
|
+
export function redoLast() {
|
|
75
|
+
const state = loadJournal();
|
|
76
|
+
const entry = state.redo.shift();
|
|
77
|
+
if (!entry)
|
|
78
|
+
return null;
|
|
79
|
+
restorePath(entry.path, entry.nextBytes);
|
|
80
|
+
state.undo.push(entry);
|
|
81
|
+
state.undo = capStack(state.undo);
|
|
82
|
+
saveJournal(state);
|
|
83
|
+
return { entry, restored: 'next' };
|
|
84
|
+
}
|
|
85
|
+
export function journalSize() {
|
|
86
|
+
const state = loadJournal();
|
|
87
|
+
return { undo: state.undo.length, redo: state.redo.length };
|
|
88
|
+
}
|
|
89
|
+
export function clearJournal() {
|
|
90
|
+
saveJournal(emptyState());
|
|
91
|
+
}
|