codeep 2.0.4 → 2.1.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 +29 -5
- package/dist/acp/commands.js +23 -0
- package/dist/acp/server.js +1 -0
- package/dist/api/index.js +35 -15
- package/dist/config/index.d.ts +31 -0
- package/dist/config/index.js +126 -5
- package/dist/config/providers.js +19 -0
- package/dist/renderer/App.js +3 -0
- package/dist/renderer/commands.js +70 -2
- package/dist/renderer/components/Help.js +4 -1
- package/dist/renderer/components/Settings.js +16 -0
- package/dist/renderer/main.js +36 -5
- package/dist/utils/agentChat.js +12 -13
- package/dist/utils/codeepCloud.d.ts +4 -0
- package/dist/utils/codeepCloud.js +96 -0
- package/dist/utils/recall.d.ts +41 -0
- package/dist/utils/recall.js +150 -0
- package/dist/utils/sessionTitles.d.ts +25 -0
- package/dist/utils/sessionTitles.js +58 -0
- package/dist/utils/taskPlanner.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ custom slash commands, lifecycle hooks, checkpoints, `/cost`,
|
|
|
56
56
|
- **MiniMax** — MiniMax M2.7, M2.5, M2.1, M2 — Coding Plan & pay-per-use API (international & China)
|
|
57
57
|
- **Ollama** — Run any model locally or on a remote server, no API key required. Models are fetched dynamically from your Ollama instance.
|
|
58
58
|
- **OpenRouter** — One key, 100+ models from Anthropic, OpenAI, Google, Meta, Mistral, DeepSeek, Qwen, xAI and more. Per-call cost reported directly by OpenRouter (matches their dashboard exactly). Use `openrouter/auto` to let OpenRouter pick the best model. Tune routing with `/openrouter prefer|ignore|fallbacks|privacy`.
|
|
59
|
+
- **Custom (OpenAI-compatible)** — Point Codeep at any self-hosted or proxied OpenAI-compatible endpoint (vLLM, LiteLLM, LM Studio, text-generation-webui). Set the base URL in `/settings` → **Custom Base URL** (config key `customBaseUrl`, e.g. `http://host:8000/v1`), then pick your model with `/model` (fetched from the server's `/models`). No API key required unless your endpoint enforces one. The `openai` provider also honors the `OPENAI_BASE_URL` env var for proxies that serve `gpt-*` model names.
|
|
59
60
|
- Switch between providers with `/provider`
|
|
60
61
|
- Configure different API keys per provider
|
|
61
62
|
- Both OpenAI-compatible and Anthropic API protocols supported
|
|
@@ -73,7 +74,9 @@ When started in a project directory, Codeep automatically:
|
|
|
73
74
|
- **Session picker** - Choose which session to continue on startup
|
|
74
75
|
- **Per-project sessions** - Sessions stored in `.codeep/sessions/`
|
|
75
76
|
- **Rename sessions** - Give meaningful names with `/rename`
|
|
76
|
-
- **
|
|
77
|
+
- **AI session titles** - Sessions auto-title themselves with an LLM one-liner ("OAuth2 migration for auth module") instead of a truncated first message. This makes one small background API call per session; turn it off with the `autoSessionTitle` setting (`/settings`) if you prefer zero unsolicited calls
|
|
78
|
+
- **Search current session** - Find text in the open conversation with `/search`
|
|
79
|
+
- **Cross-session recall** - `/recall <query>` searches across **all** saved sessions, ranked by relevance + recency. Add `--resume` to load the top match, or `--summarize` for an LLM recap of what you accomplished across matches
|
|
77
80
|
- **Export** - Save to Markdown, JSON, or plain text
|
|
78
81
|
- `/cost` - Per-session token usage and estimated cost (per provider/model)
|
|
79
82
|
- `/compact [keepN]` - AI-summarize older messages to free up context (keeps last N, default 4)
|
|
@@ -709,6 +712,7 @@ codeep account # Opens browser → sign in with GitHub → CLI is linked
|
|
|
709
712
|
- **Project archiving** — hide projects from the list with one click
|
|
710
713
|
- **Tasks** — create/complete bug, feature, and task items from the web or directly from the CLI with `/tasks add` and `/tasks done`
|
|
711
714
|
- **API key sync** — store provider keys securely on codeep.dev, sync to any machine in one command
|
|
715
|
+
- **Personal config sync** — personalities and custom slash commands sync across machines; view and prune them on the dashboard
|
|
712
716
|
- **Connected devices** — see all machines linked to your account (hostname, last seen), revoke access per device
|
|
713
717
|
|
|
714
718
|
### API key sync
|
|
@@ -716,12 +720,32 @@ codeep account # Opens browser → sign in with GitHub → CLI is linked
|
|
|
716
720
|
Add keys once on the dashboard, then sync them to any machine:
|
|
717
721
|
|
|
718
722
|
```bash
|
|
719
|
-
codeep account sync # Pull keys from codeep.dev → local
|
|
720
|
-
codeep account push # Push local keys → codeep.dev
|
|
723
|
+
codeep account sync # Pull keys + config from codeep.dev → local
|
|
724
|
+
codeep account push # Push local keys + config → codeep.dev
|
|
721
725
|
```
|
|
722
726
|
|
|
723
727
|
Keys are encrypted at rest using AES-256-GCM.
|
|
724
728
|
|
|
729
|
+
### Personal config sync
|
|
730
|
+
|
|
731
|
+
`account sync` / `account push` also carry your **personalities** and **custom
|
|
732
|
+
slash commands** between machines — the Markdown files in
|
|
733
|
+
`~/.codeep/personalities/` and `~/.codeep/commands/`:
|
|
734
|
+
|
|
735
|
+
```bash
|
|
736
|
+
codeep account push # Upload local personalities + commands to codeep.dev
|
|
737
|
+
codeep account sync # Download them onto a new machine
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
Merging is **additive** — `sync` only writes files that don't already exist
|
|
741
|
+
locally, so it never clobbers a personality or command you've edited on this
|
|
742
|
+
machine. View what's synced (and prune stale entries) under **Personalities**
|
|
743
|
+
and **Custom commands** on the [dashboard](https://codeep.dev/dashboard).
|
|
744
|
+
|
|
745
|
+
Hooks and MCP server configs are deliberately **not** synced: hooks run
|
|
746
|
+
arbitrary shell, and MCP configs often embed tokens, so both stay local to each
|
|
747
|
+
machine.
|
|
748
|
+
|
|
725
749
|
### Tasks
|
|
726
750
|
|
|
727
751
|
Create, view, and complete tasks directly from the CLI — or manage them on the codeep.dev dashboard:
|
|
@@ -973,8 +997,8 @@ In `dangerous` mode, configure which tools require confirmation via `/settings`:
|
|
|
973
997
|
| Command | Description |
|
|
974
998
|
|---------|-------------|
|
|
975
999
|
| `codeep account` | Link CLI to codeep.dev (GitHub OAuth) |
|
|
976
|
-
| `codeep account sync` | Pull API keys from codeep.dev → local
|
|
977
|
-
| `codeep account push` | Push local API keys → codeep.dev |
|
|
1000
|
+
| `codeep account sync` | Pull API keys + personalities + commands from codeep.dev → local |
|
|
1001
|
+
| `codeep account push` | Push local API keys + personalities + commands → codeep.dev |
|
|
978
1002
|
|
|
979
1003
|
### Authentication
|
|
980
1004
|
|
package/dist/acp/commands.js
CHANGED
|
@@ -600,6 +600,29 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
|
|
|
600
600
|
}
|
|
601
601
|
}
|
|
602
602
|
// ─── Export ────────────────────────────────────────────────────────────────
|
|
603
|
+
// ─── Cross-session recall (2.1.0) ─────────────────────────────────────────
|
|
604
|
+
case 'recall': {
|
|
605
|
+
const wantSummarize = args.includes('--summarize');
|
|
606
|
+
// --resume is TUI-only (ACP can't swap the client's conversation
|
|
607
|
+
// in place); ignore the flag here and just show results.
|
|
608
|
+
const query = args.filter(a => a !== '--resume' && a !== '--summarize').join(' ');
|
|
609
|
+
if (!query) {
|
|
610
|
+
return { handled: true, response: 'Usage: `/recall <query> [--summarize]` — searches across all saved sessions (vs `/search`, current-session only).' };
|
|
611
|
+
}
|
|
612
|
+
const { recallSessions, formatRecall, summarizeRecall } = await import('../utils/recall.js');
|
|
613
|
+
const matches = recallSessions(query, session.workspaceRoot);
|
|
614
|
+
const header = formatRecall(query, matches);
|
|
615
|
+
if (wantSummarize && matches.length > 0) {
|
|
616
|
+
onChunk('_Summarizing matching sessions…_\n\n');
|
|
617
|
+
const summary = await summarizeRecall(query, matches, session.workspaceRoot);
|
|
618
|
+
return {
|
|
619
|
+
handled: true,
|
|
620
|
+
response: summary ? `${header}\n\n---\n\n### Summary\n\n${summary}` : header,
|
|
621
|
+
streaming: true,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
return { handled: true, response: header };
|
|
625
|
+
}
|
|
603
626
|
// ─── Personalities + insights (2.0.3) ─────────────────────────────────────
|
|
604
627
|
case 'personality': {
|
|
605
628
|
const { formatPersonalityList, findPersonality } = await import('../utils/personalities.js');
|
package/dist/acp/server.js
CHANGED
|
@@ -35,6 +35,7 @@ const AVAILABLE_COMMANDS = [
|
|
|
35
35
|
// Sessions
|
|
36
36
|
{ name: 'session', description: 'List sessions, or: new / load <name>', input: { hint: 'new | load <name>' } },
|
|
37
37
|
{ name: 'save', description: 'Save current session', input: { hint: '[name]' } },
|
|
38
|
+
{ name: 'recall', description: 'Search across ALL saved sessions (cross-session)', input: { hint: '<query> [--summarize]' } },
|
|
38
39
|
// Context
|
|
39
40
|
{ name: 'add', description: 'Add files to agent context', input: { hint: '<file> [file2…]' } },
|
|
40
41
|
{ name: 'drop', description: 'Remove files from context (no args = clear all)', input: { hint: '[file…]' } },
|
package/dist/api/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as http from 'node:http';
|
|
2
2
|
import * as https from 'node:https';
|
|
3
|
-
import { config, getApiKey } from '../config/index.js';
|
|
3
|
+
import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
|
|
4
4
|
import { withRetry, isNetworkError } from '../utils/retry.js';
|
|
5
5
|
import { getProvider, getProviderBaseUrl, getProviderAuthHeader, usesMaxCompletionTokens, requiresDefaultTemperature } from '../config/providers.js';
|
|
6
6
|
import { logApiRequest, logApiResponse } from '../utils/logger.js';
|
|
@@ -233,15 +233,42 @@ const LANGUAGE_NAMES = {
|
|
|
233
233
|
'ru': 'Russian (Русский)',
|
|
234
234
|
'hr': 'Croatian (Hrvatski)',
|
|
235
235
|
};
|
|
236
|
+
/**
|
|
237
|
+
* Build the agent's identity sentence from the active provider + model.
|
|
238
|
+
* Codeep is the product; the model underneath varies. Stating it
|
|
239
|
+
* explicitly stops models from guessing their own identity (GLM and
|
|
240
|
+
* others often claim to be Claude because their training data includes
|
|
241
|
+
* Claude transcripts).
|
|
242
|
+
*/
|
|
243
|
+
function buildIdentityLine() {
|
|
244
|
+
const model = String(config.get('model') || '');
|
|
245
|
+
const providerId = String(config.get('provider') || '');
|
|
246
|
+
const known = {
|
|
247
|
+
'z.ai': 'Z.AI', 'z.ai-api': 'Z.AI', 'z.ai-cn': 'Z.AI', 'z.ai-cn-api': 'Z.AI',
|
|
248
|
+
openai: 'OpenAI', anthropic: 'Anthropic', deepseek: 'DeepSeek', google: 'Google',
|
|
249
|
+
minimax: 'MiniMax', 'minimax-api': 'MiniMax', 'minimax-cn': 'MiniMax',
|
|
250
|
+
openrouter: 'OpenRouter', ollama: 'Ollama (local)',
|
|
251
|
+
};
|
|
252
|
+
const providerName = known[providerId] || providerId || 'your configured provider';
|
|
253
|
+
if (model) {
|
|
254
|
+
return `You are Codeep, an AI coding assistant. You are running on the \`${model}\` model via ${providerName}. If asked which model or provider you are, answer truthfully with these details — do not claim to be a different model.`;
|
|
255
|
+
}
|
|
256
|
+
return `You are Codeep, an AI coding assistant.`;
|
|
257
|
+
}
|
|
236
258
|
function getSystemPrompt() {
|
|
237
259
|
const language = config.get('language');
|
|
260
|
+
// Identity line so the model doesn't hallucinate its name when asked
|
|
261
|
+
// "what model are you" — without it, models guess from their training
|
|
262
|
+
// data (e.g. GLM claiming to be Claude). State the truth: the product
|
|
263
|
+
// is Codeep, the underlying model + provider come from config.
|
|
264
|
+
const identity = buildIdentityLine();
|
|
238
265
|
let basePrompt;
|
|
239
266
|
if (language === 'auto') {
|
|
240
|
-
basePrompt =
|
|
267
|
+
basePrompt = `${identity} Always respond in the same language as the user's message. Detect the language of the user's input and reply in that same language.`;
|
|
241
268
|
}
|
|
242
269
|
else {
|
|
243
270
|
const langName = LANGUAGE_NAMES[language] || 'English';
|
|
244
|
-
basePrompt =
|
|
271
|
+
basePrompt = `${identity} Always respond in ${langName}, regardless of what language the user writes in.`;
|
|
245
272
|
}
|
|
246
273
|
// Important: This is CHAT mode, not agent mode
|
|
247
274
|
// The model should NOT pretend to execute tools or create files
|
|
@@ -309,14 +336,11 @@ async function chatOpenAI(message, history, model, apiKey, onChunk, abortSignal)
|
|
|
309
336
|
const timeout = config.get('apiTimeout');
|
|
310
337
|
const temperature = config.get('temperature');
|
|
311
338
|
const maxTokens = config.get('maxTokens');
|
|
312
|
-
// Get provider-specific URL and auth
|
|
339
|
+
// Get provider-specific URL and auth. resolveBaseUrl applies user
|
|
340
|
+
// overrides: Ollama (ollamaUrl), Custom (customBaseUrl), and OpenAI
|
|
341
|
+
// (OPENAI_BASE_URL env) — so self-hosted / OpenAI-compatible endpoints work.
|
|
313
342
|
const providerId = config.get('provider');
|
|
314
|
-
let baseUrl =
|
|
315
|
-
// For Ollama, use the configured URL (can't use lazy require in ESM providers.ts)
|
|
316
|
-
if (providerId === 'ollama') {
|
|
317
|
-
const ollamaUrl = (config.get('ollamaUrl') || 'http://localhost:11434').replace(/\/$/, '');
|
|
318
|
-
baseUrl = `${ollamaUrl}/v1`;
|
|
319
|
-
}
|
|
343
|
+
let baseUrl = resolveBaseUrl(providerId, 'openai');
|
|
320
344
|
const authHeader = getProviderAuthHeader(providerId, 'openai');
|
|
321
345
|
const useCompletionTokens = usesMaxCompletionTokens(providerId);
|
|
322
346
|
const omitTemperature = requiresDefaultTemperature(providerId);
|
|
@@ -720,11 +744,7 @@ export async function validateApiKey(apiKey, providerId) {
|
|
|
720
744
|
}
|
|
721
745
|
// Determine which protocol to use for validation
|
|
722
746
|
const protocol = providerConfig.defaultProtocol;
|
|
723
|
-
|
|
724
|
-
if (provider === 'ollama' && protocol === 'openai') {
|
|
725
|
-
const ollamaUrl = (config.get('ollamaUrl') || 'http://localhost:11434').replace(/\/$/, '');
|
|
726
|
-
baseUrl = `${ollamaUrl}/v1`;
|
|
727
|
-
}
|
|
747
|
+
const baseUrl = resolveBaseUrl(provider, protocol);
|
|
728
748
|
const authHeader = getProviderAuthHeader(provider, protocol);
|
|
729
749
|
const model = providerConfig.defaultModel;
|
|
730
750
|
if (!baseUrl) {
|
package/dist/config/index.d.ts
CHANGED
|
@@ -23,6 +23,10 @@ interface ConfigSchema {
|
|
|
23
23
|
plan: 'lite' | 'pro' | 'max';
|
|
24
24
|
language: LanguageCode;
|
|
25
25
|
autoSave: boolean;
|
|
26
|
+
/** Auto-generate an LLM one-liner title for sessions on save. Makes a
|
|
27
|
+
* small background API call (uses the active model) once per session.
|
|
28
|
+
* Default true; set false to avoid any unsolicited API calls. */
|
|
29
|
+
autoSessionTitle: boolean;
|
|
26
30
|
currentSessionId: string;
|
|
27
31
|
temperature: number;
|
|
28
32
|
maxTokens: number;
|
|
@@ -31,6 +35,7 @@ interface ConfigSchema {
|
|
|
31
35
|
rateLimitCommands: number;
|
|
32
36
|
agentMode: AgentMode;
|
|
33
37
|
ollamaUrl: string;
|
|
38
|
+
customBaseUrl: string;
|
|
34
39
|
agentConfirmation: 'always' | 'dangerous' | 'never';
|
|
35
40
|
agentConfirmDeleteFile: boolean;
|
|
36
41
|
agentConfirmExecuteCommand: boolean;
|
|
@@ -130,12 +135,38 @@ export declare function fetchOllamaModels(baseUrl?: string): Promise<{
|
|
|
130
135
|
name: string;
|
|
131
136
|
description: string;
|
|
132
137
|
}[] | null>;
|
|
138
|
+
/**
|
|
139
|
+
* Fetch the model list from an OpenAI-compatible server's `/models`
|
|
140
|
+
* endpoint (vLLM, LiteLLM, LM Studio, etc.). `baseUrl` is the full base
|
|
141
|
+
* (e.g. http://host:8000/v1). Returns null on error.
|
|
142
|
+
*/
|
|
143
|
+
export declare function fetchOpenAiCompatibleModels(baseUrl: string, apiKey?: string): Promise<{
|
|
144
|
+
id: string;
|
|
145
|
+
name: string;
|
|
146
|
+
description: string;
|
|
147
|
+
}[] | null>;
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the effective OpenAI-protocol base URL for a provider, honoring
|
|
150
|
+
* user overrides the static provider table can't express:
|
|
151
|
+
* - ollama → configured `ollamaUrl` + /v1
|
|
152
|
+
* - custom → configured `customBaseUrl` (full base, e.g. http://host:8000/v1)
|
|
153
|
+
* - openai → the OPENAI_BASE_URL env var, if set (OpenAI-SDK convention)
|
|
154
|
+
* Falls back to the provider's hardcoded base URL. Only the `openai`
|
|
155
|
+
* protocol takes overrides; the anthropic protocol uses the static table.
|
|
156
|
+
*/
|
|
157
|
+
export declare function resolveBaseUrl(providerId: string, protocol: 'openai' | 'anthropic'): string | null;
|
|
133
158
|
export { PROVIDERS } from './providers';
|
|
134
159
|
export declare function getCurrentSessionId(): string;
|
|
135
160
|
export declare function startNewSession(): string;
|
|
136
161
|
export declare function autoSaveSession(history: Message[], projectPath?: string): boolean;
|
|
137
162
|
export declare function flushAutoSave(): boolean;
|
|
138
163
|
export declare function saveSession(name: string, history: Message[], projectPath?: string): boolean;
|
|
164
|
+
/**
|
|
165
|
+
* Generate and persist an AI title for a session, if it doesn't have
|
|
166
|
+
* one yet. Safe to call repeatedly — early-returns when aiTitle exists
|
|
167
|
+
* or a generation is already in flight.
|
|
168
|
+
*/
|
|
169
|
+
export declare function maybeGenerateSessionTitle(name: string, projectPath?: string): Promise<void>;
|
|
139
170
|
export declare function loadSession(name: string, projectPath?: string): Message[] | null;
|
|
140
171
|
export declare function listSessions(projectPath?: string): string[];
|
|
141
172
|
export declare function deleteSession(name: string, projectPath?: string): boolean;
|
package/dist/config/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import Conf from 'conf';
|
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from 'fs';
|
|
3
3
|
import { join, dirname } from 'path';
|
|
4
4
|
import { randomUUID } from 'crypto';
|
|
5
|
-
import { PROVIDERS, getProvider } from './providers.js';
|
|
5
|
+
import { PROVIDERS, getProvider, getProviderBaseUrl } from './providers.js';
|
|
6
6
|
import { logSession } from '../utils/logger.js';
|
|
7
7
|
// We'll initialize GLOBAL_SESSIONS_DIR after config is created (to use config.path)
|
|
8
8
|
/**
|
|
@@ -144,6 +144,7 @@ function createConfig() {
|
|
|
144
144
|
model: 'glm-5.1',
|
|
145
145
|
agentMode: 'on',
|
|
146
146
|
ollamaUrl: 'http://localhost:11434',
|
|
147
|
+
customBaseUrl: '',
|
|
147
148
|
agentConfirmation: 'dangerous',
|
|
148
149
|
agentConfirmDeleteFile: true,
|
|
149
150
|
agentConfirmExecuteCommand: true,
|
|
@@ -165,6 +166,7 @@ function createConfig() {
|
|
|
165
166
|
plan: 'lite',
|
|
166
167
|
language: 'en',
|
|
167
168
|
autoSave: true,
|
|
169
|
+
autoSessionTitle: true,
|
|
168
170
|
currentSessionId: '',
|
|
169
171
|
temperature: 0.7,
|
|
170
172
|
maxTokens: 32768,
|
|
@@ -530,6 +532,59 @@ export async function fetchOllamaModels(baseUrl) {
|
|
|
530
532
|
return null;
|
|
531
533
|
}
|
|
532
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Fetch the model list from an OpenAI-compatible server's `/models`
|
|
537
|
+
* endpoint (vLLM, LiteLLM, LM Studio, etc.). `baseUrl` is the full base
|
|
538
|
+
* (e.g. http://host:8000/v1). Returns null on error.
|
|
539
|
+
*/
|
|
540
|
+
export async function fetchOpenAiCompatibleModels(baseUrl, apiKey) {
|
|
541
|
+
const base = (baseUrl || '').trim().replace(/\/+$/, '');
|
|
542
|
+
if (!base)
|
|
543
|
+
return null;
|
|
544
|
+
try {
|
|
545
|
+
const headers = {};
|
|
546
|
+
if (apiKey)
|
|
547
|
+
headers['Authorization'] = `Bearer ${apiKey}`;
|
|
548
|
+
const res = await fetch(`${base}/models`, { headers, signal: AbortSignal.timeout(5000) });
|
|
549
|
+
if (!res.ok)
|
|
550
|
+
return null;
|
|
551
|
+
const data = await res.json();
|
|
552
|
+
if (!Array.isArray(data.data))
|
|
553
|
+
return null;
|
|
554
|
+
return data.data.map(m => ({ id: m.id, name: m.id, description: '' }));
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Resolve the effective OpenAI-protocol base URL for a provider, honoring
|
|
562
|
+
* user overrides the static provider table can't express:
|
|
563
|
+
* - ollama → configured `ollamaUrl` + /v1
|
|
564
|
+
* - custom → configured `customBaseUrl` (full base, e.g. http://host:8000/v1)
|
|
565
|
+
* - openai → the OPENAI_BASE_URL env var, if set (OpenAI-SDK convention)
|
|
566
|
+
* Falls back to the provider's hardcoded base URL. Only the `openai`
|
|
567
|
+
* protocol takes overrides; the anthropic protocol uses the static table.
|
|
568
|
+
*/
|
|
569
|
+
export function resolveBaseUrl(providerId, protocol) {
|
|
570
|
+
const fallback = getProviderBaseUrl(providerId, protocol);
|
|
571
|
+
if (protocol !== 'openai')
|
|
572
|
+
return fallback;
|
|
573
|
+
if (providerId === 'ollama') {
|
|
574
|
+
const u = (config.get('ollamaUrl') || 'http://localhost:11434').replace(/\/+$/, '');
|
|
575
|
+
return `${u}/v1`;
|
|
576
|
+
}
|
|
577
|
+
if (providerId === 'custom') {
|
|
578
|
+
const u = (config.get('customBaseUrl') || '').trim().replace(/\/+$/, '');
|
|
579
|
+
return u || fallback;
|
|
580
|
+
}
|
|
581
|
+
if (providerId === 'openai') {
|
|
582
|
+
const env = (process.env.OPENAI_BASE_URL || '').trim().replace(/\/+$/, '');
|
|
583
|
+
if (env)
|
|
584
|
+
return env;
|
|
585
|
+
}
|
|
586
|
+
return fallback;
|
|
587
|
+
}
|
|
533
588
|
// Re-export PROVIDERS for convenience
|
|
534
589
|
export { PROVIDERS } from './providers.js';
|
|
535
590
|
// Generate unique session ID
|
|
@@ -599,16 +654,36 @@ export function saveSession(name, history, projectPath) {
|
|
|
599
654
|
const title = firstUserMsg
|
|
600
655
|
? firstUserMsg.content.replace(/\n/g, ' ').trim().slice(0, 60)
|
|
601
656
|
: name;
|
|
657
|
+
const sessionsDir = getSessionsDir(projectPath);
|
|
658
|
+
const filePath = join(sessionsDir, `${name}.json`);
|
|
659
|
+
// Preserve an existing aiTitle across re-saves so we don't regenerate.
|
|
660
|
+
let existingAiTitle;
|
|
661
|
+
if (existsSync(filePath)) {
|
|
662
|
+
try {
|
|
663
|
+
const prev = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
664
|
+
existingAiTitle = prev.aiTitle;
|
|
665
|
+
}
|
|
666
|
+
catch { /* ignore corrupt prior file */ }
|
|
667
|
+
}
|
|
602
668
|
const session = {
|
|
603
669
|
name,
|
|
604
670
|
title,
|
|
671
|
+
aiTitle: existingAiTitle,
|
|
605
672
|
history,
|
|
606
673
|
createdAt: new Date().toISOString(),
|
|
607
674
|
};
|
|
608
|
-
const sessionsDir = getSessionsDir(projectPath);
|
|
609
|
-
const filePath = join(sessionsDir, `${name}.json`);
|
|
610
675
|
writeFileSync(filePath, JSON.stringify(session, null, 2));
|
|
611
676
|
logSession('save', name, true);
|
|
677
|
+
// Fire-and-forget: generate an AI title once the session has enough
|
|
678
|
+
// content. The orchestrator early-returns if one already exists, so
|
|
679
|
+
// this is a no-op on every save after the first successful generation.
|
|
680
|
+
// Gated by autoSessionTitle so privacy/cost-conscious users can opt out
|
|
681
|
+
// of the (small, background) API call entirely.
|
|
682
|
+
if (config.get('autoSessionTitle') !== false
|
|
683
|
+
&& !existingAiTitle
|
|
684
|
+
&& history.filter(m => m.role !== 'system').length >= 3) {
|
|
685
|
+
void maybeGenerateSessionTitle(name, projectPath).catch(() => { });
|
|
686
|
+
}
|
|
612
687
|
return true;
|
|
613
688
|
}
|
|
614
689
|
catch (error) {
|
|
@@ -616,6 +691,49 @@ export function saveSession(name, history, projectPath) {
|
|
|
616
691
|
return false;
|
|
617
692
|
}
|
|
618
693
|
}
|
|
694
|
+
// Guard against concurrent title generation for the same session during
|
|
695
|
+
// the 5s autosave cadence — only one in-flight call per session name.
|
|
696
|
+
const titlesInFlight = new Set();
|
|
697
|
+
/**
|
|
698
|
+
* Generate and persist an AI title for a session, if it doesn't have
|
|
699
|
+
* one yet. Safe to call repeatedly — early-returns when aiTitle exists
|
|
700
|
+
* or a generation is already in flight.
|
|
701
|
+
*/
|
|
702
|
+
export async function maybeGenerateSessionTitle(name, projectPath) {
|
|
703
|
+
if (titlesInFlight.has(name))
|
|
704
|
+
return;
|
|
705
|
+
const sessionsDir = getSessionsDir(projectPath);
|
|
706
|
+
const filePath = join(sessionsDir, `${name}.json`);
|
|
707
|
+
if (!existsSync(filePath))
|
|
708
|
+
return;
|
|
709
|
+
let data;
|
|
710
|
+
try {
|
|
711
|
+
data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (data.aiTitle)
|
|
717
|
+
return;
|
|
718
|
+
titlesInFlight.add(name);
|
|
719
|
+
try {
|
|
720
|
+
const { generateSessionTitle } = await import('../utils/sessionTitles.js');
|
|
721
|
+
const aiTitle = await generateSessionTitle(data.history);
|
|
722
|
+
if (aiTitle) {
|
|
723
|
+
// Re-read in case the session was re-saved while we were generating,
|
|
724
|
+
// so we don't clobber newer history with our stale copy.
|
|
725
|
+
try {
|
|
726
|
+
const fresh = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
727
|
+
fresh.aiTitle = aiTitle;
|
|
728
|
+
writeFileSync(filePath, JSON.stringify(fresh, null, 2));
|
|
729
|
+
}
|
|
730
|
+
catch { /* ignore */ }
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
titlesInFlight.delete(name);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
619
737
|
export function loadSession(name, projectPath) {
|
|
620
738
|
try {
|
|
621
739
|
const sessionsDir = getSessionsDir(projectPath);
|
|
@@ -722,9 +840,12 @@ export function listSessionsWithInfo(projectPath) {
|
|
|
722
840
|
const stat = statSync(filePath);
|
|
723
841
|
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
724
842
|
const sessionName = data.name || file.replace('.json', '');
|
|
725
|
-
//
|
|
843
|
+
// Title priority: AI-generated one-liner > stored title > first
|
|
844
|
+
// user message > session name. aiTitle reads far better in
|
|
845
|
+
// /sessions + /recall ("OAuth2 migration" vs "help me with the…").
|
|
726
846
|
const firstUserMsg = data.history?.find(m => m.role === 'user');
|
|
727
|
-
const title = data.
|
|
847
|
+
const title = data.aiTitle
|
|
848
|
+
|| data.title
|
|
728
849
|
|| (firstUserMsg ? firstUserMsg.content.replace(/\n/g, ' ').trim().slice(0, 60) : null)
|
|
729
850
|
|| sessionName;
|
|
730
851
|
sessions.push({
|
package/dist/config/providers.js
CHANGED
|
@@ -331,6 +331,24 @@ export const PROVIDERS = {
|
|
|
331
331
|
groupLabel: 'Ollama (local)',
|
|
332
332
|
hint: 'Runs locally — no API key or account needed.',
|
|
333
333
|
},
|
|
334
|
+
'custom': {
|
|
335
|
+
name: 'Custom (OpenAI-compatible)',
|
|
336
|
+
description: 'Any OpenAI-compatible endpoint — vLLM, LiteLLM, LM Studio',
|
|
337
|
+
protocols: {
|
|
338
|
+
openai: {
|
|
339
|
+
baseUrl: 'http://localhost:8000/v1',
|
|
340
|
+
authHeader: 'Bearer',
|
|
341
|
+
supportsNativeTools: true,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
models: [],
|
|
345
|
+
defaultModel: '',
|
|
346
|
+
defaultProtocol: 'openai',
|
|
347
|
+
noApiKey: true, // key optional — sent as Bearer only if you set one
|
|
348
|
+
dynamicModels: true,
|
|
349
|
+
groupLabel: 'Custom',
|
|
350
|
+
hint: 'Point at any OpenAI-compatible server. Set the URL in /settings (Custom Base URL) or the OPENAI_BASE_URL env var, then pick your model with /model.',
|
|
351
|
+
},
|
|
334
352
|
};
|
|
335
353
|
export function getProvider(id) {
|
|
336
354
|
return PROVIDERS[id] || null;
|
|
@@ -354,6 +372,7 @@ const DISPLAY_ORDER = [
|
|
|
354
372
|
'minimax',
|
|
355
373
|
'minimax-api',
|
|
356
374
|
'ollama',
|
|
375
|
+
'custom',
|
|
357
376
|
'z.ai-cn',
|
|
358
377
|
'z.ai-cn-api',
|
|
359
378
|
'minimax-cn',
|
package/dist/renderer/App.js
CHANGED
|
@@ -95,6 +95,7 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
95
95
|
'go': 'Execute the pending plan from /plan',
|
|
96
96
|
'personality': 'Switch agent tone: concise / verbose / security / senior-reviewer / etc',
|
|
97
97
|
'insights': 'Activity summary over the last N days (default 7): runs, files, tools, projects',
|
|
98
|
+
'recall': 'Search across ALL saved sessions (cross-session; /search is current-session only)',
|
|
98
99
|
};
|
|
99
100
|
import { helpCategories, keyboardShortcuts } from './components/Help.js';
|
|
100
101
|
import { handleSettingsKey, SETTINGS } from './components/Settings.js';
|
|
@@ -238,6 +239,8 @@ export class App {
|
|
|
238
239
|
'plan', 'go',
|
|
239
240
|
// 2.0.3 — personalities + insights.
|
|
240
241
|
'personality', 'insights',
|
|
242
|
+
// 2.1.0 — cross-session recall.
|
|
243
|
+
'recall',
|
|
241
244
|
'c', 't', 'd', 'r', 'f', 'e', 'o', 'b', 'p',
|
|
242
245
|
];
|
|
243
246
|
constructor(options) {
|
|
@@ -120,6 +120,23 @@ export async function handleCommand(command, args, ctx) {
|
|
|
120
120
|
});
|
|
121
121
|
break;
|
|
122
122
|
}
|
|
123
|
+
if (providerId === 'custom') {
|
|
124
|
+
const base = config.get('customBaseUrl') || 'http://localhost:8000/v1';
|
|
125
|
+
ctx.app.notify(`Fetching models from ${base}…`);
|
|
126
|
+
const { fetchOpenAiCompatibleModels, getApiKey: _getKey } = await import('../config/index.js');
|
|
127
|
+
const models = await fetchOpenAiCompatibleModels(base, _getKey('custom') || undefined);
|
|
128
|
+
if (!models || models.length === 0) {
|
|
129
|
+
ctx.app.notify(`Could not list models from ${base}. Set the base URL in /settings, or set the model directly (config key "model").`);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
const modelItems = models.map(m => ({ key: m.id, label: m.name, description: '' }));
|
|
133
|
+
const currentModel = config.get('model');
|
|
134
|
+
ctx.app.showSelect(`Select Model (${models.length})`, modelItems, currentModel, (item) => {
|
|
135
|
+
config.set('model', item.key);
|
|
136
|
+
ctx.app.notify(`Model: ${item.key}`);
|
|
137
|
+
});
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
123
140
|
const { fetchOllamaModels } = await import('../config/index.js');
|
|
124
141
|
ctx.app.notify('Fetching models from Ollama...');
|
|
125
142
|
const ollamaModels = await fetchOllamaModels();
|
|
@@ -369,13 +386,21 @@ export async function handleCommand(command, args, ctx) {
|
|
|
369
386
|
ctx.app.notify('No saved sessions');
|
|
370
387
|
return;
|
|
371
388
|
}
|
|
372
|
-
|
|
389
|
+
// Show the readable title (AI-generated > stored > first-message >
|
|
390
|
+
// name) with a short date for disambiguation, instead of the raw
|
|
391
|
+
// session id. Loading still keys off the index → session mapping.
|
|
392
|
+
const labels = sessions.map(s => {
|
|
393
|
+
const date = new Date(s.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
394
|
+
const title = s.title && s.title !== s.name ? s.title : s.name;
|
|
395
|
+
return `${title} · ${date} · ${s.messageCount} msg`;
|
|
396
|
+
});
|
|
397
|
+
ctx.app.showList('Load Session', labels, (index) => {
|
|
373
398
|
const selected = sessions[index];
|
|
374
399
|
const loaded = loadSession(selected.name, ctx.projectPath);
|
|
375
400
|
if (loaded) {
|
|
376
401
|
ctx.app.setMessages(loaded);
|
|
377
402
|
ctx.setSessionId(selected.name);
|
|
378
|
-
ctx.app.notify(`Loaded: ${selected.name}`);
|
|
403
|
+
ctx.app.notify(`Loaded: ${selected.title || selected.name}`);
|
|
379
404
|
}
|
|
380
405
|
else {
|
|
381
406
|
ctx.app.notify('Failed to load session');
|
|
@@ -589,6 +614,49 @@ Format: use headers per category, only include categories where you found issues
|
|
|
589
614
|
}
|
|
590
615
|
break;
|
|
591
616
|
}
|
|
617
|
+
case 'recall': {
|
|
618
|
+
// Cross-session search (vs /search which is current-session only).
|
|
619
|
+
// Flags: --resume (load top match), --summarize (LLM recap).
|
|
620
|
+
const wantResume = args.includes('--resume');
|
|
621
|
+
const wantSummarize = args.includes('--summarize');
|
|
622
|
+
const query = args.filter(a => a !== '--resume' && a !== '--summarize').join(' ');
|
|
623
|
+
if (!query) {
|
|
624
|
+
ctx.app.notify('Usage: /recall <query> [--resume | --summarize]');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const { recallSessions, formatRecall, summarizeRecall } = await import('../utils/recall.js');
|
|
628
|
+
const matches = recallSessions(query, ctx.projectPath);
|
|
629
|
+
if (matches.length === 0) {
|
|
630
|
+
ctx.app.addMessage({ role: 'system', content: formatRecall(query, matches) });
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
if (wantResume) {
|
|
634
|
+
// Load the top match directly — skip the list + /sessions dance.
|
|
635
|
+
const top = matches[0];
|
|
636
|
+
const loaded = loadSession(top.session.name, ctx.projectPath);
|
|
637
|
+
if (loaded) {
|
|
638
|
+
ctx.app.setMessages(loaded);
|
|
639
|
+
ctx.setSessionId(top.session.name);
|
|
640
|
+
ctx.app.notify(`Resumed: ${top.session.title} (${top.session.name})`);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
ctx.app.notify(`Couldn't load ${top.session.name}.`);
|
|
644
|
+
}
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
if (wantSummarize) {
|
|
648
|
+
ctx.app.notify('Summarizing matching sessions…');
|
|
649
|
+
const summary = await summarizeRecall(query, matches, ctx.projectPath);
|
|
650
|
+
const header = formatRecall(query, matches);
|
|
651
|
+
const block = summary
|
|
652
|
+
? `${header}\n\n---\n\n### Summary\n\n${summary}`
|
|
653
|
+
: header;
|
|
654
|
+
ctx.app.addMessage({ role: 'system', content: block });
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
ctx.app.addMessage({ role: 'system', content: formatRecall(query, matches) });
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
592
660
|
case 'export': {
|
|
593
661
|
const messages = ctx.app.getMessages();
|
|
594
662
|
if (messages.length === 0) {
|
|
@@ -27,7 +27,10 @@ export const helpCategories = [
|
|
|
27
27
|
{ key: '/sessions', description: 'List and load sessions' },
|
|
28
28
|
{ key: '/new', description: 'Start new session' },
|
|
29
29
|
{ key: '/rename <name>', description: 'Rename current session' },
|
|
30
|
-
{ key: '/search <term>', description: 'Search
|
|
30
|
+
{ key: '/search <term>', description: 'Search the current session' },
|
|
31
|
+
{ key: '/recall <query>', description: 'Search across ALL saved sessions (cross-session)' },
|
|
32
|
+
{ key: '/recall … --resume', description: 'Load the top-matching session directly' },
|
|
33
|
+
{ key: '/recall … --summarize', description: 'LLM recap of what you did across matches' },
|
|
31
34
|
{ key: '/export [md|json|txt]', description: 'Export chat' },
|
|
32
35
|
{ key: '/compact [keepN]', description: 'AI-summarize older messages to free up context (keeps last N)' },
|
|
33
36
|
],
|
|
@@ -63,6 +63,16 @@ export const SETTINGS = [
|
|
|
63
63
|
{ value: false, label: 'Off' },
|
|
64
64
|
],
|
|
65
65
|
},
|
|
66
|
+
{
|
|
67
|
+
key: 'autoSessionTitle',
|
|
68
|
+
label: 'Auto Session Titles',
|
|
69
|
+
getValue: () => config.get('autoSessionTitle'),
|
|
70
|
+
type: 'select',
|
|
71
|
+
options: [
|
|
72
|
+
{ value: true, label: 'On (small background API call)' },
|
|
73
|
+
{ value: false, label: 'Off' },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
66
76
|
{
|
|
67
77
|
key: 'agentMode',
|
|
68
78
|
label: 'Agent Mode',
|
|
@@ -121,6 +131,12 @@ export const SETTINGS = [
|
|
|
121
131
|
getValue: () => config.get('ollamaUrl') || 'http://localhost:11434',
|
|
122
132
|
type: 'text',
|
|
123
133
|
},
|
|
134
|
+
{
|
|
135
|
+
key: 'customBaseUrl',
|
|
136
|
+
label: 'Custom Base URL',
|
|
137
|
+
getValue: () => config.get('customBaseUrl') || '',
|
|
138
|
+
type: 'text',
|
|
139
|
+
},
|
|
124
140
|
{
|
|
125
141
|
key: 'agentApiTimeout',
|
|
126
142
|
label: 'Agent API Timeout (ms)',
|
package/dist/renderer/main.js
CHANGED
|
@@ -252,6 +252,14 @@ async function showLoginFlow() {
|
|
|
252
252
|
else if (event.key === 'enter') {
|
|
253
253
|
selectedProvider = providers[selectedProviderIndex];
|
|
254
254
|
setProvider(selectedProvider.id);
|
|
255
|
+
// Providers that don't need a key (Ollama, Custom OpenAI-compatible)
|
|
256
|
+
// skip the API-key prompt entirely. Configure their endpoint in
|
|
257
|
+
// /settings (Ollama URL / Custom Base URL) once inside the app.
|
|
258
|
+
if (selectedProvider.noApiKey) {
|
|
259
|
+
cleanup();
|
|
260
|
+
resolve('ollama'); // non-null sentinel so the caller proceeds
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
255
263
|
currentStep = 'apikey';
|
|
256
264
|
loginScreen = new LoginScreen(screen, input, {
|
|
257
265
|
providerName: selectedProvider.name,
|
|
@@ -378,8 +386,8 @@ Codeep - AI-powered coding assistant TUI
|
|
|
378
386
|
Usage:
|
|
379
387
|
codeep Start interactive chat
|
|
380
388
|
codeep account Link CLI to your codeep.dev dashboard
|
|
381
|
-
codeep account sync Pull
|
|
382
|
-
codeep account push Push local
|
|
389
|
+
codeep account sync Pull keys + personalities + commands from codeep.dev
|
|
390
|
+
codeep account push Push local keys + personalities + commands to codeep.dev
|
|
383
391
|
codeep acp Start ACP server (for Zed editor integration)
|
|
384
392
|
codeep --version Show version
|
|
385
393
|
codeep --help Show this help
|
|
@@ -411,14 +419,26 @@ Commands (in chat):
|
|
|
411
419
|
}
|
|
412
420
|
const count = Object.keys(keys).length;
|
|
413
421
|
if (count === 0) {
|
|
414
|
-
console.log(' no keys found.\n Add keys at codeep.dev/dashboard
|
|
422
|
+
console.log(' no keys found.\n Add keys at codeep.dev/dashboard');
|
|
415
423
|
}
|
|
416
424
|
else {
|
|
417
425
|
for (const [provider, key] of Object.entries(keys)) {
|
|
418
426
|
setApiKey(key, provider);
|
|
419
427
|
}
|
|
420
|
-
console.log(` synced ${count} key${count !== 1 ? 's' : ''}
|
|
428
|
+
console.log(` synced ${count} key${count !== 1 ? 's' : ''}.`);
|
|
421
429
|
}
|
|
430
|
+
// Also pull portable personal config — personalities + custom
|
|
431
|
+
// commands. Additive merge (never clobbers local files).
|
|
432
|
+
const { pullPersonalities, pullCommands } = await import('../utils/codeepCloud.js');
|
|
433
|
+
const pCount = await pullPersonalities();
|
|
434
|
+
if (typeof pCount === 'number' && pCount > 0) {
|
|
435
|
+
console.log(` Pulled ${pCount} personalit${pCount === 1 ? 'y' : 'ies'}.`);
|
|
436
|
+
}
|
|
437
|
+
const cCount = await pullCommands();
|
|
438
|
+
if (typeof cCount === 'number' && cCount > 0) {
|
|
439
|
+
console.log(` Pulled ${cCount} custom command${cCount === 1 ? '' : 's'}.`);
|
|
440
|
+
}
|
|
441
|
+
console.log('');
|
|
422
442
|
process.exit(0);
|
|
423
443
|
}
|
|
424
444
|
if (sub === 'push') {
|
|
@@ -444,7 +464,18 @@ Commands (in chat):
|
|
|
444
464
|
}
|
|
445
465
|
process.stdout.write(` Pushing ${count} key${count !== 1 ? 's' : ''} to codeep.dev...`);
|
|
446
466
|
const ok = await pushKeys(keys);
|
|
447
|
-
console.log(ok ? ' done
|
|
467
|
+
console.log(ok ? ' done.' : ' failed.');
|
|
468
|
+
// Also push portable personal config.
|
|
469
|
+
const { pushPersonalities, pushCommands } = await import('../utils/codeepCloud.js');
|
|
470
|
+
const pCount = await pushPersonalities();
|
|
471
|
+
if (typeof pCount === 'number' && pCount > 0) {
|
|
472
|
+
console.log(` Pushed ${pCount} personalit${pCount === 1 ? 'y' : 'ies'}.`);
|
|
473
|
+
}
|
|
474
|
+
const cCount = await pushCommands();
|
|
475
|
+
if (typeof cCount === 'number' && cCount > 0) {
|
|
476
|
+
console.log(` Pushed ${cCount} custom command${cCount === 1 ? '' : 's'}.`);
|
|
477
|
+
}
|
|
478
|
+
console.log('');
|
|
448
479
|
process.exit(ok ? 0 : 1);
|
|
449
480
|
}
|
|
450
481
|
const { runAccountFlow } = await import('../utils/codeepCloud.js');
|
package/dist/utils/agentChat.js
CHANGED
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
15
15
|
import { join } from 'path';
|
|
16
|
-
import { config, getApiKey } from '../config/index.js';
|
|
16
|
+
import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
|
|
17
17
|
import { loadProjectIntelligence, generateContextFromIntelligence } from './projectIntelligence.js';
|
|
18
18
|
import { syncProgress, generateProjectId } from './codeepCloud.js';
|
|
19
|
-
import {
|
|
19
|
+
import { getProviderAuthHeader, supportsNativeTools, getEffectiveMaxTokens, usesMaxCompletionTokens, requiresDefaultTemperature, isNoApiKeyProvider } from '../config/providers.js';
|
|
20
20
|
import { recordTokenUsage, extractOpenAIUsage, extractAnthropicUsage } from './tokenTracker.js';
|
|
21
21
|
import { parseOpenAIToolCalls, parseAnthropicToolCalls, parseToolCalls } from './toolParsing.js';
|
|
22
22
|
import { formatToolDefinitions, getOpenAITools, getAnthropicTools } from './tools.js';
|
|
@@ -182,7 +182,14 @@ export function formatChatHistoryForAgent(history, maxChars = 16000) {
|
|
|
182
182
|
}
|
|
183
183
|
export function getAgentSystemPrompt(projectContext) {
|
|
184
184
|
const root = projectContext.root || process.cwd();
|
|
185
|
-
|
|
185
|
+
// State the real underlying model/provider so "which model are you"
|
|
186
|
+
// gets a truthful answer instead of a hallucinated one.
|
|
187
|
+
const model = String(config.get('model') || '');
|
|
188
|
+
const providerId = String(config.get('provider') || '');
|
|
189
|
+
const identity = model
|
|
190
|
+
? `You are Codeep, an autonomous AI coding agent operating inside this project. The underlying model is \`${model}\` (via ${providerId}). If asked which model or provider you are, answer truthfully with these details. Never claim to be Claude or any other model unless that is genuinely the configured model.`
|
|
191
|
+
: `You are Codeep, an autonomous AI coding agent operating inside this project. Never refer to yourself as Claude or any other AI unless that is genuinely the configured model.`;
|
|
192
|
+
return `${identity}
|
|
186
193
|
|
|
187
194
|
## Tools
|
|
188
195
|
- read_file / write_file / edit_file / delete_file — file ops (prefer edit_file for modifications to keep surrounding content intact)
|
|
@@ -232,11 +239,7 @@ additionalTools) {
|
|
|
232
239
|
const model = config.get('model');
|
|
233
240
|
const providerId = config.get('provider');
|
|
234
241
|
const apiKey = getApiKey() || (isNoApiKeyProvider(providerId) ? 'ollama' : null);
|
|
235
|
-
let baseUrl =
|
|
236
|
-
if (providerId === 'ollama' && protocol === 'openai') {
|
|
237
|
-
const ollamaUrl = (config.get('ollamaUrl') || 'http://localhost:11434').replace(/\/$/, '');
|
|
238
|
-
baseUrl = `${ollamaUrl}/v1`;
|
|
239
|
-
}
|
|
242
|
+
let baseUrl = resolveBaseUrl(providerId, protocol);
|
|
240
243
|
const authHeader = getProviderAuthHeader(providerId, protocol);
|
|
241
244
|
if (!baseUrl)
|
|
242
245
|
throw new Error(`Provider ${providerId} does not support ${protocol} protocol`);
|
|
@@ -406,11 +409,7 @@ export async function agentChatFallback(messages, systemPrompt, onChunk, abortSi
|
|
|
406
409
|
const model = config.get('model');
|
|
407
410
|
const providerId = config.get('provider');
|
|
408
411
|
const apiKey = getApiKey() || (isNoApiKeyProvider(providerId) ? 'ollama' : null);
|
|
409
|
-
let baseUrl =
|
|
410
|
-
if (providerId === 'ollama' && protocol === 'openai') {
|
|
411
|
-
const ollamaUrl = (config.get('ollamaUrl') || 'http://localhost:11434').replace(/\/$/, '');
|
|
412
|
-
baseUrl = `${ollamaUrl}/v1`;
|
|
413
|
-
}
|
|
412
|
+
let baseUrl = resolveBaseUrl(providerId, protocol);
|
|
414
413
|
const authHeader = getProviderAuthHeader(providerId, protocol);
|
|
415
414
|
if (!baseUrl)
|
|
416
415
|
throw new Error(`Provider ${providerId} does not support ${protocol} protocol`);
|
|
@@ -65,6 +65,10 @@ export declare function pullKeys(): Promise<Record<string, string> | null>;
|
|
|
65
65
|
* Returns true on success.
|
|
66
66
|
*/
|
|
67
67
|
export declare function pushKeys(keys: Record<string, string>): Promise<boolean>;
|
|
68
|
+
export declare const pullPersonalities: () => Promise<number | null>;
|
|
69
|
+
export declare const pushPersonalities: () => Promise<number | null>;
|
|
70
|
+
export declare const pullCommands: () => Promise<number | null>;
|
|
71
|
+
export declare const pushCommands: () => Promise<number | null>;
|
|
68
72
|
/**
|
|
69
73
|
* Sync session conversation history to codeep.dev.
|
|
70
74
|
* Only user/assistant messages are sent — system messages are filtered out.
|
|
@@ -209,6 +209,102 @@ export async function pushKeys(keys) {
|
|
|
209
209
|
});
|
|
210
210
|
return res?.ok ?? false;
|
|
211
211
|
}
|
|
212
|
+
// ─── Portable personal config sync (personalities + commands) ──────────────────
|
|
213
|
+
//
|
|
214
|
+
// Both are name → raw-.md-body bundles stored in a global dir
|
|
215
|
+
// (~/.codeep/personalities, ~/.codeep/commands). The sync is bidirectional
|
|
216
|
+
// and merge-based: pull writes any remote file not present locally; push
|
|
217
|
+
// sends every local file. Last-write-wins on the server via upsert. We
|
|
218
|
+
// never delete locally on pull — additive only, so a sync can't nuke
|
|
219
|
+
// work you haven't pushed.
|
|
220
|
+
import { readFileSync, readdirSync, existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
221
|
+
import { join } from 'path';
|
|
222
|
+
import { homedir } from 'os';
|
|
223
|
+
function globalDir(kind) {
|
|
224
|
+
return join(homedir(), '.codeep', kind);
|
|
225
|
+
}
|
|
226
|
+
/** Read every <name>.md in a global config dir into a { name → body } map. */
|
|
227
|
+
function readFileBundle(kind) {
|
|
228
|
+
const dir = globalDir(kind);
|
|
229
|
+
if (!existsSync(dir))
|
|
230
|
+
return {};
|
|
231
|
+
const out = {};
|
|
232
|
+
try {
|
|
233
|
+
for (const f of readdirSync(dir)) {
|
|
234
|
+
if (!f.endsWith('.md'))
|
|
235
|
+
continue;
|
|
236
|
+
const name = f.slice(0, -3).toLowerCase();
|
|
237
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name) || name.length > 64)
|
|
238
|
+
continue;
|
|
239
|
+
try {
|
|
240
|
+
const body = readFileSync(join(dir, f), 'utf8');
|
|
241
|
+
if (body.length <= 64 * 1024)
|
|
242
|
+
out[name] = body;
|
|
243
|
+
}
|
|
244
|
+
catch { /* skip unreadable */ }
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch { /* dir read failed */ }
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
/** Write a { name → body } map into a global config dir as <name>.md
|
|
251
|
+
* files. Only writes files that don't already exist (additive merge —
|
|
252
|
+
* never clobber local edits). Returns the count of newly written files. */
|
|
253
|
+
function writeFileBundle(kind, items) {
|
|
254
|
+
const dir = globalDir(kind);
|
|
255
|
+
if (!existsSync(dir))
|
|
256
|
+
mkdirSync(dir, { recursive: true });
|
|
257
|
+
let written = 0;
|
|
258
|
+
for (const [name, body] of Object.entries(items)) {
|
|
259
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name) || name.length > 64 || typeof body !== 'string' || !body)
|
|
260
|
+
continue;
|
|
261
|
+
const filePath = join(dir, `${name}.md`);
|
|
262
|
+
if (existsSync(filePath))
|
|
263
|
+
continue; // don't clobber local
|
|
264
|
+
try {
|
|
265
|
+
writeFileSync(filePath, body);
|
|
266
|
+
written++;
|
|
267
|
+
}
|
|
268
|
+
catch { /* skip */ }
|
|
269
|
+
}
|
|
270
|
+
return written;
|
|
271
|
+
}
|
|
272
|
+
async function pullBundle(kind) {
|
|
273
|
+
const syncToken = getSyncToken();
|
|
274
|
+
if (!syncToken)
|
|
275
|
+
return null;
|
|
276
|
+
const res = await fetchWithRetry(`${API_BASE}/api/${kind}`, { headers: { 'x-sync-token': syncToken } });
|
|
277
|
+
if (!res?.ok)
|
|
278
|
+
return null;
|
|
279
|
+
try {
|
|
280
|
+
const data = await res.json();
|
|
281
|
+
if (!data.ok)
|
|
282
|
+
return null;
|
|
283
|
+
return writeFileBundle(kind, data.items ?? {});
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function pushBundle(kind) {
|
|
290
|
+
const syncToken = getSyncToken();
|
|
291
|
+
if (!syncToken)
|
|
292
|
+
return null;
|
|
293
|
+
const items = readFileBundle(kind);
|
|
294
|
+
const count = Object.keys(items).length;
|
|
295
|
+
if (count === 0)
|
|
296
|
+
return 0;
|
|
297
|
+
const res = await fetchWithRetry(`${API_BASE}/api/${kind}`, {
|
|
298
|
+
method: 'POST',
|
|
299
|
+
headers: { 'Content-Type': 'application/json', 'x-sync-token': syncToken },
|
|
300
|
+
body: JSON.stringify({ items }),
|
|
301
|
+
});
|
|
302
|
+
return res?.ok ? count : null;
|
|
303
|
+
}
|
|
304
|
+
export const pullPersonalities = () => pullBundle('personalities');
|
|
305
|
+
export const pushPersonalities = () => pushBundle('personalities');
|
|
306
|
+
export const pullCommands = () => pullBundle('commands');
|
|
307
|
+
export const pushCommands = () => pushBundle('commands');
|
|
212
308
|
// ─── Session history sync ─────────────────────────────────────────────────────
|
|
213
309
|
/**
|
|
214
310
|
* Sync session conversation history to codeep.dev.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/recall` — cross-session search.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `/search`, which scans only the *current* session's
|
|
5
|
+
* messages. `/recall` scans every saved session in the active scope
|
|
6
|
+
* (project sessions under `<workspace>/.codeep/sessions/` when in a
|
|
7
|
+
* project, else global `~/.codeep/sessions/`) and surfaces the ones
|
|
8
|
+
* that match — ranked by how many query terms hit, boosted by recency.
|
|
9
|
+
*
|
|
10
|
+
* No FTS index / SQLite dependency: sessions are JSON files, and for
|
|
11
|
+
* the realistic case (tens to low-hundreds of sessions) an in-memory
|
|
12
|
+
* scan is fast and dependency-free. If a power user accumulates
|
|
13
|
+
* thousands of sessions and this gets slow, the natural upgrade is a
|
|
14
|
+
* cached FTS5 index — but that's premature today.
|
|
15
|
+
*/
|
|
16
|
+
import { type SessionInfo } from '../config/index.js';
|
|
17
|
+
export interface RecallMatch {
|
|
18
|
+
session: SessionInfo;
|
|
19
|
+
/** Composite relevance: term-hit count + recency boost. */
|
|
20
|
+
score: number;
|
|
21
|
+
/** Context snippet from the best-matching message. */
|
|
22
|
+
snippet: string;
|
|
23
|
+
/** How many messages in the session matched at least one term. */
|
|
24
|
+
matchedMessages: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Search saved sessions for the query. A session matches only if EVERY
|
|
28
|
+
* query term appears somewhere in its non-system messages (AND
|
|
29
|
+
* semantics — narrows results to genuinely relevant sessions rather
|
|
30
|
+
* than any-term noise). Returns up to `limit` matches, best first.
|
|
31
|
+
*/
|
|
32
|
+
export declare function recallSessions(query: string, projectPath?: string, limit?: number): RecallMatch[];
|
|
33
|
+
/**
|
|
34
|
+
* LLM-summarize what was accomplished across the matching sessions.
|
|
35
|
+
* Loads each match's history, builds a compact multi-session transcript,
|
|
36
|
+
* and asks the model for a short "here's what you did" paragraph. Used
|
|
37
|
+
* by `/recall <query> --summarize`. Returns null on chat failure.
|
|
38
|
+
*/
|
|
39
|
+
export declare function summarizeRecall(query: string, matches: RecallMatch[], projectPath?: string): Promise<string | null>;
|
|
40
|
+
/** Render recall results as a Markdown block for TUI / ACP display. */
|
|
41
|
+
export declare function formatRecall(query: string, matches: RecallMatch[]): string;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/recall` — cross-session search.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `/search`, which scans only the *current* session's
|
|
5
|
+
* messages. `/recall` scans every saved session in the active scope
|
|
6
|
+
* (project sessions under `<workspace>/.codeep/sessions/` when in a
|
|
7
|
+
* project, else global `~/.codeep/sessions/`) and surfaces the ones
|
|
8
|
+
* that match — ranked by how many query terms hit, boosted by recency.
|
|
9
|
+
*
|
|
10
|
+
* No FTS index / SQLite dependency: sessions are JSON files, and for
|
|
11
|
+
* the realistic case (tens to low-hundreds of sessions) an in-memory
|
|
12
|
+
* scan is fast and dependency-free. If a power user accumulates
|
|
13
|
+
* thousands of sessions and this gets slow, the natural upgrade is a
|
|
14
|
+
* cached FTS5 index — but that's premature today.
|
|
15
|
+
*/
|
|
16
|
+
import { listSessionsWithInfo, loadSession } from '../config/index.js';
|
|
17
|
+
/**
|
|
18
|
+
* Search saved sessions for the query. A session matches only if EVERY
|
|
19
|
+
* query term appears somewhere in its non-system messages (AND
|
|
20
|
+
* semantics — narrows results to genuinely relevant sessions rather
|
|
21
|
+
* than any-term noise). Returns up to `limit` matches, best first.
|
|
22
|
+
*/
|
|
23
|
+
export function recallSessions(query, projectPath, limit = 10) {
|
|
24
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
25
|
+
if (terms.length === 0)
|
|
26
|
+
return [];
|
|
27
|
+
const sessions = listSessionsWithInfo(projectPath);
|
|
28
|
+
const matches = [];
|
|
29
|
+
for (const session of sessions) {
|
|
30
|
+
const history = loadSession(session.name, projectPath);
|
|
31
|
+
if (!history || history.length === 0)
|
|
32
|
+
continue;
|
|
33
|
+
let totalTermHits = 0;
|
|
34
|
+
let matchedMessages = 0;
|
|
35
|
+
let bestSnippet = '';
|
|
36
|
+
let bestSnippetTermCount = 0;
|
|
37
|
+
// Track which terms appear anywhere in the session (for AND gate).
|
|
38
|
+
const termsSeen = new Set();
|
|
39
|
+
for (const msg of history) {
|
|
40
|
+
if (msg.role === 'system')
|
|
41
|
+
continue;
|
|
42
|
+
const lower = msg.content.toLowerCase();
|
|
43
|
+
const termsInMsg = terms.filter((t) => lower.includes(t));
|
|
44
|
+
if (termsInMsg.length === 0)
|
|
45
|
+
continue;
|
|
46
|
+
matchedMessages++;
|
|
47
|
+
totalTermHits += termsInMsg.length;
|
|
48
|
+
for (const t of termsInMsg)
|
|
49
|
+
termsSeen.add(t);
|
|
50
|
+
// Pick the snippet from whichever message hits the most distinct terms.
|
|
51
|
+
if (termsInMsg.length > bestSnippetTermCount) {
|
|
52
|
+
bestSnippetTermCount = termsInMsg.length;
|
|
53
|
+
bestSnippet = extractSnippet(msg.content, termsInMsg[0]);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// AND gate: every query term must appear somewhere in the session.
|
|
57
|
+
if (termsSeen.size < terms.length)
|
|
58
|
+
continue;
|
|
59
|
+
// Recency boost: sessions touched in the last ~10 days get up to +10,
|
|
60
|
+
// tapering linearly. Keeps "what did I do recently" answers near the top
|
|
61
|
+
// without drowning out an older session that's a much stronger text match.
|
|
62
|
+
const ageDays = (Date.now() - new Date(session.createdAt).getTime()) / 86_400_000;
|
|
63
|
+
const recencyBoost = Math.max(0, 10 - ageDays);
|
|
64
|
+
const score = totalTermHits + recencyBoost;
|
|
65
|
+
matches.push({ session, score, snippet: bestSnippet, matchedMessages });
|
|
66
|
+
}
|
|
67
|
+
return matches.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
68
|
+
}
|
|
69
|
+
/** Extract a one-line context window around the first match of `term`. */
|
|
70
|
+
function extractSnippet(content, term) {
|
|
71
|
+
const lower = content.toLowerCase();
|
|
72
|
+
const idx = lower.indexOf(term.toLowerCase());
|
|
73
|
+
if (idx === -1)
|
|
74
|
+
return content.slice(0, 120).replace(/\s+/g, ' ').trim();
|
|
75
|
+
const start = Math.max(0, idx - 40);
|
|
76
|
+
const end = Math.min(content.length, idx + term.length + 80);
|
|
77
|
+
const prefix = start > 0 ? '…' : '';
|
|
78
|
+
const suffix = end < content.length ? '…' : '';
|
|
79
|
+
return prefix + content.slice(start, end).replace(/\s+/g, ' ').trim() + suffix;
|
|
80
|
+
}
|
|
81
|
+
function relativeAge(iso) {
|
|
82
|
+
const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
|
83
|
+
if (days === 0)
|
|
84
|
+
return 'today';
|
|
85
|
+
if (days === 1)
|
|
86
|
+
return 'yesterday';
|
|
87
|
+
if (days < 7)
|
|
88
|
+
return `${days}d ago`;
|
|
89
|
+
if (days < 30)
|
|
90
|
+
return `${Math.floor(days / 7)}w ago`;
|
|
91
|
+
return new Date(iso).toISOString().slice(0, 10);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* LLM-summarize what was accomplished across the matching sessions.
|
|
95
|
+
* Loads each match's history, builds a compact multi-session transcript,
|
|
96
|
+
* and asks the model for a short "here's what you did" paragraph. Used
|
|
97
|
+
* by `/recall <query> --summarize`. Returns null on chat failure.
|
|
98
|
+
*/
|
|
99
|
+
export async function summarizeRecall(query, matches, projectPath) {
|
|
100
|
+
if (matches.length === 0)
|
|
101
|
+
return null;
|
|
102
|
+
// Build a transcript across the top matches, capped so the prompt
|
|
103
|
+
// stays affordable even when many sessions match.
|
|
104
|
+
const blocks = [];
|
|
105
|
+
for (const m of matches.slice(0, 5)) {
|
|
106
|
+
const history = loadSession(m.session.name, projectPath);
|
|
107
|
+
if (!history)
|
|
108
|
+
continue;
|
|
109
|
+
const turns = history
|
|
110
|
+
.filter((msg) => msg.role !== 'system')
|
|
111
|
+
.slice(0, 8)
|
|
112
|
+
.map((msg) => `${msg.role}: ${msg.content.replace(/\s+/g, ' ').slice(0, 300)}`)
|
|
113
|
+
.join('\n');
|
|
114
|
+
blocks.push(`### Session: ${m.session.name} (${relativeAge(m.session.createdAt)})\n${turns}`);
|
|
115
|
+
}
|
|
116
|
+
if (blocks.length === 0)
|
|
117
|
+
return null;
|
|
118
|
+
const system = `You are summarizing a developer's past work sessions that matched the search "${query}". Read the transcripts and write a SHORT recap (2-4 sentences) of what they actually accomplished across these sessions — concrete changes, decisions, and any unfinished threads. Use past tense. No preamble, no bullet headers, just the recap paragraph.`;
|
|
119
|
+
try {
|
|
120
|
+
const { chat } = await import('../api/index.js');
|
|
121
|
+
const summary = await chat(blocks.join('\n\n'), [{ role: 'system', content: system }]);
|
|
122
|
+
return summary.trim() || null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Render recall results as a Markdown block for TUI / ACP display. */
|
|
129
|
+
export function formatRecall(query, matches) {
|
|
130
|
+
if (matches.length === 0) {
|
|
131
|
+
return `_No saved sessions match "${query}"._\n\nTip: \`/recall\` searches across all your sessions. For the current session only, use \`/search\`.`;
|
|
132
|
+
}
|
|
133
|
+
const lines = [
|
|
134
|
+
`## Recall: "${query}"`,
|
|
135
|
+
'',
|
|
136
|
+
`${matches.length} session${matches.length === 1 ? '' : 's'} match${matches.length === 1 ? 'es' : ''}, best first:`,
|
|
137
|
+
'',
|
|
138
|
+
];
|
|
139
|
+
for (const m of matches) {
|
|
140
|
+
const age = relativeAge(m.session.createdAt);
|
|
141
|
+
const title = m.session.title && m.session.title !== m.session.name ? m.session.title : '';
|
|
142
|
+
lines.push(`**${m.session.name}**${title ? ` — ${title}` : ''}`);
|
|
143
|
+
lines.push(`_${age} · ${m.session.messageCount} messages · ${m.matchedMessages} matched_`);
|
|
144
|
+
if (m.snippet)
|
|
145
|
+
lines.push(`> ${m.snippet}`);
|
|
146
|
+
lines.push('');
|
|
147
|
+
}
|
|
148
|
+
lines.push('Resume one with `/sessions` and pick it from the list.');
|
|
149
|
+
return lines.join('\n').trim();
|
|
150
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generated session titles.
|
|
3
|
+
*
|
|
4
|
+
* The default title is the first user message truncated to 60 chars —
|
|
5
|
+
* which makes `/sessions` and `/recall` read like "help me fix the…",
|
|
6
|
+
* "can you add a…". This module replaces that with an LLM one-liner
|
|
7
|
+
* ("OAuth2 migration for auth module") generated once per session, in
|
|
8
|
+
* the background, after the session has enough content to summarize.
|
|
9
|
+
*
|
|
10
|
+
* Design notes:
|
|
11
|
+
* - Fire-and-forget from saveSession. Never blocks a save.
|
|
12
|
+
* - One-shot: once `aiTitle` is written to the session file, the
|
|
13
|
+
* generator early-returns. An in-flight guard prevents concurrent
|
|
14
|
+
* duplicate calls during the 5s autosave cadence.
|
|
15
|
+
* - Uses the user's active model via chat() — a 4-8 word completion
|
|
16
|
+
* is negligible cost, and respecting the active model means it
|
|
17
|
+
* works for every provider without special-casing.
|
|
18
|
+
*/
|
|
19
|
+
import type { Message } from '../config/index.js';
|
|
20
|
+
/**
|
|
21
|
+
* Generate a short title from a session's history, or null if there
|
|
22
|
+
* isn't enough to summarize (fewer than 3 non-system messages) or the
|
|
23
|
+
* LLM call fails. The caller persists the result.
|
|
24
|
+
*/
|
|
25
|
+
export declare function generateSessionTitle(history: Message[]): Promise<string | null>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generated session titles.
|
|
3
|
+
*
|
|
4
|
+
* The default title is the first user message truncated to 60 chars —
|
|
5
|
+
* which makes `/sessions` and `/recall` read like "help me fix the…",
|
|
6
|
+
* "can you add a…". This module replaces that with an LLM one-liner
|
|
7
|
+
* ("OAuth2 migration for auth module") generated once per session, in
|
|
8
|
+
* the background, after the session has enough content to summarize.
|
|
9
|
+
*
|
|
10
|
+
* Design notes:
|
|
11
|
+
* - Fire-and-forget from saveSession. Never blocks a save.
|
|
12
|
+
* - One-shot: once `aiTitle` is written to the session file, the
|
|
13
|
+
* generator early-returns. An in-flight guard prevents concurrent
|
|
14
|
+
* duplicate calls during the 5s autosave cadence.
|
|
15
|
+
* - Uses the user's active model via chat() — a 4-8 word completion
|
|
16
|
+
* is negligible cost, and respecting the active model means it
|
|
17
|
+
* works for every provider without special-casing.
|
|
18
|
+
*/
|
|
19
|
+
const TITLE_SYSTEM_PROMPT = `You write concise titles for coding sessions. Given a transcript, reply with ONLY a 4-8 word title that captures the main task. No quotes. No trailing period. No preamble.
|
|
20
|
+
|
|
21
|
+
Good examples:
|
|
22
|
+
- OAuth2 migration for auth module
|
|
23
|
+
- Fix flaky payment integration tests
|
|
24
|
+
- Add dark mode to settings page
|
|
25
|
+
- Debug WebSocket reconnection loop
|
|
26
|
+
|
|
27
|
+
Reply with the title and nothing else.`;
|
|
28
|
+
/**
|
|
29
|
+
* Generate a short title from a session's history, or null if there
|
|
30
|
+
* isn't enough to summarize (fewer than 3 non-system messages) or the
|
|
31
|
+
* LLM call fails. The caller persists the result.
|
|
32
|
+
*/
|
|
33
|
+
export async function generateSessionTitle(history) {
|
|
34
|
+
const turns = history.filter((m) => m.role !== 'system');
|
|
35
|
+
if (turns.length < 3)
|
|
36
|
+
return null;
|
|
37
|
+
// Compact transcript: first 6 turns, each capped, so the prompt stays
|
|
38
|
+
// cheap regardless of how long the session ran.
|
|
39
|
+
const transcript = turns
|
|
40
|
+
.slice(0, 6)
|
|
41
|
+
.map((m) => `${m.role}: ${m.content.replace(/\s+/g, ' ').slice(0, 400)}`)
|
|
42
|
+
.join('\n');
|
|
43
|
+
try {
|
|
44
|
+
const { chat } = await import('../api/index.js');
|
|
45
|
+
const raw = await chat(transcript, [{ role: 'system', content: TITLE_SYSTEM_PROMPT }]);
|
|
46
|
+
const cleaned = raw
|
|
47
|
+
.replace(/^["'`]+|["'`]+$/g, '') // strip wrapping quotes
|
|
48
|
+
.replace(/[.\s]+$/, '') // strip trailing period / whitespace
|
|
49
|
+
.replace(/\s+/g, ' ')
|
|
50
|
+
.trim();
|
|
51
|
+
if (!cleaned)
|
|
52
|
+
return null;
|
|
53
|
+
return cleaned.length > 70 ? cleaned.slice(0, 67) + '…' : cleaned;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Task Planning - breaks down complex tasks into subtasks
|
|
3
3
|
*/
|
|
4
|
-
import { config, getApiKey } from '../config/index.js';
|
|
5
|
-
import {
|
|
4
|
+
import { config, getApiKey, resolveBaseUrl } from '../config/index.js';
|
|
5
|
+
import { getProviderAuthHeader, requiresDefaultTemperature } from '../config/providers.js';
|
|
6
6
|
/**
|
|
7
7
|
* Ask AI to break down a complex task into subtasks
|
|
8
8
|
*/
|
|
@@ -43,7 +43,7 @@ Break this down into subtasks. Each task = one file or one logical unit. Respond
|
|
|
43
43
|
const protocol = config.get('protocol');
|
|
44
44
|
const provider = config.get('provider');
|
|
45
45
|
const model = config.get('model');
|
|
46
|
-
const baseUrl =
|
|
46
|
+
const baseUrl = resolveBaseUrl(provider, protocol);
|
|
47
47
|
const authHeaderType = getProviderAuthHeader(provider, protocol);
|
|
48
48
|
const messages = [
|
|
49
49
|
{ role: 'user', content: systemPrompt }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codeep",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "AI-powered coding assistant built for the terminal. Multiple LLM providers, project-aware context, and a seamless development workflow.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|