codeep 2.14.0 → 2.16.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/README.md +47 -27
- package/dist/acp/commands.js +22 -1
- package/dist/acp/server.js +13 -2
- package/dist/acp/session.js +22 -1
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +2 -2
- package/dist/config/providers.js +35 -24
- package/dist/renderer/App.d.ts +77 -30
- package/dist/renderer/App.js +429 -659
- package/dist/renderer/agentExecution.d.ts +1 -0
- package/dist/renderer/agentExecution.js +3 -2
- package/dist/renderer/commands/helpers.d.ts +251 -0
- package/dist/renderer/commands/helpers.js +450 -0
- package/dist/renderer/commands/registry.js +7 -1
- package/dist/renderer/commands.d.ts +4 -0
- package/dist/renderer/commands.js +363 -318
- package/dist/renderer/components/ActionFormatting.d.ts +17 -0
- package/dist/renderer/components/ActionFormatting.js +67 -0
- package/dist/renderer/components/Autocomplete.d.ts +58 -0
- package/dist/renderer/components/Autocomplete.js +75 -0
- package/dist/renderer/components/Intro.d.ts +9 -0
- package/dist/renderer/components/Intro.js +5 -15
- package/dist/renderer/components/MessageFormatter.d.ts +96 -0
- package/dist/renderer/components/MessageFormatter.js +375 -0
- package/dist/renderer/components/Permission.d.ts +4 -0
- package/dist/renderer/components/Permission.js +1 -1
- package/dist/renderer/components/Status.d.ts +4 -0
- package/dist/renderer/components/Status.js +2 -3
- package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
- package/dist/renderer/components/WelcomeFormatter.js +79 -0
- package/dist/renderer/components/uiConstants.d.ts +8 -0
- package/dist/renderer/components/uiConstants.js +24 -0
- package/dist/renderer/inputParsing.d.ts +22 -0
- package/dist/renderer/inputParsing.js +28 -0
- package/dist/renderer/layout.d.ts +219 -0
- package/dist/renderer/layout.js +338 -0
- package/dist/renderer/main.d.ts +2 -1
- package/dist/renderer/main.js +79 -11
- package/dist/renderer/ollamaHint.d.ts +12 -0
- package/dist/renderer/ollamaHint.js +29 -0
- package/dist/utils/agentChat.js +23 -1
- package/dist/utils/codeepCloud.d.ts +54 -0
- package/dist/utils/codeepCloud.js +95 -0
- package/dist/utils/diffPreview.d.ts +31 -0
- package/dist/utils/diffPreview.js +102 -0
- package/dist/utils/export.d.ts +12 -0
- package/dist/utils/export.js +3 -3
- package/dist/utils/git.d.ts +28 -0
- package/dist/utils/git.js +111 -1
- package/dist/utils/hooks.d.ts +26 -0
- package/dist/utils/hooks.js +69 -1
- package/dist/utils/keychain.js +45 -29
- package/dist/utils/logger.d.ts +12 -0
- package/dist/utils/logger.js +1 -1
- package/dist/utils/mcpConfig.d.ts +26 -0
- package/dist/utils/mcpConfig.js +109 -4
- package/dist/utils/mentions.d.ts +195 -0
- package/dist/utils/mentions.js +672 -0
- package/dist/utils/skillBundles.d.ts +14 -0
- package/dist/utils/skillBundles.js +3 -3
- package/dist/utils/skillBundlesCloud.d.ts +7 -0
- package/dist/utils/skillBundlesCloud.js +1 -1
- package/dist/utils/tokenTracker.js +21 -5
- package/dist/utils/toolParsing.d.ts +11 -0
- package/dist/utils/toolParsing.js +6 -0
- package/dist/utils/webFetch.d.ts +101 -0
- package/dist/utils/webFetch.js +375 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/renderer/main.js
CHANGED
|
@@ -20,6 +20,8 @@ import { getSessionStats, getCostBreakdown } from '../utils/tokenTracker.js';
|
|
|
20
20
|
import { isGitRepository } from '../utils/git.js';
|
|
21
21
|
import { reportStats, syncSession, generateProjectId } from '../utils/codeepCloud.js';
|
|
22
22
|
import { checkApiRateLimit } from '../utils/ratelimit.js';
|
|
23
|
+
import { expandFileAndFolderMentions, expandGitMentions } from '../utils/mentions.js';
|
|
24
|
+
import { expandWebMentions } from '../utils/webFetch.js';
|
|
23
25
|
import { handleCommand as dispatchCommand } from './commands.js';
|
|
24
26
|
import { logAppError } from '../utils/logger.js';
|
|
25
27
|
import { executeAgentTask, runAgentTask, } from './agentExecution.js';
|
|
@@ -33,7 +35,7 @@ let app;
|
|
|
33
35
|
let sessionDisplayName = null;
|
|
34
36
|
const addedFiles = new Map();
|
|
35
37
|
/** Derive a short display name from a user message (first ~5 words, max 48 chars). */
|
|
36
|
-
function deriveSessionName(message) {
|
|
38
|
+
export function deriveSessionName(message) {
|
|
37
39
|
const clean = message.replace(/\s+/g, ' ').trim();
|
|
38
40
|
const words = clean.split(' ').slice(0, 5).join(' ');
|
|
39
41
|
return words.length > 48 ? words.slice(0, 45) + '…' : words;
|
|
@@ -163,11 +165,41 @@ async function handleSubmit(message) {
|
|
|
163
165
|
try {
|
|
164
166
|
app.startStreaming();
|
|
165
167
|
const history = app.getChatHistory();
|
|
168
|
+
// Expand inline @-mentions (@src/file.ts) into the prompt's context.
|
|
169
|
+
// Done after history capture (mentions are per-message) but before
|
|
170
|
+
// deriveSessionName so the title reflects what the user typed, not the
|
|
171
|
+
// expanded path.
|
|
172
|
+
const mentionRoot = projectContext?.root || projectPath || process.cwd();
|
|
173
|
+
// Expand @folder and @file mentions in one pass, merged into a single
|
|
174
|
+
// [Attached files] block.
|
|
175
|
+
const { enrichedPrompt: fileExpanded, loaded: loadedMentions, failures: mentionFailures } = expandFileAndFolderMentions(message, { root: mentionRoot });
|
|
176
|
+
if (loadedMentions.length > 0) {
|
|
177
|
+
app.notify(`Loaded ${loadedMentions.length} file(s) from @mentions/@folder`);
|
|
178
|
+
}
|
|
179
|
+
for (const f of mentionFailures) {
|
|
180
|
+
app.notify(`${f.mention}: ${f.reason}`);
|
|
181
|
+
}
|
|
182
|
+
// Expand @git <ref> mentions — resolve diffs / file-at-ref / commit
|
|
183
|
+
// patches into a [Git ref] block. Runs between file and web mentions
|
|
184
|
+
// so the prompt flows: [files] [git] [web] <text>.
|
|
185
|
+
const { enrichedPrompt: gitExpanded, failures: gitFailures } = await expandGitMentions(fileExpanded, { root: mentionRoot });
|
|
186
|
+
for (const f of gitFailures) {
|
|
187
|
+
app.notify(`${f.mention}: ${f.reason}`);
|
|
188
|
+
}
|
|
189
|
+
// Expand @web <url> mentions — fetch each page and prepend its text.
|
|
190
|
+
// Runs after file mentions so the prompt flows: [files] [web] <text>.
|
|
191
|
+
const { enrichedPrompt: webExpanded, loaded: loadedPages, failures: webFailures } = await expandWebMentions(gitExpanded);
|
|
192
|
+
if (loadedPages.length > 0) {
|
|
193
|
+
app.notify(`Fetched ${loadedPages.length} page(s) from @web`);
|
|
194
|
+
}
|
|
195
|
+
for (const f of webFailures) {
|
|
196
|
+
app.notify(`${f.mention}: ${f.reason}`);
|
|
197
|
+
}
|
|
166
198
|
if (!sessionDisplayName && history.filter(m => m.role === 'user').length === 0) {
|
|
167
199
|
sessionDisplayName = deriveSessionName(message);
|
|
168
200
|
}
|
|
169
201
|
const fileContext = formatAddedFilesContext();
|
|
170
|
-
const enrichedMessage = fileContext ? fileContext +
|
|
202
|
+
const enrichedMessage = fileContext ? fileContext + webExpanded : webExpanded;
|
|
171
203
|
await chat(enrichedMessage, history, (chunk) => app.addStreamChunk(chunk), undefined, projectContext, undefined);
|
|
172
204
|
app.endStreaming();
|
|
173
205
|
autoSaveSession(app.getMessages(), projectPath);
|
|
@@ -621,6 +653,7 @@ Commands (in chat):
|
|
|
621
653
|
getStatus,
|
|
622
654
|
hasWriteAccess: () => hasWriteAccess,
|
|
623
655
|
hasProjectContext: () => projectContext !== null,
|
|
656
|
+
getProjectRoot: () => projectContext?.root || projectPath || process.cwd(),
|
|
624
657
|
});
|
|
625
658
|
const provider = getCurrentProvider();
|
|
626
659
|
const providers = getProviderList();
|
|
@@ -706,18 +739,53 @@ Commands (in chat):
|
|
|
706
739
|
if (projectPath) {
|
|
707
740
|
(async () => {
|
|
708
741
|
try {
|
|
709
|
-
const {
|
|
742
|
+
const { loadMcpServerConfigSplit, isWorkspaceMcpTrusted, trustWorkspaceMcp } = await import('../utils/mcpConfig.js');
|
|
710
743
|
const { registerSessionServers } = await import('../utils/mcpRegistry.js');
|
|
711
|
-
const
|
|
712
|
-
|
|
744
|
+
const { global: globalServers, workspace: workspaceServers } = loadMcpServerConfigSplit(projectPath);
|
|
745
|
+
const spawnServers = async (servers) => {
|
|
746
|
+
if (servers.length === 0)
|
|
747
|
+
return;
|
|
748
|
+
const { registered, errors } = await registerSessionServers('codeep-tui', servers, { workspaceRoot: projectPath });
|
|
749
|
+
if (registered.length > 0) {
|
|
750
|
+
app.notify(`MCP: ${registered.length} tool(s) from ${servers.length} server(s) ready. Type /mcp.`);
|
|
751
|
+
}
|
|
752
|
+
for (const e of errors) {
|
|
753
|
+
app.notifyWarn(`MCP server "${e.server}" failed: ${e.error}`);
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
// ~/.codeep servers are the user's own machine-wide config — spawn.
|
|
757
|
+
await spawnServers(globalServers);
|
|
758
|
+
// Workspace files (.codeep/mcp_servers.json, .mcp.json) travel WITH
|
|
759
|
+
// the repo — a cloned project could otherwise execute arbitrary
|
|
760
|
+
// commands at startup. One-time per-workspace approval, mirroring
|
|
761
|
+
// the trustedHookProjects gate for hooks.
|
|
762
|
+
if (workspaceServers.length === 0)
|
|
763
|
+
return;
|
|
764
|
+
if (isWorkspaceMcpTrusted(projectPath)) {
|
|
765
|
+
await spawnServers(workspaceServers);
|
|
713
766
|
return;
|
|
714
|
-
const { registered, errors } = await registerSessionServers('codeep-tui', servers, { workspaceRoot: projectPath });
|
|
715
|
-
if (registered.length > 0) {
|
|
716
|
-
app.notify(`MCP: ${registered.length} tool(s) from ${servers.length} server(s) ready. Type /mcp.`);
|
|
717
|
-
}
|
|
718
|
-
for (const e of errors) {
|
|
719
|
-
app.notifyWarn(`MCP server "${e.server}" failed: ${e.error}`);
|
|
720
767
|
}
|
|
768
|
+
const preview = workspaceServers.slice(0, 5).map(s => ` ${s.name}: ${s.command ? [s.command, ...(s.args ?? [])].join(' ') : s.url ?? ''}`);
|
|
769
|
+
if (workspaceServers.length > 5)
|
|
770
|
+
preview.push(` …and ${workspaceServers.length - 5} more`);
|
|
771
|
+
app.showConfirm({
|
|
772
|
+
title: 'Trust workspace MCP servers?',
|
|
773
|
+
message: [
|
|
774
|
+
`This workspace defines ${workspaceServers.length} MCP server(s) that run as local processes:`,
|
|
775
|
+
...preview,
|
|
776
|
+
'',
|
|
777
|
+
'Only start them if you trust this repo — they run with your permissions.',
|
|
778
|
+
],
|
|
779
|
+
confirmLabel: 'Trust & start',
|
|
780
|
+
cancelLabel: 'Not now',
|
|
781
|
+
onConfirm: () => {
|
|
782
|
+
trustWorkspaceMcp(projectPath);
|
|
783
|
+
void spawnServers(workspaceServers);
|
|
784
|
+
},
|
|
785
|
+
onCancel: () => {
|
|
786
|
+
app.notify('Workspace MCP servers skipped. Run /mcp trust to enable them.');
|
|
787
|
+
},
|
|
788
|
+
});
|
|
721
789
|
}
|
|
722
790
|
catch {
|
|
723
791
|
// Loading MCP must never block the TUI.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return a UI hint for an Ollama model id, based on its parameter count.
|
|
3
|
+
*
|
|
4
|
+
* Examples:
|
|
5
|
+
* - `7b`, `8b`, `14b`, `72b` → `'✓ agent mode'`
|
|
6
|
+
* - `1.5b`, `3b` → `'⚠ chat only (< 7B)'`
|
|
7
|
+
* - `custom-name` (no size) → `''` (no hint)
|
|
8
|
+
*
|
|
9
|
+
* The parameter count is parsed from the first `<number>b` token in the
|
|
10
|
+
* id (case-insensitive), so both `qwen3:14b` and `llama2-13b` work.
|
|
11
|
+
*/
|
|
12
|
+
export declare function ollamaModelHint(modelId: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama model-size hint.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `commands.ts` so the size threshold + label rule can be
|
|
5
|
+
* unit-tested. Maps a model id like `qwen3:14b` to a UI hint telling the
|
|
6
|
+
* user whether the model is agent-capable (≥ 7B params) or chat-only.
|
|
7
|
+
*/
|
|
8
|
+
const AGENT_MIN_PARAMS_B = 7;
|
|
9
|
+
/**
|
|
10
|
+
* Return a UI hint for an Ollama model id, based on its parameter count.
|
|
11
|
+
*
|
|
12
|
+
* Examples:
|
|
13
|
+
* - `7b`, `8b`, `14b`, `72b` → `'✓ agent mode'`
|
|
14
|
+
* - `1.5b`, `3b` → `'⚠ chat only (< 7B)'`
|
|
15
|
+
* - `custom-name` (no size) → `''` (no hint)
|
|
16
|
+
*
|
|
17
|
+
* The parameter count is parsed from the first `<number>b` token in the
|
|
18
|
+
* id (case-insensitive), so both `qwen3:14b` and `llama2-13b` work.
|
|
19
|
+
*/
|
|
20
|
+
export function ollamaModelHint(modelId) {
|
|
21
|
+
const lower = modelId.toLowerCase();
|
|
22
|
+
const match = lower.match(/(\d+(?:\.\d+)?)b/);
|
|
23
|
+
if (!match)
|
|
24
|
+
return '';
|
|
25
|
+
const params = parseFloat(match[1]);
|
|
26
|
+
if (params >= AGENT_MIN_PARAMS_B)
|
|
27
|
+
return '✓ agent mode';
|
|
28
|
+
return '⚠ chat only (< 7B)';
|
|
29
|
+
}
|
package/dist/utils/agentChat.js
CHANGED
|
@@ -43,15 +43,37 @@ export class TimeoutError extends Error {
|
|
|
43
43
|
* Load project rules from .codeep/rules.md or CODEEP.md
|
|
44
44
|
*/
|
|
45
45
|
export function loadProjectRules(projectRoot) {
|
|
46
|
+
// Lookup precedence (highest first):
|
|
47
|
+
// 1. .codeep/rules.md — Codeep-native, committed with the repo
|
|
48
|
+
// 2. CODEEP.md — Codeep-native, root-level convenience
|
|
49
|
+
// 3. AGENTS.md — cross-tool standard (Claude Code, Cursor,
|
|
50
|
+
// Kilo Code). Read so users coming from those
|
|
51
|
+
// tools don't have to duplicate their rules.
|
|
52
|
+
//
|
|
53
|
+
// First non-empty file wins. We deliberately don't concatenate: when two
|
|
54
|
+
// files exist, the Codeep-native one is authoritative (a user who keeps
|
|
55
|
+
// both probably has a trimmed AGENTS.md for the other tools and a richer
|
|
56
|
+
// CODEEP-specific rules file here).
|
|
46
57
|
const candidates = [
|
|
47
58
|
join(projectRoot, '.codeep', 'rules.md'),
|
|
48
59
|
join(projectRoot, 'CODEEP.md'),
|
|
60
|
+
join(projectRoot, 'AGENTS.md'),
|
|
49
61
|
];
|
|
62
|
+
// Rules ride EVERY system prompt, so cap the injected size — an oversized
|
|
63
|
+
// file (AGENTS.md files from other tools can grow unbounded) would bloat
|
|
64
|
+
// every request's token bill and can push small-context models over their
|
|
65
|
+
// limit. 64KB ≈ 16k tokens is far above any sane rules file.
|
|
66
|
+
const MAX_RULES_BYTES = 64 * 1024;
|
|
50
67
|
for (const filePath of candidates) {
|
|
51
68
|
if (existsSync(filePath)) {
|
|
52
69
|
try {
|
|
53
|
-
|
|
70
|
+
let content = readFileSync(filePath, 'utf-8').trim();
|
|
54
71
|
if (content) {
|
|
72
|
+
if (content.length > MAX_RULES_BYTES) {
|
|
73
|
+
debug('Project rules truncated', filePath, `${content.length} > ${MAX_RULES_BYTES}`);
|
|
74
|
+
content = content.slice(0, MAX_RULES_BYTES)
|
|
75
|
+
+ '\n\n[Rules truncated by Codeep — file exceeds the 64KB inline limit.]';
|
|
76
|
+
}
|
|
55
77
|
debug('Loaded project rules from', filePath);
|
|
56
78
|
return `\n\n## Project Rules\nThe following rules are defined by the project owner. You MUST follow these rules:\n\n${content}`;
|
|
57
79
|
}
|
|
@@ -77,6 +77,13 @@ export declare function pushKeys(keys: Record<string, string>): Promise<boolean>
|
|
|
77
77
|
* later wants them off the server. Returns true on success.
|
|
78
78
|
*/
|
|
79
79
|
export declare function purgeKeys(): Promise<boolean>;
|
|
80
|
+
declare function globalDir(kind: 'personalities' | 'commands'): string;
|
|
81
|
+
/** Read every <name>.md in a global config dir into a { name → body } map. */
|
|
82
|
+
declare function readFileBundle(kind: 'personalities' | 'commands'): Record<string, string>;
|
|
83
|
+
/** Write a { name → body } map into a global config dir as <name>.md
|
|
84
|
+
* files. Only writes files that don't already exist (additive merge —
|
|
85
|
+
* never clobber local edits). Returns the count of newly written files. */
|
|
86
|
+
declare function writeFileBundle(kind: 'personalities' | 'commands', items: Record<string, string>): number;
|
|
80
87
|
export declare const pullPersonalities: () => Promise<number | null>;
|
|
81
88
|
export declare const pushPersonalities: () => Promise<number | null>;
|
|
82
89
|
export declare const pullCommands: () => Promise<number | null>;
|
|
@@ -106,6 +113,49 @@ export declare function syncSessionAsync(payload: {
|
|
|
106
113
|
content: string;
|
|
107
114
|
}[];
|
|
108
115
|
}): Promise<void>;
|
|
116
|
+
/** Summary of a remote session — no messages, just metadata for listing. */
|
|
117
|
+
export interface CloudSessionSummary {
|
|
118
|
+
sessionId: string;
|
|
119
|
+
sessionName: string | null;
|
|
120
|
+
projectName: string | null;
|
|
121
|
+
projectId: string | null;
|
|
122
|
+
messageCount: number;
|
|
123
|
+
updatedAt: string;
|
|
124
|
+
}
|
|
125
|
+
/** Full remote session — messages included (fetched on demand by id). */
|
|
126
|
+
export interface CloudSession extends CloudSessionSummary {
|
|
127
|
+
messages: {
|
|
128
|
+
role: string;
|
|
129
|
+
content: string;
|
|
130
|
+
}[];
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* List the user's cloud sessions (summaries only — no message bodies).
|
|
134
|
+
*
|
|
135
|
+
* The server supports three scopes via the `projectId` query param:
|
|
136
|
+
* - omitted → all sessions for the user
|
|
137
|
+
* - "none" → only personal (no-project) sessions
|
|
138
|
+
* - <id> → sessions scoped to that project
|
|
139
|
+
*
|
|
140
|
+
* Returns null if not linked or on network/server error. The caller decides
|
|
141
|
+
* how to surface that (silently skip vs. notify).
|
|
142
|
+
*
|
|
143
|
+
* `telemetry` is NOT consulted here — reading your own previously-pushed
|
|
144
|
+
* data back is not telemetry, and the user is explicitly asking for it
|
|
145
|
+
* (via /cloud). The original push was already gated.
|
|
146
|
+
*/
|
|
147
|
+
export declare function listCloudSessions(projectId?: string): Promise<CloudSessionSummary[] | null>;
|
|
148
|
+
/**
|
|
149
|
+
* Fetch a single cloud session by id, including the full message array.
|
|
150
|
+
*
|
|
151
|
+
* Used by `/cloud` → pick → resume: we pull the messages and write them
|
|
152
|
+
* into the local `.codeep/sessions/` store via `saveSession`, so the
|
|
153
|
+
* resumed session behaves identically to a locally-created one (shows
|
|
154
|
+
* up in `/sessions`, survives restarts, re-syncs on next change).
|
|
155
|
+
*
|
|
156
|
+
* Returns null if not linked, not found (404), or network/server error.
|
|
157
|
+
*/
|
|
158
|
+
export declare function pullCloudSession(sessionId: string): Promise<CloudSession | null>;
|
|
109
159
|
/**
|
|
110
160
|
* Sync progress.md content to codeep.dev.
|
|
111
161
|
* Fire-and-forget. Only sends if linked (githubId + syncToken).
|
|
@@ -128,3 +178,7 @@ export declare function pushUserProfile(): Promise<boolean>;
|
|
|
128
178
|
* exists. Returns 1 if written, 0 if skipped, null on error / not linked. */
|
|
129
179
|
export declare function pullUserProfile(): Promise<number | null>;
|
|
130
180
|
export declare function syncMemoryNotes(projectName: string, notes: string[]): Promise<void>;
|
|
181
|
+
export declare const _globalDirForTest: typeof globalDir;
|
|
182
|
+
export declare const _readFileBundleForTest: typeof readFileBundle;
|
|
183
|
+
export declare const _writeFileBundleForTest: typeof writeFileBundle;
|
|
184
|
+
export {};
|
|
@@ -372,6 +372,95 @@ export async function syncSessionAsync(payload) {
|
|
|
372
372
|
body: JSON.stringify({ ...payload, messages: filtered, githubId }),
|
|
373
373
|
});
|
|
374
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* List the user's cloud sessions (summaries only — no message bodies).
|
|
377
|
+
*
|
|
378
|
+
* The server supports three scopes via the `projectId` query param:
|
|
379
|
+
* - omitted → all sessions for the user
|
|
380
|
+
* - "none" → only personal (no-project) sessions
|
|
381
|
+
* - <id> → sessions scoped to that project
|
|
382
|
+
*
|
|
383
|
+
* Returns null if not linked or on network/server error. The caller decides
|
|
384
|
+
* how to surface that (silently skip vs. notify).
|
|
385
|
+
*
|
|
386
|
+
* `telemetry` is NOT consulted here — reading your own previously-pushed
|
|
387
|
+
* data back is not telemetry, and the user is explicitly asking for it
|
|
388
|
+
* (via /cloud). The original push was already gated.
|
|
389
|
+
*/
|
|
390
|
+
export async function listCloudSessions(projectId) {
|
|
391
|
+
const syncToken = getSyncToken();
|
|
392
|
+
if (!syncToken)
|
|
393
|
+
return null;
|
|
394
|
+
const url = new URL(`${API_BASE}/api/sessions`);
|
|
395
|
+
if (projectId)
|
|
396
|
+
url.searchParams.set('projectId', projectId);
|
|
397
|
+
try {
|
|
398
|
+
const res = await fetch(url.toString(), {
|
|
399
|
+
headers: { 'x-sync-token': syncToken },
|
|
400
|
+
});
|
|
401
|
+
if (!res.ok)
|
|
402
|
+
return null;
|
|
403
|
+
// Server responses are untrusted input — validate the shape instead of
|
|
404
|
+
// casting, so a malformed/hostile payload degrades to null (the normal
|
|
405
|
+
// "unavailable" path) rather than throwing deep inside the /cloud picker.
|
|
406
|
+
// Note `!== true` + Array.isArray also normalizes an absent field to
|
|
407
|
+
// null (a bare `data.ok ? … : null` would leak `undefined` past the
|
|
408
|
+
// caller's `=== null` check).
|
|
409
|
+
const data = await res.json();
|
|
410
|
+
if (data?.ok !== true || !Array.isArray(data.sessions))
|
|
411
|
+
return null;
|
|
412
|
+
return data.sessions.filter((s) => {
|
|
413
|
+
const c = s;
|
|
414
|
+
return !!c && typeof c === 'object' && typeof c.sessionId === 'string' && c.sessionId.length > 0;
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Fetch a single cloud session by id, including the full message array.
|
|
423
|
+
*
|
|
424
|
+
* Used by `/cloud` → pick → resume: we pull the messages and write them
|
|
425
|
+
* into the local `.codeep/sessions/` store via `saveSession`, so the
|
|
426
|
+
* resumed session behaves identically to a locally-created one (shows
|
|
427
|
+
* up in `/sessions`, survives restarts, re-syncs on next change).
|
|
428
|
+
*
|
|
429
|
+
* Returns null if not linked, not found (404), or network/server error.
|
|
430
|
+
*/
|
|
431
|
+
export async function pullCloudSession(sessionId) {
|
|
432
|
+
const syncToken = getSyncToken();
|
|
433
|
+
if (!syncToken)
|
|
434
|
+
return null;
|
|
435
|
+
try {
|
|
436
|
+
const url = new URL(`${API_BASE}/api/sessions`);
|
|
437
|
+
url.searchParams.set('id', sessionId);
|
|
438
|
+
const res = await fetch(url.toString(), {
|
|
439
|
+
headers: { 'x-sync-token': syncToken },
|
|
440
|
+
});
|
|
441
|
+
if (!res.ok)
|
|
442
|
+
return null;
|
|
443
|
+
// Untrusted input — validate before it flows into the local session
|
|
444
|
+
// store. Messages are filtered to well-formed {role, content} string
|
|
445
|
+
// pairs; anything else is dropped rather than persisted.
|
|
446
|
+
const data = await res.json();
|
|
447
|
+
if (data?.ok !== true || !data.session || typeof data.session !== 'object')
|
|
448
|
+
return null;
|
|
449
|
+
const s = data.session;
|
|
450
|
+
if (typeof s.sessionId !== 'string' || s.sessionId.length === 0)
|
|
451
|
+
return null;
|
|
452
|
+
if (!Array.isArray(s.messages))
|
|
453
|
+
return null;
|
|
454
|
+
const messages = s.messages.filter((m) => {
|
|
455
|
+
const c = m;
|
|
456
|
+
return !!c && typeof c === 'object' && typeof c.role === 'string' && typeof c.content === 'string';
|
|
457
|
+
});
|
|
458
|
+
return { ...s, messages };
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
375
464
|
// ─── Progress log sync ────────────────────────────────────────────────────────
|
|
376
465
|
/**
|
|
377
466
|
* Sync progress.md content to codeep.dev.
|
|
@@ -549,3 +638,9 @@ async function fetchWithRetry(url, options, maxRetries = 2) {
|
|
|
549
638
|
}
|
|
550
639
|
return null;
|
|
551
640
|
}
|
|
641
|
+
// Test seams — these helpers are otherwise file-private; export them under
|
|
642
|
+
// a `_forTest` suffix so the bundle read/write logic can be exercised
|
|
643
|
+
// directly without going through the network round-trip.
|
|
644
|
+
export const _globalDirForTest = globalDir;
|
|
645
|
+
export const _readFileBundleForTest = readFileBundle;
|
|
646
|
+
export const _writeFileBundleForTest = writeFileBundle;
|
|
@@ -55,3 +55,34 @@ export declare function formatDiffPreview(diffs: FileDiff[]): string;
|
|
|
55
55
|
* Calculate diff statistics
|
|
56
56
|
*/
|
|
57
57
|
export declare function getDiffStats(diffs: FileDiff[]): DiffPreviewResult;
|
|
58
|
+
/**
|
|
59
|
+
* Apply a subset of a file diff's hunks to the original content.
|
|
60
|
+
*
|
|
61
|
+
* Hunk indices in `acceptedHunks` refer to positions in `diff.hunks`
|
|
62
|
+
* (0-based). Hunks not in the set are skipped — their original lines
|
|
63
|
+
* stay, their additions are dropped.
|
|
64
|
+
*
|
|
65
|
+
* Returns the resulting file content. The caller writes it to disk.
|
|
66
|
+
*
|
|
67
|
+
* For `type === 'create'`, the whole file is either accepted (any hunk
|
|
68
|
+
* accepted) or rejected (empty set) — there's no original to merge
|
|
69
|
+
* against. For `type === 'delete'`, accepting any hunk deletes the file.
|
|
70
|
+
*/
|
|
71
|
+
export declare function applyHunks(diff: FileDiff, acceptedHunks: Set<number>): string;
|
|
72
|
+
/**
|
|
73
|
+
* Apply accepted hunks across multiple file diffs and return the
|
|
74
|
+
* resulting content for each. The caller writes the files to disk.
|
|
75
|
+
*
|
|
76
|
+
* `accepted` maps file path → set of accepted hunk indices. Files not
|
|
77
|
+
* in the map are skipped entirely.
|
|
78
|
+
*/
|
|
79
|
+
export declare function applyHunksToFiles(diffs: FileDiff[], accepted: Map<string, Set<number>>): Array<{
|
|
80
|
+
path: string;
|
|
81
|
+
content: string;
|
|
82
|
+
type: FileDiff['type'];
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Count how many hunks in a diff contain actual changes (not just
|
|
86
|
+
* context). Used to label hunks in the UI ("hunk 2/5").
|
|
87
|
+
*/
|
|
88
|
+
export declare function countChangeHunks(diff: FileDiff): number;
|
|
@@ -404,3 +404,105 @@ export function getDiffStats(diffs) {
|
|
|
404
404
|
totalFiles: diffs.length,
|
|
405
405
|
};
|
|
406
406
|
}
|
|
407
|
+
// ─── Selective apply (per-hunk accept/reject) ───────────────────────────────
|
|
408
|
+
/**
|
|
409
|
+
* Apply a subset of a file diff's hunks to the original content.
|
|
410
|
+
*
|
|
411
|
+
* Hunk indices in `acceptedHunks` refer to positions in `diff.hunks`
|
|
412
|
+
* (0-based). Hunks not in the set are skipped — their original lines
|
|
413
|
+
* stay, their additions are dropped.
|
|
414
|
+
*
|
|
415
|
+
* Returns the resulting file content. The caller writes it to disk.
|
|
416
|
+
*
|
|
417
|
+
* For `type === 'create'`, the whole file is either accepted (any hunk
|
|
418
|
+
* accepted) or rejected (empty set) — there's no original to merge
|
|
419
|
+
* against. For `type === 'delete'`, accepting any hunk deletes the file.
|
|
420
|
+
*/
|
|
421
|
+
export function applyHunks(diff, acceptedHunks) {
|
|
422
|
+
// Create: accept-all-or-nothing — there's no original content to
|
|
423
|
+
// selectively merge into.
|
|
424
|
+
if (diff.type === 'create') {
|
|
425
|
+
return acceptedHunks.size > 0 ? (diff.newContent ?? '') : (diff.oldContent ?? '');
|
|
426
|
+
}
|
|
427
|
+
const oldLines = (diff.oldContent ?? '').split('\n');
|
|
428
|
+
// No accepted hunks → original content unchanged.
|
|
429
|
+
if (acceptedHunks.size === 0) {
|
|
430
|
+
return oldLines.join('\n');
|
|
431
|
+
}
|
|
432
|
+
const acceptedHunkList = diff.hunks
|
|
433
|
+
.map((h, i) => ({ hunk: h, index: i }))
|
|
434
|
+
.filter(({ index }) => acceptedHunks.has(index));
|
|
435
|
+
// Per-original-line union model.
|
|
436
|
+
//
|
|
437
|
+
// The obvious implementation — walk the hunks in order, copying original
|
|
438
|
+
// lines between them and replaying each hunk's line list — is wrong for
|
|
439
|
+
// hunks that sit close together, because unified-diff context OVERLAPS:
|
|
440
|
+
// hunk N's trailing context is hunk N+1's leading context, so the shared
|
|
441
|
+
// lines get emitted twice. Worse, with a small gap a later hunk's `remove`
|
|
442
|
+
// can fall *inside* an earlier hunk's already-emitted context, so the
|
|
443
|
+
// deletion is silently lost. Both corrupt the user's file on `/apply`.
|
|
444
|
+
//
|
|
445
|
+
// Instead reduce every accepted hunk to two facts per original line — is it
|
|
446
|
+
// removed, and what is inserted after it — then rebuild the file once.
|
|
447
|
+
// Overlap becomes a set union rather than double emission, and the result
|
|
448
|
+
// is independent of hunk order.
|
|
449
|
+
const removed = new Set(); // old line numbers deleted
|
|
450
|
+
const insertAfter = new Map(); // old line number → inserted lines (0 = file head)
|
|
451
|
+
for (const { hunk } of acceptedHunkList) {
|
|
452
|
+
// Adds before any context in this hunk belong right before its start,
|
|
453
|
+
// not at the top of the file.
|
|
454
|
+
let anchor = Math.max(0, hunk.oldStart - 1);
|
|
455
|
+
for (const line of hunk.lines) {
|
|
456
|
+
if (line.type === 'add') {
|
|
457
|
+
const at = insertAfter.get(anchor);
|
|
458
|
+
if (at)
|
|
459
|
+
at.push(line.content);
|
|
460
|
+
else
|
|
461
|
+
insertAfter.set(anchor, [line.content]);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (line.oldLineNum === undefined)
|
|
465
|
+
continue;
|
|
466
|
+
anchor = line.oldLineNum;
|
|
467
|
+
if (line.type === 'remove')
|
|
468
|
+
removed.add(line.oldLineNum);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const result = [];
|
|
472
|
+
for (const head of insertAfter.get(0) ?? [])
|
|
473
|
+
result.push(head);
|
|
474
|
+
for (let n = 1; n <= oldLines.length; n++) {
|
|
475
|
+
if (!removed.has(n))
|
|
476
|
+
result.push(oldLines[n - 1]);
|
|
477
|
+
const added = insertAfter.get(n);
|
|
478
|
+
if (added)
|
|
479
|
+
for (const line of added)
|
|
480
|
+
result.push(line);
|
|
481
|
+
}
|
|
482
|
+
return result.join('\n');
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Apply accepted hunks across multiple file diffs and return the
|
|
486
|
+
* resulting content for each. The caller writes the files to disk.
|
|
487
|
+
*
|
|
488
|
+
* `accepted` maps file path → set of accepted hunk indices. Files not
|
|
489
|
+
* in the map are skipped entirely.
|
|
490
|
+
*/
|
|
491
|
+
export function applyHunksToFiles(diffs, accepted) {
|
|
492
|
+
const results = [];
|
|
493
|
+
for (const diff of diffs) {
|
|
494
|
+
const acceptedSet = accepted.get(diff.path);
|
|
495
|
+
if (!acceptedSet)
|
|
496
|
+
continue;
|
|
497
|
+
const content = applyHunks(diff, acceptedSet);
|
|
498
|
+
results.push({ path: diff.path, content, type: diff.type });
|
|
499
|
+
}
|
|
500
|
+
return results;
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Count how many hunks in a diff contain actual changes (not just
|
|
504
|
+
* context). Used to label hunks in the UI ("hunk 2/5").
|
|
505
|
+
*/
|
|
506
|
+
export function countChangeHunks(diff) {
|
|
507
|
+
return diff.hunks.filter((h) => h.lines.some((l) => l.type === 'add' || l.type === 'remove')).length;
|
|
508
|
+
}
|
package/dist/utils/export.d.ts
CHANGED
|
@@ -5,6 +5,18 @@ export interface ExportOptions {
|
|
|
5
5
|
sessionName?: string;
|
|
6
6
|
timestamp?: string;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Export messages to Markdown format
|
|
10
|
+
*/
|
|
11
|
+
export declare function exportToMarkdown(messages: Message[], sessionName?: string): string;
|
|
12
|
+
/**
|
|
13
|
+
* Export messages to JSON format
|
|
14
|
+
*/
|
|
15
|
+
export declare function exportToJson(messages: Message[], sessionName?: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Export messages to plain text format
|
|
18
|
+
*/
|
|
19
|
+
export declare function exportToText(messages: Message[], sessionName?: string): string;
|
|
8
20
|
/**
|
|
9
21
|
* Export messages to specified format
|
|
10
22
|
*/
|
package/dist/utils/export.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
/**
|
|
4
4
|
* Export messages to Markdown format
|
|
5
5
|
*/
|
|
6
|
-
function exportToMarkdown(messages, sessionName) {
|
|
6
|
+
export function exportToMarkdown(messages, sessionName) {
|
|
7
7
|
const timestamp = new Date().toLocaleString('hr-HR');
|
|
8
8
|
let markdown = `# Codeep Chat Export\n\n`;
|
|
9
9
|
if (sessionName) {
|
|
@@ -24,7 +24,7 @@ function exportToMarkdown(messages, sessionName) {
|
|
|
24
24
|
/**
|
|
25
25
|
* Export messages to JSON format
|
|
26
26
|
*/
|
|
27
|
-
function exportToJson(messages, sessionName) {
|
|
27
|
+
export function exportToJson(messages, sessionName) {
|
|
28
28
|
const exportData = {
|
|
29
29
|
session: sessionName || 'Unnamed',
|
|
30
30
|
exportedAt: new Date().toISOString(),
|
|
@@ -36,7 +36,7 @@ function exportToJson(messages, sessionName) {
|
|
|
36
36
|
/**
|
|
37
37
|
* Export messages to plain text format
|
|
38
38
|
*/
|
|
39
|
-
function exportToText(messages, sessionName) {
|
|
39
|
+
export function exportToText(messages, sessionName) {
|
|
40
40
|
const timestamp = new Date().toLocaleString('hr-HR');
|
|
41
41
|
let text = `Codeep Chat Export\n`;
|
|
42
42
|
text += `===================\n\n`;
|
package/dist/utils/git.d.ts
CHANGED
|
@@ -83,3 +83,31 @@ export declare function createBranchAndCommit(prompt: string, actions: ActionLog
|
|
|
83
83
|
hash?: string;
|
|
84
84
|
error?: string;
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* Result of resolving a `@git <ref>` mention.
|
|
88
|
+
*/
|
|
89
|
+
export interface GitContentResult {
|
|
90
|
+
success: boolean;
|
|
91
|
+
/** Raw output from git (diff text, file content, or commit metadata). */
|
|
92
|
+
content: string;
|
|
93
|
+
/** A short label for the [Attached files]-style block header. */
|
|
94
|
+
label: string;
|
|
95
|
+
error?: string;
|
|
96
|
+
}
|
|
97
|
+
/** Max bytes we'll inline from a single `@git` mention. */
|
|
98
|
+
export declare const MAX_GIT_BYTES: number;
|
|
99
|
+
export declare function isSafeGitRef(token: string): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Resolve a `@git <ref>` mention to inline content. The `ref` can be:
|
|
102
|
+
*
|
|
103
|
+
* - `diff` — unstaged changes (`git diff`)
|
|
104
|
+
* - `diff --staged` — staged changes (`git diff --cached`)
|
|
105
|
+
* - `diff a..b` — diff between two refs (`git diff a..b`)
|
|
106
|
+
* - `HEAD` — the latest commit's full diff vs its parent
|
|
107
|
+
* - `<sha>` — a specific commit's patch (`git show <sha>`)
|
|
108
|
+
* - `<ref>:<path>` — a file at a ref (`git show main:src/x.ts`)
|
|
109
|
+
* - `<ref>` — any other git ref → `git show`
|
|
110
|
+
*
|
|
111
|
+
* Sync (spawn-based) so it slots into the mention-expansion pipeline.
|
|
112
|
+
*/
|
|
113
|
+
export declare function getGitContent(ref: string, cwd?: string): GitContentResult;
|