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 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
- - **Search history** - Find past conversations with `/search`
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 config
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 config |
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
 
@@ -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');
@@ -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 = `You are a helpful AI coding assistant. 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.`;
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 = `You are a helpful AI coding assistant. Always respond in ${langName}, regardless of what language the user writes in.`;
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 = getProviderBaseUrl(providerId, 'openai');
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
- let baseUrl = getProviderBaseUrl(provider, protocol);
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) {
@@ -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;
@@ -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
- // Derive title: use stored title, else first user message, else session name
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.title
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({
@@ -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',
@@ -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
- ctx.app.showList('Load Session', sessions.map(s => s.name), (index) => {
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 chat history' },
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)',
@@ -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 API keys from codeep.dev
382
- codeep account push Push local API keys to codeep.dev
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\n');
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' : ''}.\n`);
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.\n' : ' failed.\n');
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');
@@ -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 { getProviderBaseUrl, getProviderAuthHeader, supportsNativeTools, getEffectiveMaxTokens, usesMaxCompletionTokens, requiresDefaultTemperature, isNoApiKeyProvider } from '../config/providers.js';
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
- return `You are Codeep, an autonomous AI coding agent operating inside this project. Never refer to yourself as Claude or any other AI.
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 = getProviderBaseUrl(providerId, protocol);
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 = getProviderBaseUrl(providerId, protocol);
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 { getProviderBaseUrl, getProviderAuthHeader, requiresDefaultTemperature } from '../config/providers.js';
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 = getProviderBaseUrl(provider, protocol);
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.0.4",
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",