codeep 2.14.0 → 2.15.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.
Files changed (61) hide show
  1. package/README.md +35 -24
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +13 -2
  4. package/dist/config/index.d.ts +10 -0
  5. package/dist/config/index.js +2 -2
  6. package/dist/config/providers.js +15 -10
  7. package/dist/renderer/App.d.ts +0 -30
  8. package/dist/renderer/App.js +149 -659
  9. package/dist/renderer/agentExecution.d.ts +1 -0
  10. package/dist/renderer/agentExecution.js +3 -2
  11. package/dist/renderer/commands/helpers.d.ts +63 -0
  12. package/dist/renderer/commands/helpers.js +108 -0
  13. package/dist/renderer/commands/registry.js +5 -0
  14. package/dist/renderer/commands.d.ts +4 -0
  15. package/dist/renderer/commands.js +179 -63
  16. package/dist/renderer/components/ActionFormatting.d.ts +17 -0
  17. package/dist/renderer/components/ActionFormatting.js +67 -0
  18. package/dist/renderer/components/Autocomplete.d.ts +33 -0
  19. package/dist/renderer/components/Autocomplete.js +40 -0
  20. package/dist/renderer/components/Intro.d.ts +9 -0
  21. package/dist/renderer/components/Intro.js +5 -15
  22. package/dist/renderer/components/MessageFormatter.d.ts +96 -0
  23. package/dist/renderer/components/MessageFormatter.js +375 -0
  24. package/dist/renderer/components/Permission.d.ts +4 -0
  25. package/dist/renderer/components/Permission.js +1 -1
  26. package/dist/renderer/components/Status.d.ts +4 -0
  27. package/dist/renderer/components/Status.js +2 -3
  28. package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
  29. package/dist/renderer/components/WelcomeFormatter.js +79 -0
  30. package/dist/renderer/components/uiConstants.d.ts +8 -0
  31. package/dist/renderer/components/uiConstants.js +24 -0
  32. package/dist/renderer/inputParsing.d.ts +22 -0
  33. package/dist/renderer/inputParsing.js +28 -0
  34. package/dist/renderer/layout.d.ts +215 -0
  35. package/dist/renderer/layout.js +326 -0
  36. package/dist/renderer/main.d.ts +2 -1
  37. package/dist/renderer/main.js +45 -10
  38. package/dist/renderer/ollamaHint.d.ts +12 -0
  39. package/dist/renderer/ollamaHint.js +29 -0
  40. package/dist/utils/agentChat.js +23 -1
  41. package/dist/utils/codeepCloud.d.ts +54 -0
  42. package/dist/utils/codeepCloud.js +95 -0
  43. package/dist/utils/export.d.ts +12 -0
  44. package/dist/utils/export.js +3 -3
  45. package/dist/utils/hooks.d.ts +26 -0
  46. package/dist/utils/hooks.js +69 -1
  47. package/dist/utils/keychain.js +45 -29
  48. package/dist/utils/logger.d.ts +12 -0
  49. package/dist/utils/logger.js +1 -1
  50. package/dist/utils/mcpConfig.d.ts +26 -0
  51. package/dist/utils/mcpConfig.js +109 -4
  52. package/dist/utils/skillBundles.d.ts +14 -0
  53. package/dist/utils/skillBundles.js +3 -3
  54. package/dist/utils/skillBundlesCloud.d.ts +7 -0
  55. package/dist/utils/skillBundlesCloud.js +1 -1
  56. package/dist/utils/tokenTracker.js +12 -2
  57. package/dist/utils/toolParsing.d.ts +11 -0
  58. package/dist/utils/toolParsing.js +6 -0
  59. package/dist/version.d.ts +1 -1
  60. package/dist/version.js +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,326 @@
