codeep 1.3.42 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +208 -0
- package/dist/acp/commands.js +770 -7
- package/dist/acp/protocol.d.ts +11 -2
- package/dist/acp/server.js +179 -11
- package/dist/acp/session.d.ts +3 -0
- package/dist/acp/session.js +5 -0
- package/dist/api/index.js +39 -6
- package/dist/config/index.d.ts +13 -0
- package/dist/config/index.js +45 -0
- package/dist/config/providers.js +76 -1
- package/dist/renderer/App.d.ts +12 -0
- package/dist/renderer/App.js +109 -4
- package/dist/renderer/agentExecution.js +5 -0
- package/dist/renderer/commands.js +638 -2
- package/dist/renderer/components/Help.js +28 -0
- package/dist/renderer/components/Login.d.ts +1 -0
- package/dist/renderer/components/Login.js +24 -9
- package/dist/renderer/handlers.d.ts +11 -1
- package/dist/renderer/handlers.js +30 -0
- package/dist/renderer/main.js +73 -0
- package/dist/utils/agent.d.ts +17 -0
- package/dist/utils/agent.js +91 -7
- package/dist/utils/agentChat.d.ts +10 -2
- package/dist/utils/agentChat.js +48 -9
- package/dist/utils/agentStream.js +6 -2
- package/dist/utils/checkpoints.d.ts +93 -0
- package/dist/utils/checkpoints.js +205 -0
- package/dist/utils/context.d.ts +24 -0
- package/dist/utils/context.js +57 -0
- package/dist/utils/customCommands.d.ts +62 -0
- package/dist/utils/customCommands.js +201 -0
- package/dist/utils/hooks.d.ts +97 -0
- package/dist/utils/hooks.js +223 -0
- package/dist/utils/mcpClient.d.ts +229 -0
- package/dist/utils/mcpClient.js +497 -0
- package/dist/utils/mcpConfig.d.ts +55 -0
- package/dist/utils/mcpConfig.js +177 -0
- package/dist/utils/mcpMarketplace.d.ts +49 -0
- package/dist/utils/mcpMarketplace.js +175 -0
- package/dist/utils/mcpRegistry.d.ts +129 -0
- package/dist/utils/mcpRegistry.js +427 -0
- package/dist/utils/mcpSamplingBridge.d.ts +32 -0
- package/dist/utils/mcpSamplingBridge.js +88 -0
- package/dist/utils/mcpStreamableHttp.d.ts +65 -0
- package/dist/utils/mcpStreamableHttp.js +207 -0
- package/dist/utils/openrouterPrefs.d.ts +36 -0
- package/dist/utils/openrouterPrefs.js +83 -0
- package/dist/utils/skillBundles.d.ts +84 -0
- package/dist/utils/skillBundles.js +257 -0
- package/dist/utils/skillBundlesCloud.d.ts +69 -0
- package/dist/utils/skillBundlesCloud.js +202 -0
- package/dist/utils/tokenTracker.d.ts +14 -2
- package/dist/utils/tokenTracker.js +59 -41
- package/dist/utils/toolExecution.d.ts +17 -1
- package/dist/utils/toolExecution.js +184 -6
- package/dist/utils/tools.d.ts +22 -6
- package/dist/utils/tools.js +83 -8
- package/package.json +3 -2
- package/bin/codeep-macos-arm64 +0 -0
- package/bin/codeep-macos-x64 +0 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session checkpoints — `/checkpoint` and `/rewind`.
|
|
3
|
+
*
|
|
4
|
+
* A checkpoint is a snapshot of the agent conversation at a point in time:
|
|
5
|
+
* session history, provider/model state, and the list of files the agent has
|
|
6
|
+
* touched in this session. It does NOT snapshot file content — that's git's
|
|
7
|
+
* job, and trying to do it ourselves means either a 100KB-per-file ceiling
|
|
8
|
+
* with surprise truncation, or a real-time disk hog. We surface a hint at
|
|
9
|
+
* rewind time telling the user how to restore files via git.
|
|
10
|
+
*
|
|
11
|
+
* Use cases this is for:
|
|
12
|
+
* - User suspects the agent is going off the rails after N steps and
|
|
13
|
+
* wants to roll the conversation back to before that turn.
|
|
14
|
+
* - User is about to start a risky multi-step refactor and wants a
|
|
15
|
+
* named bookmark to return to if it gets messy.
|
|
16
|
+
*
|
|
17
|
+
* Use cases this is NOT for:
|
|
18
|
+
* - Replacing git. If you only need file rollback, `git restore` /
|
|
19
|
+
* `git stash` is faster and reliable.
|
|
20
|
+
* - Cross-session persistence beyond the workspace's `.codeep/checkpoints/`
|
|
21
|
+
* folder — checkpoints are per-project, not global.
|
|
22
|
+
*
|
|
23
|
+
* On-disk shape (`.codeep/checkpoints/<id>.json`):
|
|
24
|
+
* {
|
|
25
|
+
* "id": "ck-2026-05-18-abc123",
|
|
26
|
+
* "name": "before big refactor",
|
|
27
|
+
* "createdAt": "...",
|
|
28
|
+
* "sessionId": "session-2026-05-18-...",
|
|
29
|
+
* "provider": "z.ai",
|
|
30
|
+
* "model": "glm-5.1",
|
|
31
|
+
* "messages": [ ... ],
|
|
32
|
+
* "filesTouched": ["src/a.ts", "src/b.ts"],
|
|
33
|
+
* "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
import type { Message } from '../config/index.js';
|
|
37
|
+
export interface Checkpoint {
|
|
38
|
+
id: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
sessionId: string;
|
|
42
|
+
provider: string;
|
|
43
|
+
model: string;
|
|
44
|
+
messages: Message[];
|
|
45
|
+
filesTouched: string[];
|
|
46
|
+
gitHead?: string;
|
|
47
|
+
}
|
|
48
|
+
/** Lightweight metadata for `/checkpoints` list — avoids loading full message arrays. */
|
|
49
|
+
export interface CheckpointMeta {
|
|
50
|
+
id: string;
|
|
51
|
+
name?: string;
|
|
52
|
+
createdAt: string;
|
|
53
|
+
sessionId: string;
|
|
54
|
+
messageCount: number;
|
|
55
|
+
filesTouchedCount: number;
|
|
56
|
+
gitHead?: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Create a new checkpoint snapshot of the current session state.
|
|
60
|
+
* Returns the persisted checkpoint object.
|
|
61
|
+
*/
|
|
62
|
+
export declare function createCheckpoint(opts: {
|
|
63
|
+
workspaceRoot: string;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
provider: string;
|
|
66
|
+
model: string;
|
|
67
|
+
messages: Message[];
|
|
68
|
+
filesTouched: string[];
|
|
69
|
+
name?: string;
|
|
70
|
+
}): Checkpoint;
|
|
71
|
+
/**
|
|
72
|
+
* Load a single checkpoint by id. Returns null if not found or unreadable.
|
|
73
|
+
*/
|
|
74
|
+
export declare function loadCheckpoint(workspaceRoot: string, id: string): Checkpoint | null;
|
|
75
|
+
/**
|
|
76
|
+
* List checkpoints in the workspace, newest first. Returns metadata only —
|
|
77
|
+
* the full `messages` array is not loaded so this is cheap for `/checkpoints`.
|
|
78
|
+
*/
|
|
79
|
+
export declare function listCheckpoints(workspaceRoot: string): CheckpointMeta[];
|
|
80
|
+
/**
|
|
81
|
+
* Delete a checkpoint by id. Returns true if a file was removed.
|
|
82
|
+
*/
|
|
83
|
+
export declare function deleteCheckpoint(workspaceRoot: string, id: string): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Format a checkpoint list as a Markdown block for `/checkpoints` output.
|
|
86
|
+
*/
|
|
87
|
+
export declare function formatCheckpointList(metas: CheckpointMeta[]): string;
|
|
88
|
+
/**
|
|
89
|
+
* Build the user-facing hint shown after a successful /rewind. Tells the
|
|
90
|
+
* user how to bring their files back to checkpoint state — checkpoints
|
|
91
|
+
* don't snapshot file content, so this is the only restore path.
|
|
92
|
+
*/
|
|
93
|
+
export declare function buildRewindGitHint(cp: Checkpoint): string;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session checkpoints — `/checkpoint` and `/rewind`.
|
|
3
|
+
*
|
|
4
|
+
* A checkpoint is a snapshot of the agent conversation at a point in time:
|
|
5
|
+
* session history, provider/model state, and the list of files the agent has
|
|
6
|
+
* touched in this session. It does NOT snapshot file content — that's git's
|
|
7
|
+
* job, and trying to do it ourselves means either a 100KB-per-file ceiling
|
|
8
|
+
* with surprise truncation, or a real-time disk hog. We surface a hint at
|
|
9
|
+
* rewind time telling the user how to restore files via git.
|
|
10
|
+
*
|
|
11
|
+
* Use cases this is for:
|
|
12
|
+
* - User suspects the agent is going off the rails after N steps and
|
|
13
|
+
* wants to roll the conversation back to before that turn.
|
|
14
|
+
* - User is about to start a risky multi-step refactor and wants a
|
|
15
|
+
* named bookmark to return to if it gets messy.
|
|
16
|
+
*
|
|
17
|
+
* Use cases this is NOT for:
|
|
18
|
+
* - Replacing git. If you only need file rollback, `git restore` /
|
|
19
|
+
* `git stash` is faster and reliable.
|
|
20
|
+
* - Cross-session persistence beyond the workspace's `.codeep/checkpoints/`
|
|
21
|
+
* folder — checkpoints are per-project, not global.
|
|
22
|
+
*
|
|
23
|
+
* On-disk shape (`.codeep/checkpoints/<id>.json`):
|
|
24
|
+
* {
|
|
25
|
+
* "id": "ck-2026-05-18-abc123",
|
|
26
|
+
* "name": "before big refactor",
|
|
27
|
+
* "createdAt": "...",
|
|
28
|
+
* "sessionId": "session-2026-05-18-...",
|
|
29
|
+
* "provider": "z.ai",
|
|
30
|
+
* "model": "glm-5.1",
|
|
31
|
+
* "messages": [ ... ],
|
|
32
|
+
* "filesTouched": ["src/a.ts", "src/b.ts"],
|
|
33
|
+
* "gitHead": "abcdef0" // optional, recorded only if cwd is a git repo
|
|
34
|
+
* }
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'fs';
|
|
37
|
+
import { join } from 'path';
|
|
38
|
+
import { randomUUID } from 'crypto';
|
|
39
|
+
import { execSync } from 'child_process';
|
|
40
|
+
function getCheckpointsDir(workspaceRoot) {
|
|
41
|
+
const dir = join(workspaceRoot, '.codeep', 'checkpoints');
|
|
42
|
+
if (!existsSync(dir))
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
function readGitHead(workspaceRoot) {
|
|
47
|
+
try {
|
|
48
|
+
// Short SHA is enough for human display; full SHA available via git if needed.
|
|
49
|
+
const out = execSync('git rev-parse --short HEAD', {
|
|
50
|
+
cwd: workspaceRoot,
|
|
51
|
+
encoding: 'utf-8',
|
|
52
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
53
|
+
timeout: 2000,
|
|
54
|
+
}).trim();
|
|
55
|
+
return out || undefined;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Generate a unique, human-recognizable checkpoint id.
|
|
63
|
+
* Format: `ck-YYYY-MM-DD-<8-char-uuid>`
|
|
64
|
+
*/
|
|
65
|
+
function generateCheckpointId() {
|
|
66
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
67
|
+
return `ck-${date}-${randomUUID().slice(0, 8)}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Create a new checkpoint snapshot of the current session state.
|
|
71
|
+
* Returns the persisted checkpoint object.
|
|
72
|
+
*/
|
|
73
|
+
export function createCheckpoint(opts) {
|
|
74
|
+
const checkpoint = {
|
|
75
|
+
id: generateCheckpointId(),
|
|
76
|
+
name: opts.name,
|
|
77
|
+
createdAt: new Date().toISOString(),
|
|
78
|
+
sessionId: opts.sessionId,
|
|
79
|
+
provider: opts.provider,
|
|
80
|
+
model: opts.model,
|
|
81
|
+
messages: opts.messages,
|
|
82
|
+
filesTouched: opts.filesTouched,
|
|
83
|
+
gitHead: readGitHead(opts.workspaceRoot),
|
|
84
|
+
};
|
|
85
|
+
const dir = getCheckpointsDir(opts.workspaceRoot);
|
|
86
|
+
writeFileSync(join(dir, `${checkpoint.id}.json`), JSON.stringify(checkpoint, null, 2));
|
|
87
|
+
return checkpoint;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Load a single checkpoint by id. Returns null if not found or unreadable.
|
|
91
|
+
*/
|
|
92
|
+
export function loadCheckpoint(workspaceRoot, id) {
|
|
93
|
+
// Defensive: reject ids that look like path traversal attempts. Real ids are
|
|
94
|
+
// `ck-YYYY-MM-DD-<hex>` so the regex is tight enough to catch typos too.
|
|
95
|
+
if (!/^ck-\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(id))
|
|
96
|
+
return null;
|
|
97
|
+
const file = join(getCheckpointsDir(workspaceRoot), `${id}.json`);
|
|
98
|
+
if (!existsSync(file))
|
|
99
|
+
return null;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* List checkpoints in the workspace, newest first. Returns metadata only —
|
|
109
|
+
* the full `messages` array is not loaded so this is cheap for `/checkpoints`.
|
|
110
|
+
*/
|
|
111
|
+
export function listCheckpoints(workspaceRoot) {
|
|
112
|
+
const dir = getCheckpointsDir(workspaceRoot);
|
|
113
|
+
let files;
|
|
114
|
+
try {
|
|
115
|
+
files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
const metas = [];
|
|
121
|
+
for (const file of files) {
|
|
122
|
+
try {
|
|
123
|
+
const full = join(dir, file);
|
|
124
|
+
const stat = statSync(full);
|
|
125
|
+
const cp = JSON.parse(readFileSync(full, 'utf-8'));
|
|
126
|
+
metas.push({
|
|
127
|
+
id: cp.id,
|
|
128
|
+
name: cp.name,
|
|
129
|
+
createdAt: cp.createdAt || stat.mtime.toISOString(),
|
|
130
|
+
sessionId: cp.sessionId,
|
|
131
|
+
messageCount: cp.messages?.length ?? 0,
|
|
132
|
+
filesTouchedCount: cp.filesTouched?.length ?? 0,
|
|
133
|
+
gitHead: cp.gitHead,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// Skip corrupt entries — don't let one bad checkpoint block the list.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Newest first by createdAt.
|
|
141
|
+
metas.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
|
142
|
+
return metas;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Delete a checkpoint by id. Returns true if a file was removed.
|
|
146
|
+
*/
|
|
147
|
+
export function deleteCheckpoint(workspaceRoot, id) {
|
|
148
|
+
if (!/^ck-\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(id))
|
|
149
|
+
return false;
|
|
150
|
+
const file = join(getCheckpointsDir(workspaceRoot), `${id}.json`);
|
|
151
|
+
if (!existsSync(file))
|
|
152
|
+
return false;
|
|
153
|
+
try {
|
|
154
|
+
unlinkSync(file);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Format a checkpoint list as a Markdown block for `/checkpoints` output.
|
|
163
|
+
*/
|
|
164
|
+
export function formatCheckpointList(metas) {
|
|
165
|
+
if (metas.length === 0) {
|
|
166
|
+
return [
|
|
167
|
+
'_No checkpoints yet._',
|
|
168
|
+
'',
|
|
169
|
+
'Create one with `/checkpoint [name]` before risky refactors so you can `/rewind` if things go sideways.',
|
|
170
|
+
].join('\n');
|
|
171
|
+
}
|
|
172
|
+
const lines = ['## Checkpoints', ''];
|
|
173
|
+
for (const m of metas) {
|
|
174
|
+
const label = m.name ? `**${m.name}** (\`${m.id}\`)` : `\`${m.id}\``;
|
|
175
|
+
const gitFragment = m.gitHead ? ` · git \`${m.gitHead}\`` : '';
|
|
176
|
+
const date = m.createdAt.slice(0, 19).replace('T', ' ');
|
|
177
|
+
lines.push(`- ${label}`, ` ${date} · ${m.messageCount} message${m.messageCount === 1 ? '' : 's'} · ${m.filesTouchedCount} file${m.filesTouchedCount === 1 ? '' : 's'} touched${gitFragment}`);
|
|
178
|
+
}
|
|
179
|
+
lines.push('', 'Use `/rewind <id>` to restore a checkpoint, or `/checkpoint delete <id>` to drop one.');
|
|
180
|
+
return lines.join('\n');
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Build the user-facing hint shown after a successful /rewind. Tells the
|
|
184
|
+
* user how to bring their files back to checkpoint state — checkpoints
|
|
185
|
+
* don't snapshot file content, so this is the only restore path.
|
|
186
|
+
*/
|
|
187
|
+
export function buildRewindGitHint(cp) {
|
|
188
|
+
if (cp.gitHead) {
|
|
189
|
+
return [
|
|
190
|
+
`**Files were NOT restored** — only the conversation. To bring files back to the state at checkpoint time:`,
|
|
191
|
+
'',
|
|
192
|
+
'```bash',
|
|
193
|
+
`git stash # save current uncommitted changes if you want to keep them`,
|
|
194
|
+
`git checkout ${cp.gitHead} -- ${cp.filesTouched.length > 0 ? cp.filesTouched.map(f => `'${f}'`).join(' ') : '.'}`,
|
|
195
|
+
'```',
|
|
196
|
+
].join('\n');
|
|
197
|
+
}
|
|
198
|
+
return [
|
|
199
|
+
'**Files were NOT restored** — only the conversation.',
|
|
200
|
+
cp.filesTouched.length > 0
|
|
201
|
+
? `Files the agent touched between checkpoint and now: ${cp.filesTouched.map(f => `\`${f}\``).join(', ')}.`
|
|
202
|
+
: '',
|
|
203
|
+
'Use `git` (or your editor\'s undo) to revert any file changes you don\'t want.',
|
|
204
|
+
].filter(Boolean).join('\n');
|
|
205
|
+
}
|
package/dist/utils/context.d.ts
CHANGED
|
@@ -27,6 +27,30 @@ export declare function clearContext(projectPath: string): boolean;
|
|
|
27
27
|
* Get all saved contexts
|
|
28
28
|
*/
|
|
29
29
|
export declare function getAllContexts(): ConversationContext[];
|
|
30
|
+
/**
|
|
31
|
+
* AI-powered compaction of a conversation history.
|
|
32
|
+
*
|
|
33
|
+
* Used by the `/compact` slash command. Sends the older portion of the
|
|
34
|
+
* conversation to the active provider with a summarization prompt, then
|
|
35
|
+
* replaces those messages with a single system message containing the
|
|
36
|
+
* summary. Keeps the last `keepRecent` messages verbatim so the
|
|
37
|
+
* conversation can continue without losing the most recent context.
|
|
38
|
+
*
|
|
39
|
+
* Returns the same `history` (untouched) if there isn't enough to
|
|
40
|
+
* meaningfully compact.
|
|
41
|
+
*/
|
|
42
|
+
export declare function compactHistory(history: Message[], options?: {
|
|
43
|
+
keepRecent?: number;
|
|
44
|
+
projectContext?: import('./project').ProjectContext | null;
|
|
45
|
+
/** Cap on how long the summarization API call can take, in ms. Defaults to 60s. */
|
|
46
|
+
timeoutMs?: number;
|
|
47
|
+
/** External abort signal (e.g. user pressed /stop). Combined with the timeout. */
|
|
48
|
+
abortSignal?: AbortSignal;
|
|
49
|
+
}): Promise<{
|
|
50
|
+
compacted: Message[];
|
|
51
|
+
replaced: number;
|
|
52
|
+
summary: string;
|
|
53
|
+
}>;
|
|
30
54
|
/**
|
|
31
55
|
* Summarize messages for context persistence
|
|
32
56
|
* Keeps recent messages and summarizes older ones
|
package/dist/utils/context.js
CHANGED
|
@@ -113,6 +113,63 @@ export function getAllContexts() {
|
|
|
113
113
|
return [];
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* AI-powered compaction of a conversation history.
|
|
118
|
+
*
|
|
119
|
+
* Used by the `/compact` slash command. Sends the older portion of the
|
|
120
|
+
* conversation to the active provider with a summarization prompt, then
|
|
121
|
+
* replaces those messages with a single system message containing the
|
|
122
|
+
* summary. Keeps the last `keepRecent` messages verbatim so the
|
|
123
|
+
* conversation can continue without losing the most recent context.
|
|
124
|
+
*
|
|
125
|
+
* Returns the same `history` (untouched) if there isn't enough to
|
|
126
|
+
* meaningfully compact.
|
|
127
|
+
*/
|
|
128
|
+
export async function compactHistory(history, options = {}) {
|
|
129
|
+
const keepRecent = options.keepRecent ?? 4;
|
|
130
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
131
|
+
// Need at least one full exchange to compact (user + assistant) plus the
|
|
132
|
+
// recent tail — otherwise compaction is just overhead.
|
|
133
|
+
if (history.length <= keepRecent + 2) {
|
|
134
|
+
return { compacted: history, replaced: 0, summary: '' };
|
|
135
|
+
}
|
|
136
|
+
const toCompact = history.slice(0, history.length - keepRecent);
|
|
137
|
+
const recent = history.slice(history.length - keepRecent);
|
|
138
|
+
const transcript = toCompact
|
|
139
|
+
.map(m => `[${m.role.toUpperCase()}]\n${m.content}`)
|
|
140
|
+
.join('\n\n---\n\n');
|
|
141
|
+
const prompt = 'Summarize the following conversation between a user and a coding assistant.' +
|
|
142
|
+
' Capture concisely: (1) what the user was trying to accomplish, (2) key decisions and rationale,' +
|
|
143
|
+
' (3) files or components touched, (4) outstanding questions or unfinished work.' +
|
|
144
|
+
' The summary will replace these messages in the agent\'s context, so include anything needed' +
|
|
145
|
+
' to continue the work without re-reading the originals.\n\nConversation:\n\n' +
|
|
146
|
+
transcript;
|
|
147
|
+
// Cap how long we'll wait. /compact otherwise blocks the whole session
|
|
148
|
+
// with no way to interrupt (the regular /stop targets the agent loop,
|
|
149
|
+
// not arbitrary chat calls). If the user passed their own AbortSignal,
|
|
150
|
+
// combine it with our timer so either can cancel.
|
|
151
|
+
const timeoutController = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
|
|
153
|
+
const onExternalAbort = () => timeoutController.abort();
|
|
154
|
+
options.abortSignal?.addEventListener('abort', onExternalAbort);
|
|
155
|
+
try {
|
|
156
|
+
const { chat } = await import('../api/index.js');
|
|
157
|
+
const summary = await chat(prompt, [], undefined, undefined, options.projectContext ?? undefined, timeoutController.signal);
|
|
158
|
+
const summaryMessage = {
|
|
159
|
+
role: 'system',
|
|
160
|
+
content: `[Conversation compacted — ${toCompact.length} earlier message${toCompact.length === 1 ? '' : 's'} summarized below]\n\n${summary}`,
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
compacted: [summaryMessage, ...recent],
|
|
164
|
+
replaced: toCompact.length,
|
|
165
|
+
summary,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
options.abortSignal?.removeEventListener('abort', onExternalAbort);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
116
173
|
/**
|
|
117
174
|
* Summarize messages for context persistence
|
|
118
175
|
* Keeps recent messages and summarizes older ones
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom slash commands.
|
|
3
|
+
*
|
|
4
|
+
* Users drop `.md` files in either:
|
|
5
|
+
* - `<workspace>/.codeep/commands/<name>.md` (project-scoped)
|
|
6
|
+
* - `~/.codeep/commands/<name>.md` (global, all projects)
|
|
7
|
+
*
|
|
8
|
+
* Each file becomes a `/<name>` slash command. Project files take precedence
|
|
9
|
+
* over global files with the same name.
|
|
10
|
+
*
|
|
11
|
+
* File format (frontmatter optional):
|
|
12
|
+
*
|
|
13
|
+
* ---
|
|
14
|
+
* description: Detailed security review of a file
|
|
15
|
+
* aliases: [sec, secrev]
|
|
16
|
+
* ---
|
|
17
|
+
*
|
|
18
|
+
* Please perform a thorough security review of: {{args}}
|
|
19
|
+
*
|
|
20
|
+
* The body is expanded with the user's arguments and sent as a user message
|
|
21
|
+
* to the agent. Placeholders supported:
|
|
22
|
+
* - `{{args}}` and `$ARGUMENTS` — full args string (Claude Code compat)
|
|
23
|
+
* - `{{arg1}}` … `{{argN}}` — positional args (1-indexed)
|
|
24
|
+
*
|
|
25
|
+
* Custom commands are not invoked automatically — they only fire when the
|
|
26
|
+
* user types `/<name>`. The agent never sees them in its tool catalog.
|
|
27
|
+
*/
|
|
28
|
+
export interface CustomCommand {
|
|
29
|
+
/** Slash name without the leading `/` */
|
|
30
|
+
name: string;
|
|
31
|
+
/** One-line description for /help and ACP autocomplete */
|
|
32
|
+
description: string;
|
|
33
|
+
/** Body text after frontmatter, with placeholders unresolved */
|
|
34
|
+
body: string;
|
|
35
|
+
/** Filesystem path the command was loaded from */
|
|
36
|
+
source: string;
|
|
37
|
+
/** 'project' if loaded from <workspace>/.codeep/commands, else 'global' */
|
|
38
|
+
scope: 'project' | 'global';
|
|
39
|
+
/** Optional alternative names that also trigger this command */
|
|
40
|
+
aliases: string[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Load all custom commands available in this workspace.
|
|
44
|
+
* Project commands shadow global commands with the same name.
|
|
45
|
+
*/
|
|
46
|
+
export declare function loadCustomCommands(workspaceRoot?: string): CustomCommand[];
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a user-typed slash name to a custom command, checking primary
|
|
49
|
+
* name first then aliases. Project scope wins over global on collisions
|
|
50
|
+
* (handled inside loadCustomCommands).
|
|
51
|
+
*/
|
|
52
|
+
export declare function findCustomCommand(name: string, workspaceRoot?: string): CustomCommand | null;
|
|
53
|
+
/**
|
|
54
|
+
* Expand `{{args}}`, `$ARGUMENTS`, and `{{argN}}` placeholders in the
|
|
55
|
+
* command body. If the body has no placeholders, the args are appended
|
|
56
|
+
* on a new line so the agent still sees them.
|
|
57
|
+
*/
|
|
58
|
+
export declare function expandCommand(cmd: CustomCommand, args: string[]): string;
|
|
59
|
+
/**
|
|
60
|
+
* Render the catalog as a Markdown block for `/commands`.
|
|
61
|
+
*/
|
|
62
|
+
export declare function formatCommandList(commands: CustomCommand[]): string;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom slash commands.
|
|
3
|
+
*
|
|
4
|
+
* Users drop `.md` files in either:
|
|
5
|
+
* - `<workspace>/.codeep/commands/<name>.md` (project-scoped)
|
|
6
|
+
* - `~/.codeep/commands/<name>.md` (global, all projects)
|
|
7
|
+
*
|
|
8
|
+
* Each file becomes a `/<name>` slash command. Project files take precedence
|
|
9
|
+
* over global files with the same name.
|
|
10
|
+
*
|
|
11
|
+
* File format (frontmatter optional):
|
|
12
|
+
*
|
|
13
|
+
* ---
|
|
14
|
+
* description: Detailed security review of a file
|
|
15
|
+
* aliases: [sec, secrev]
|
|
16
|
+
* ---
|
|
17
|
+
*
|
|
18
|
+
* Please perform a thorough security review of: {{args}}
|
|
19
|
+
*
|
|
20
|
+
* The body is expanded with the user's arguments and sent as a user message
|
|
21
|
+
* to the agent. Placeholders supported:
|
|
22
|
+
* - `{{args}}` and `$ARGUMENTS` — full args string (Claude Code compat)
|
|
23
|
+
* - `{{arg1}}` … `{{argN}}` — positional args (1-indexed)
|
|
24
|
+
*
|
|
25
|
+
* Custom commands are not invoked automatically — they only fire when the
|
|
26
|
+
* user types `/<name>`. The agent never sees them in its tool catalog.
|
|
27
|
+
*/
|
|
28
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'fs';
|
|
29
|
+
import { join } from 'path';
|
|
30
|
+
import { homedir } from 'os';
|
|
31
|
+
/**
|
|
32
|
+
* Minimal YAML frontmatter parser — handles `key: value` and
|
|
33
|
+
* `key: [a, b, c]`. We deliberately avoid a YAML dependency because the
|
|
34
|
+
* surface area we care about is tiny.
|
|
35
|
+
*/
|
|
36
|
+
function parseFrontmatter(raw) {
|
|
37
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
38
|
+
if (!match)
|
|
39
|
+
return { meta: {}, body: raw };
|
|
40
|
+
const meta = {};
|
|
41
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
42
|
+
const kv = line.match(/^\s*([a-zA-Z_][\w-]*)\s*:\s*(.*?)\s*$/);
|
|
43
|
+
if (!kv)
|
|
44
|
+
continue;
|
|
45
|
+
const key = kv[1];
|
|
46
|
+
let value = kv[2];
|
|
47
|
+
const arr = value.match(/^\[(.*)\]$/);
|
|
48
|
+
if (arr) {
|
|
49
|
+
value = arr[1]
|
|
50
|
+
.split(',')
|
|
51
|
+
.map(s => s.trim().replace(/^["']|["']$/g, ''))
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
value = value.replace(/^["']|["']$/g, '');
|
|
56
|
+
}
|
|
57
|
+
if (key === 'description' && typeof value === 'string')
|
|
58
|
+
meta.description = value;
|
|
59
|
+
if (key === 'aliases' && Array.isArray(value))
|
|
60
|
+
meta.aliases = value;
|
|
61
|
+
}
|
|
62
|
+
return { meta, body: match[2].trimStart() };
|
|
63
|
+
}
|
|
64
|
+
function loadFromDir(dir, scope) {
|
|
65
|
+
if (!existsSync(dir))
|
|
66
|
+
return [];
|
|
67
|
+
let entries;
|
|
68
|
+
try {
|
|
69
|
+
entries = readdirSync(dir);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
const commands = [];
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (!entry.endsWith('.md'))
|
|
77
|
+
continue;
|
|
78
|
+
const fullPath = join(dir, entry);
|
|
79
|
+
try {
|
|
80
|
+
const stat = statSync(fullPath);
|
|
81
|
+
if (!stat.isFile())
|
|
82
|
+
continue;
|
|
83
|
+
// Sanity ceiling — a slash-command template above 64KB is almost
|
|
84
|
+
// certainly someone dumping a doc in the wrong folder.
|
|
85
|
+
if (stat.size > 65_536)
|
|
86
|
+
continue;
|
|
87
|
+
const raw = readFileSync(fullPath, 'utf-8');
|
|
88
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
89
|
+
const name = entry.replace(/\.md$/, '').toLowerCase();
|
|
90
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name))
|
|
91
|
+
continue; // names match `/foo-bar`
|
|
92
|
+
commands.push({
|
|
93
|
+
name,
|
|
94
|
+
description: meta.description ?? `Custom ${scope} command`,
|
|
95
|
+
body: body.trim(),
|
|
96
|
+
source: fullPath,
|
|
97
|
+
scope,
|
|
98
|
+
aliases: (meta.aliases ?? []).map(a => a.toLowerCase()).filter(a => /^[a-z0-9][a-z0-9-]*$/.test(a)),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Skip unreadable / malformed files silently — they shouldn't block
|
|
103
|
+
// the rest of the catalog from loading.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return commands;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Load all custom commands available in this workspace.
|
|
110
|
+
* Project commands shadow global commands with the same name.
|
|
111
|
+
*/
|
|
112
|
+
export function loadCustomCommands(workspaceRoot) {
|
|
113
|
+
const global = loadFromDir(join(homedir(), '.codeep', 'commands'), 'global');
|
|
114
|
+
const project = workspaceRoot
|
|
115
|
+
? loadFromDir(join(workspaceRoot, '.codeep', 'commands'), 'project')
|
|
116
|
+
: [];
|
|
117
|
+
// Project wins on name collisions; collapse to a single map keyed by name.
|
|
118
|
+
const byName = new Map();
|
|
119
|
+
for (const cmd of global)
|
|
120
|
+
byName.set(cmd.name, cmd);
|
|
121
|
+
for (const cmd of project)
|
|
122
|
+
byName.set(cmd.name, cmd);
|
|
123
|
+
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a user-typed slash name to a custom command, checking primary
|
|
127
|
+
* name first then aliases. Project scope wins over global on collisions
|
|
128
|
+
* (handled inside loadCustomCommands).
|
|
129
|
+
*/
|
|
130
|
+
export function findCustomCommand(name, workspaceRoot) {
|
|
131
|
+
const lower = name.toLowerCase();
|
|
132
|
+
const all = loadCustomCommands(workspaceRoot);
|
|
133
|
+
return all.find(c => c.name === lower || c.aliases.includes(lower)) ?? null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Expand `{{args}}`, `$ARGUMENTS`, and `{{argN}}` placeholders in the
|
|
137
|
+
* command body. If the body has no placeholders, the args are appended
|
|
138
|
+
* on a new line so the agent still sees them.
|
|
139
|
+
*/
|
|
140
|
+
export function expandCommand(cmd, args) {
|
|
141
|
+
const joined = args.join(' ');
|
|
142
|
+
const hasPlaceholder = /\{\{args\}\}|\$ARGUMENTS|\{\{arg\d+\}\}/.test(cmd.body);
|
|
143
|
+
let expanded = cmd.body
|
|
144
|
+
.replace(/\{\{args\}\}/g, joined)
|
|
145
|
+
.replace(/\$ARGUMENTS/g, joined);
|
|
146
|
+
// Positional: {{arg1}}, {{arg2}}, ...
|
|
147
|
+
expanded = expanded.replace(/\{\{arg(\d+)\}\}/g, (_m, n) => {
|
|
148
|
+
const idx = parseInt(n, 10) - 1;
|
|
149
|
+
return args[idx] ?? '';
|
|
150
|
+
});
|
|
151
|
+
if (!hasPlaceholder && joined) {
|
|
152
|
+
expanded += `\n\n${joined}`;
|
|
153
|
+
}
|
|
154
|
+
return expanded;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Render the catalog as a Markdown block for `/commands`.
|
|
158
|
+
*/
|
|
159
|
+
export function formatCommandList(commands) {
|
|
160
|
+
if (!commands.length) {
|
|
161
|
+
return [
|
|
162
|
+
'_No custom commands yet._',
|
|
163
|
+
'',
|
|
164
|
+
'Create one by adding a Markdown file:',
|
|
165
|
+
'',
|
|
166
|
+
'- `<workspace>/.codeep/commands/<name>.md` — project-scoped',
|
|
167
|
+
'- `~/.codeep/commands/<name>.md` — global (all projects)',
|
|
168
|
+
'',
|
|
169
|
+
'Example:',
|
|
170
|
+
'',
|
|
171
|
+
'```markdown',
|
|
172
|
+
'---',
|
|
173
|
+
'description: Detailed security review of a file',
|
|
174
|
+
'---',
|
|
175
|
+
'',
|
|
176
|
+
'Please perform a thorough security review of: {{args}}',
|
|
177
|
+
'```',
|
|
178
|
+
'',
|
|
179
|
+
'Then call it as `/<name> <args>`.',
|
|
180
|
+
].join('\n');
|
|
181
|
+
}
|
|
182
|
+
const projectCmds = commands.filter(c => c.scope === 'project');
|
|
183
|
+
const globalCmds = commands.filter(c => c.scope === 'global');
|
|
184
|
+
const lines = ['## Custom Commands', ''];
|
|
185
|
+
if (projectCmds.length) {
|
|
186
|
+
lines.push('**Project**');
|
|
187
|
+
for (const c of projectCmds) {
|
|
188
|
+
const aliasNote = c.aliases.length ? ` (aliases: ${c.aliases.map(a => `\`/${a}\``).join(', ')})` : '';
|
|
189
|
+
lines.push(`- \`/${c.name}\`${aliasNote} — ${c.description}`);
|
|
190
|
+
}
|
|
191
|
+
lines.push('');
|
|
192
|
+
}
|
|
193
|
+
if (globalCmds.length) {
|
|
194
|
+
lines.push('**Global**');
|
|
195
|
+
for (const c of globalCmds) {
|
|
196
|
+
const aliasNote = c.aliases.length ? ` (aliases: ${c.aliases.map(a => `\`/${a}\``).join(', ')})` : '';
|
|
197
|
+
lines.push(`- \`/${c.name}\`${aliasNote} — ${c.description}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return lines.join('\n');
|
|
201
|
+
}
|