1
+ /**
2
+ * Pure layout helpers extracted from App.ts.
3
+ *
4
+ * These functions take a *snapshot* of the App's UI state (which panels are
5
+ * open, how many items they hold) and return geometric values (heights,
6
+ * offsets) without touching `this`. Keeping them pure means they can be
7
+ * unit-tested directly — the layout math is the part of the renderer most
8
+ * prone to off-by-one regressions, and it was previously untestable because
9
+ * it was inlined in `renderChat` with `this.*` access on every line.
10
+ *
11
+ * Convention: every field on `LayoutSnapshot` is `readonly` so callers
12
+ * cannot mutate the App's real state through the snapshot.
13
+ */
14
+ /**
15
+ * Compute how many terminal rows the bottom panel (paste info, agent box,
16
+ * permission/session/confirm/search/export/login/logout/menu/settings
17
+ * dialogs, autocomplete) occupies in the current frame.
18
+ *
19
+ * Mirrors the if/else chain that used to live inline in `renderChat`.
20
+ * Returns 0 when no panel is open.
21
+ */
22
+ export function bottomPanelHeight(s) {
23
+ if (s.pasteInfoOpen) {
24
+ const previewLines = Math.min(s.pasteInfoPreviewLines, 5);
25
+ return previewLines + 6; // title + preview + extra line indicator + options
26
+ }
27
+ if (s.isAgentRunning && !s.confirmOpen) {
28
+ return 9; // Agent progress box: top + 5 log lines + stats + bottom + 1 margin
29
+ }
30
+ if (s.permissionOpen) {
31
+ return 10; // Permission dialog
32
+ }
33
+ if (s.sessionPickerOpen) {
34
+ return Math.min(s.sessionPickerItemCount + 6, 14); // Session picker
35
+ }
36
+ if (s.confirmOpen) {
37
+ return s.confirmMessageCount + 5; // title + messages + buttons + padding
38
+ }
39
+ if (s.statusOpen) {
40
+ return 16; // Status info panel
41
+ }
42
+ if (s.helpOpen) {
43
+ return Math.min(s.height - 6, 20); // Help takes more space
44
+ }
45
+ if (s.searchOpen) {
46
+ return Math.min(s.searchResultCount * 3 + 6, 18); // Search results
47
+ }
48
+ if (s.exportOpen) {
49
+ return 10; // Export dialog
50
+ }
51
+ if (s.logoutOpen) {
52
+ return Math.min(s.logoutProviderCount + 6, 12); // Logout picker
53
+ }
54
+ if (s.loginOpen) {
55
+ return s.loginStep === 'provider'
56
+ ? Math.min(s.loginProviderCount + 5, 14)
57
+ : 8; // Login dialog
58
+ }
59
+ if (s.menuOpen) {
60
+ return Math.min(s.menuItemCount + 4, 14);
61
+ }
62
+ if (s.settingsOpen) {
63
+ return Math.min(s.settingsCount + 4, 16);
64
+ }
65
+ if (s.showAutocomplete && s.autocompleteItemCount > 0) {
66
+ return Math.min(s.autocompleteItemCount + 3, 12);
67
+ }
68
+ return 0;
69
+ }
70
+ export function chatLayout(height, panelHeight) {
71
+ const mainHeight = Math.max(1, height - panelHeight);
72
+ const messagesEnd = Math.max(0, mainHeight - 4);
73
+ const separatorLine = Math.max(0, mainHeight - 3);
74
+ const inputLine = Math.max(0, mainHeight - 2);
75
+ const statusLine = Math.max(0, mainHeight - 1);
76
+ return { messagesStart: 0, messagesEnd, separatorLine, inputLine, statusLine, mainHeight };
77
+ }
78
+ /**
79
+ * Count how many terminal rows a single chat message will occupy once
80
+ * word-wrapped to `maxWidth` columns. Used by `scrollToMessage` to find
81
+ * the right scroll offset.
82
+ *
83
+ * Every message renders as: 1 header row + 1 blank row + one or more
84
+ * wrapped content rows, followed by 1 blank spacing row.
85
+ */
86
+ export function messageLineCount(content, maxWidth) {
87
+ const contentLines = content.split('\n');
88
+ let lines = 2; // Header + empty line after
89
+ for (const line of contentLines) {
90
+ lines += Math.ceil(Math.max(1, line.length) / maxWidth);
91
+ }
92
+ return lines + 1; // +1 for spacing between messages
93
+ }
94
+ /**
95
+ * Sum `messageLineCount` across a list of messages and return both the
96
+ * running total and the line offset where `targetIndex` begins. This is
97
+ * the pure core of the old `scrollToMessage` method.
98
+ */
99
+ export function messageOffsets(contents, maxWidth, targetIndex) {
100
+ let totalLines = 0;
101
+ let targetStartLine = 0;
102
+ for (let i = 0; i < contents.length; i++) {
103
+ if (i === targetIndex)
104
+ targetStartLine = totalLines;
105
+ totalLines += messageLineCount(contents[i], maxWidth);
106
+ }
107
+ return { totalLines, targetStartLine };
108
+ }
109
+ /**
110
+ * Compute the scroll offset that places the target message roughly in
111
+ * the middle of the visible window.
112
+ */
113
+ export function scrollOffsetForTarget(totalLines, targetStartLine, visibleLines) {
114
+ return Math.max(0, totalLines - targetStartLine - Math.floor(visibleLines / 2));
115
+ }
116
+ /**
117
+ * Compute the visible window of a chat transcript given the current scroll
118
+ * offset. Returns the [startIndex, endIndex) slice into the all-lines array
119
+ * and, as a side-effect contract, the clamped scroll offset the caller
120
+ * should store (the renderer overwrites `this.scrollOffset` with this).
121
+ *
122
+ * Extracted from `getVisibleMessages` so the off-by-one-prone scroll math
123
+ * has direct unit tests.
124
+ */
125
+ export function scrollWindow(args) {
126
+ const maxScroll = Math.max(0, args.totalLines - args.height);
127
+ const clampedScrollOffset = Math.min(args.scrollOffset, maxScroll);
128
+ const endIndex = args.totalLines - clampedScrollOffset;
129
+ const startIndex = Math.max(0, endIndex - args.height);
130
+ return { startIndex, endIndex, clampedScrollOffset };
131
+ }
132
+ // ─── Agent progress bar ───────────────────────────────────────────────────────
133
+ //
134
+ // `renderInlineAgentProgress` builds a gradient progress bar from block
135
+ // characters (░▒▓█) when the agent has a known iteration budget. The bar
136
+ // construction is pure string math; extracting it makes the gradient
137
+ // thresholds testable without a Screen mock.
138
+ /** Render a gradient progress bar of the given width for the given ratio. */
139
+ export function agentProgressBar(iteration, maxIterations, barWidth) {
140
+ // Avoid Infinity when maxIterations is 0 — show an empty bar instead.
141
+ const progress = maxIterations > 0 ? Math.min(iteration / maxIterations, 1) : 0;
142
+ const filled = Math.round(progress * barWidth);
143
+ let bar = '';
144
+ for (let i = 0; i < barWidth; i++) {
145
+ if (i < filled - 1)
146
+ bar += '█';
147
+ else if (i === filled - 1)
148
+ bar += '▓';
149
+ else if (i === filled)
150
+ bar += '▒';
151
+ else
152
+ bar += '░';
153
+ }
154
+ return bar;
155
+ }
156
+ // ─── Notification truncation ──────────────────────────────────────────────────
157
+ //
158
+ // `renderStatusBar` truncates the notification string to fit the terminal
159
+ // width with an ellipsis. The truncation rule is pure.
160
+ /** Truncate `text` to `maxLen` columns, appending an ellipsis if it doesn’t fit. */
161
+ export function truncateNotification(text, maxLen) {
162
+ return text.length > maxLen ? text.slice(0, maxLen - 1) + '…' : text;
163
+ }
164
+ /** Threshold above which a paste is considered "large" and shows a dialog. */
165
+ export const PASTE_DIALOG_THRESHOLD = { chars: 100, lines: 3 };
166
+ /** True when the paste is large enough to warrant the confirm dialog. */
167
+ export function shouldShowPasteDialog(text) {
168
+ const chars = text.length;
169
+ const lines = text.split('\n').length;
170
+ return chars >= PASTE_DIALOG_THRESHOLD.chars || lines > PASTE_DIALOG_THRESHOLD.lines;
171
+ }
172
+ /** Build the PasteInfo struct for a large paste (preview truncated to 200 chars). */
173
+ export function buildPasteInfo(text) {
174
+ const preview = text.length > 200 ? text.slice(0, 197) + '...' : text;
175
+ return {
176
+ chars: text.length,
177
+ lines: text.split('\n').length,
178
+ preview,
179
+ fullText: text,
180
+ };
181
+ }
182
+ // ─── Status-bar formatting ────────────────────────────────────────────────────
183
+ //
184
+ // Small pure helpers used by `renderStatusBar`. Extracted so the
185
+ // formatting rules (token compacting, context-sensitive right hint) are
186
+ // unit-testable instead of buried in a 60-line screen-painting method.
187
+ /**
188
+ * Compact a raw token count into the short string shown in the status bar
189
+ * ("123", "1.2K", "12.3K"). Returns an empty string when tokens is 0 so
190
+ * the caller can omit the segment entirely.
191
+ */
192
+ export function formatTokenCount(tokens) {
193
+ if (tokens <= 0)
194
+ return '';
195
+ if (tokens < 1000)
196
+ return String(tokens);
197
+ return (tokens / 1000).toFixed(1) + 'K';
198
+ }
199
+ /**
200
+ * Pick the context-sensitive hint shown at the right edge of the status
201
+ * bar. The "new messages below" badge takes priority when the user has
202
+ * scrolled up — otherwise the hint depends on whether work is in flight.
203
+ */
204
+ export function statusBarRightHint(args) {
205
+ if (args.scrollOffset > 0 && args.unseenWhileScrolled > 0) {
206
+ return `↓ ${args.unseenWhileScrolled} new · PgDn `;
207
+ }
208
+ return args.isStreaming || args.isLoading ? 'Esc to stop ' : '/help · ↑↓ history ';
209
+ }
210
+ /**
211
+ * Return the highest-priority open panel. `chat` is the fallback when no
212
+ * panel is open. The order matches the if/else chain that used to live in
213
+ * `handleChatKey`.
214
+ */
215
+ export function activePanel(s) {
216
+ if (s.pasteInfoOpen)
217
+ return 'pasteInfo';
218
+ if (s.permissionOpen)
219
+ return 'permission';
220
+ if (s.sessionPickerOpen)
221
+ return 'sessionPicker';
222
+ if (s.confirmOpen)
223
+ return 'confirm';
224
+ if (s.statusOpen)
225
+ return 'status';
226
+ if (s.helpOpen)
227
+ return 'help';
228
+ if (s.settingsOpen)
229
+ return 'settings';
230
+ if (s.searchOpen)
231
+ return 'search';
232
+ if (s.exportOpen)
233
+ return 'export';
234
+ if (s.logoutOpen)
235
+ return 'logout';
236
+ if (s.loginOpen)
237
+ return 'login';
238
+ if (s.menuOpen)
239
+ return 'menu';
240
+ if (s.showAutocomplete)
241
+ return 'autocomplete';
242
+ return 'chat';
243
+ }
244
+ // ─── Input-line display ───────────────────────────────────────────────────────
245
+ //
246
+ // `renderInput` builds the text and cursor position for the bottom input
247
+ // row. The geometry (which slice of a long line to show, where the cursor
248
+ // lands within that slice, how the prompt symbol scales with multi-line
249
+ // mode) is pure and was previously inlined alongside screen-write calls.
250
+ // Extracting it makes the truncation/scroll behaviour unit-testable.
251
+ /** Multiplier that controls how far from the left edge the cursor sits. */
252
+ const INPUT_CURSOR_ANCHOR = 0.7;
253
+ /**
254
+ * Compute the prompt symbol for the current state. Multi-line content
255
+ * shows a `[n] ❯ ` prefix with the line count; otherwise `❯❯ ` in
256
+ * multi-line mode, or the plain `❯ `.
257
+ */
258
+ export function inputPromptSymbol(value, isMultilineMode) {
259
+ const lineCount = value.split('\n').length;
260
+ if (lineCount > 1)
261
+ return `[${lineCount}] ❯ `;
262
+ return isMultilineMode ? '❯❯ ' : '❯ ';
263
+ }
264
+ /**
265
+ * Compute the visible slice of a long input line, plus the cursor column
266
+ * it maps to. Mirrors the inline logic that used to live in `renderInput`:
267
+ * when the line fits, show it whole; otherwise anchor the cursor at 70%
268
+ * of the available width and slide the viewport.
269
+ */
270
+ export function inputViewport(args) {
271
+ const { line, cursorInLine, maxInputWidth } = args;
272
+ if (line.length <= maxInputWidth) {
273
+ return { displayValue: line, cursorOffset: Math.max(0, cursorInLine) };
274
+ }
275
+ const effectiveCursor = Math.max(0, cursorInLine);
276
+ const visibleStart = Math.max(0, effectiveCursor - Math.floor(maxInputWidth * INPUT_CURSOR_ANCHOR));
277
+ const visibleEnd = visibleStart + maxInputWidth;
278
+ let displayValue;
279
+ if (visibleStart > 0) {
280
+ displayValue = '…' + line.slice(visibleStart + 1, visibleEnd);
281
+ }
282
+ else {
283
+ displayValue = line.slice(0, maxInputWidth);
284
+ }
285
+ return { displayValue, cursorOffset: effectiveCursor - visibleStart };
286
+ }
287
+ /**
288
+ * Top-level entry point used by `renderInput`. Produces the prompt symbol,
289
+ * the visible text, the absolute cursor X, and the placeholder.
290
+ */
291
+ export function computeInputDisplay(opts) {
292
+ const promptSymbol = inputPromptSymbol(opts.value, opts.isMultilineMode);
293
+ const maxInputWidth = opts.width - promptSymbol.length - 1;
294
+ const isEmpty = opts.value.length === 0;
295
+ const placeholder = opts.isMultilineMode
296
+ ? 'Multi-line mode Enter=newline · Esc=send'
297
+ : 'Message or /command';
298
+ if (isEmpty) {
299
+ return {
300
+ promptSymbol,
301
+ displayValue: '',
302
+ cursorX: promptSymbol.length,
303
+ placeholder,
304
+ isEmpty,
305
+ };
306
+ }
307
+ // For multi-line content, show the last line being edited.
308
+ const lines = opts.value.split('\n');
309
+ const lineCount = lines.length;
310
+ const lastLine = lines[lines.length - 1];
311
+ const displayInput = lineCount > 1 ? lastLine : opts.value;
312
+ const charsBeforeLastLine = lineCount > 1 ? opts.value.lastIndexOf('\n') + 1 : 0;
313
+ const cursorInLine = opts.cursorPos - charsBeforeLastLine;
314
+ const { displayValue, cursorOffset } = inputViewport({
315
+ line: displayInput,
316
+ cursorInLine,
317
+ maxInputWidth,
318
+ });
319
+ return {
320
+ promptSymbol,
321
+ displayValue,
322
+ cursorX: promptSymbol.length + cursorOffset,
323
+ placeholder,
324
+ isEmpty,
325
+ };
326
+ }
@@ -5,4 +5,5 @@
5
5
  * This file contains only startup/init logic. Command dispatch lives in
6
6
  * commands.ts and agent execution in agentExecution.ts.
7
7
  */
8
- export {};
8
+ /** Derive a short display name from a user message (first ~5 words, max 48 chars). */
9
+ export declare function deriveSessionName(message: string): string;
@@ -33,7 +33,7 @@ let app;
33
33
  let sessionDisplayName = null;
34
34
  const addedFiles = new Map();
35
35
  /** Derive a short display name from a user message (first ~5 words, max 48 chars). */
36
- function deriveSessionName(message) {
36
+ export function deriveSessionName(message) {
37
37
  const clean = message.replace(/\s+/g, ' ').trim();
38
38
  const words = clean.split(' ').slice(0, 5).join(' ');
39
39
  return words.length > 48 ? words.slice(0, 45) + '…' : words;
@@ -706,18 +706,53 @@ Commands (in chat):
706
706
  if (projectPath) {
707
707
  (async () => {
708
708
  try {
709
- const { loadMcpServerConfig } = await import('../utils/mcpConfig.js');
709
+ const { loadMcpServerConfigSplit, isWorkspaceMcpTrusted, trustWorkspaceMcp } = await import('../utils/mcpConfig.js');
710
710
  const { registerSessionServers } = await import('../utils/mcpRegistry.js');
711
- const servers = loadMcpServerConfig(projectPath);
712
- if (servers.length === 0)
711
+ const { global: globalServers, workspace: workspaceServers } = loadMcpServerConfigSplit(projectPath);
712
+ const spawnServers = async (servers) => {
713
+ if (servers.length === 0)
714
+ return;
715
+ const { registered, errors } = await registerSessionServers('codeep-tui', servers, { workspaceRoot: projectPath });
716
+ if (registered.length > 0) {
717
+ app.notify(`MCP: ${registered.length} tool(s) from ${servers.length} server(s) ready. Type /mcp.`);
718
+ }
719
+ for (const e of errors) {
720
+ app.notifyWarn(`MCP server "${e.server}" failed: ${e.error}`);
721
+ }
722
+ };
723
+ // ~/.codeep servers are the user's own machine-wide config — spawn.
724
+ await spawnServers(globalServers);
725
+ // Workspace files (.codeep/mcp_servers.json, .mcp.json) travel WITH
726
+ // the repo — a cloned project could otherwise execute arbitrary
727
+ // commands at startup. One-time per-workspace approval, mirroring
728
+ // the trustedHookProjects gate for hooks.
729
+ if (workspaceServers.length === 0)
730
+ return;
731
+ if (isWorkspaceMcpTrusted(projectPath)) {
732
+ await spawnServers(workspaceServers);
713
733
  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
734
  }
735
+ const preview = workspaceServers.slice(0, 5).map(s => ` ${s.name}: ${s.command ? [s.command, ...(s.args ?? [])].join(' ') : s.url ?? ''}`);
736
+ if (workspaceServers.length > 5)
737
+ preview.push(` …and ${workspaceServers.length - 5} more`);
738
+ app.showConfirm({
739
+ title: 'Trust workspace MCP servers?',
740
+ message: [
741
+ `This workspace defines ${workspaceServers.length} MCP server(s) that run as local processes:`,
742
+ ...preview,
743
+ '',
744
+ 'Only start them if you trust this repo — they run with your permissions.',
745
+ ],
746
+ confirmLabel: 'Trust & start',
747
+ cancelLabel: 'Not now',
748
+ onConfirm: () => {
749
+ trustWorkspaceMcp(projectPath);
750
+ void spawnServers(workspaceServers);
751
+ },
752
+ onCancel: () => {
753
+ app.notify('Workspace MCP servers skipped. Run /mcp trust to enable them.');
754
+ },
755
+ });
721
756
  }
722
757
  catch {
723
758
  // 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
+ }
@@ -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
- const content = readFileSync(filePath, 'utf-8').trim();
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;