codeep 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,7 +73,9 @@ When started in a project directory, Codeep automatically:
73
73
  - **Session picker** - Choose which session to continue on startup
74
74
  - **Per-project sessions** - Sessions stored in `.codeep/sessions/`
75
75
  - **Rename sessions** - Give meaningful names with `/rename`
76
- - **Search history** - Find past conversations with `/search`
76
+ - **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
77
+ - **Search current session** - Find text in the open conversation with `/search`
78
+ - **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
79
  - **Export** - Save to Markdown, JSON, or plain text
78
80
  - `/cost` - Per-session token usage and estimated cost (per provider/model)
79
81
  - `/compact [keepN]` - AI-summarize older messages to free up context (keeps last N, default 4)
@@ -709,6 +711,7 @@ codeep account # Opens browser → sign in with GitHub → CLI is linked
709
711
  - **Project archiving** — hide projects from the list with one click
710
712
  - **Tasks** — create/complete bug, feature, and task items from the web or directly from the CLI with `/tasks add` and `/tasks done`
711
713
  - **API key sync** — store provider keys securely on codeep.dev, sync to any machine in one command
714
+ - **Personal config sync** — personalities and custom slash commands sync across machines; view and prune them on the dashboard
712
715
  - **Connected devices** — see all machines linked to your account (hostname, last seen), revoke access per device
713
716
 
714
717
  ### API key sync
@@ -716,12 +719,32 @@ codeep account # Opens browser → sign in with GitHub → CLI is linked
716
719
  Add keys once on the dashboard, then sync them to any machine:
717
720
 
718
721
  ```bash
719
- codeep account sync # Pull keys from codeep.dev → local config
720
- codeep account push # Push local keys → codeep.dev
722
+ codeep account sync # Pull keys + config from codeep.dev → local
723
+ codeep account push # Push local keys + config → codeep.dev
721
724
  ```
722
725
 
723
726
  Keys are encrypted at rest using AES-256-GCM.
724
727
 
728
+ ### Personal config sync
729
+
730
+ `account sync` / `account push` also carry your **personalities** and **custom
731
+ slash commands** between machines — the Markdown files in
732
+ `~/.codeep/personalities/` and `~/.codeep/commands/`:
733
+
734
+ ```bash
735
+ codeep account push # Upload local personalities + commands to codeep.dev
736
+ codeep account sync # Download them onto a new machine
737
+ ```
738
+
739
+ Merging is **additive** — `sync` only writes files that don't already exist
740
+ locally, so it never clobbers a personality or command you've edited on this
741
+ machine. View what's synced (and prune stale entries) under **Personalities**
742
+ and **Custom commands** on the [dashboard](https://codeep.dev/dashboard).
743
+
744
+ Hooks and MCP server configs are deliberately **not** synced: hooks run
745
+ arbitrary shell, and MCP configs often embed tokens, so both stay local to each
746
+ machine.
747
+
725
748
  ### Tasks
726
749
 
727
750
  Create, view, and complete tasks directly from the CLI — or manage them on the codeep.dev dashboard:
@@ -973,8 +996,8 @@ In `dangerous` mode, configure which tools require confirmation via `/settings`:
973
996
  | Command | Description |
974
997
  |---------|-------------|
975
998
  | `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 |
999
+ | `codeep account sync` | Pull API keys + personalities + commands from codeep.dev → local |
1000
+ | `codeep account push` | Push local API keys + personalities + commands → codeep.dev |
978
1001
 
979
1002
  ### Authentication
980
1003
 
@@ -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
@@ -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
@@ -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;
@@ -136,6 +140,12 @@ export declare function startNewSession(): string;
136
140
  export declare function autoSaveSession(history: Message[], projectPath?: string): boolean;
137
141
  export declare function flushAutoSave(): boolean;
138
142
  export declare function saveSession(name: string, history: Message[], projectPath?: string): boolean;
143
+ /**
144
+ * Generate and persist an AI title for a session, if it doesn't have
145
+ * one yet. Safe to call repeatedly — early-returns when aiTitle exists
146
+ * or a generation is already in flight.
147
+ */
148
+ export declare function maybeGenerateSessionTitle(name: string, projectPath?: string): Promise<void>;
139
149
  export declare function loadSession(name: string, projectPath?: string): Message[] | null;
140
150
  export declare function listSessions(projectPath?: string): string[];
141
151
  export declare function deleteSession(name: string, projectPath?: string): boolean;
@@ -165,6 +165,7 @@ function createConfig() {
165
165
  plan: 'lite',
166
166
  language: 'en',
167
167
  autoSave: true,
168
+ autoSessionTitle: true,
168
169
  currentSessionId: '',
169
170
  temperature: 0.7,
170
171
  maxTokens: 32768,
@@ -599,16 +600,36 @@ export function saveSession(name, history, projectPath) {
599
600
  const title = firstUserMsg
600
601
  ? firstUserMsg.content.replace(/\n/g, ' ').trim().slice(0, 60)
601
602
  : name;
603
+ const sessionsDir = getSessionsDir(projectPath);
604
+ const filePath = join(sessionsDir, `${name}.json`);
605
+ // Preserve an existing aiTitle across re-saves so we don't regenerate.
606
+ let existingAiTitle;
607
+ if (existsSync(filePath)) {
608
+ try {
609
+ const prev = JSON.parse(readFileSync(filePath, 'utf-8'));
610
+ existingAiTitle = prev.aiTitle;
611
+ }
612
+ catch { /* ignore corrupt prior file */ }
613
+ }
602
614
  const session = {
603
615
  name,
604
616
  title,
617
+ aiTitle: existingAiTitle,
605
618
  history,
606
619
  createdAt: new Date().toISOString(),
607
620
  };
608
- const sessionsDir = getSessionsDir(projectPath);
609
- const filePath = join(sessionsDir, `${name}.json`);
610
621
  writeFileSync(filePath, JSON.stringify(session, null, 2));
611
622
  logSession('save', name, true);
623
+ // Fire-and-forget: generate an AI title once the session has enough
624
+ // content. The orchestrator early-returns if one already exists, so
625
+ // this is a no-op on every save after the first successful generation.
626
+ // Gated by autoSessionTitle so privacy/cost-conscious users can opt out
627
+ // of the (small, background) API call entirely.
628
+ if (config.get('autoSessionTitle') !== false
629
+ && !existingAiTitle
630
+ && history.filter(m => m.role !== 'system').length >= 3) {
631
+ void maybeGenerateSessionTitle(name, projectPath).catch(() => { });
632
+ }
612
633
  return true;
613
634
  }
614
635
  catch (error) {
@@ -616,6 +637,49 @@ export function saveSession(name, history, projectPath) {
616
637
  return false;
617
638
  }
618
639
  }
640
+ // Guard against concurrent title generation for the same session during
641
+ // the 5s autosave cadence — only one in-flight call per session name.
642
+ const titlesInFlight = new Set();
643
+ /**
644
+ * Generate and persist an AI title for a session, if it doesn't have
645
+ * one yet. Safe to call repeatedly — early-returns when aiTitle exists
646
+ * or a generation is already in flight.
647
+ */
648
+ export async function maybeGenerateSessionTitle(name, projectPath) {
649
+ if (titlesInFlight.has(name))
650
+ return;
651
+ const sessionsDir = getSessionsDir(projectPath);
652
+ const filePath = join(sessionsDir, `${name}.json`);
653
+ if (!existsSync(filePath))
654
+ return;
655
+ let data;
656
+ try {
657
+ data = JSON.parse(readFileSync(filePath, 'utf-8'));
658
+ }
659
+ catch {
660
+ return;
661
+ }
662
+ if (data.aiTitle)
663
+ return;
664
+ titlesInFlight.add(name);
665
+ try {
666
+ const { generateSessionTitle } = await import('../utils/sessionTitles.js');
667
+ const aiTitle = await generateSessionTitle(data.history);
668
+ if (aiTitle) {
669
+ // Re-read in case the session was re-saved while we were generating,
670
+ // so we don't clobber newer history with our stale copy.
671
+ try {
672
+ const fresh = JSON.parse(readFileSync(filePath, 'utf-8'));
673
+ fresh.aiTitle = aiTitle;
674
+ writeFileSync(filePath, JSON.stringify(fresh, null, 2));
675
+ }
676
+ catch { /* ignore */ }
677
+ }
678
+ }
679
+ finally {
680
+ titlesInFlight.delete(name);
681
+ }
682
+ }
619
683
  export function loadSession(name, projectPath) {
620
684
  try {
621
685
  const sessionsDir = getSessionsDir(projectPath);
@@ -722,9 +786,12 @@ export function listSessionsWithInfo(projectPath) {
722
786
  const stat = statSync(filePath);
723
787
  const data = JSON.parse(readFileSync(filePath, 'utf-8'));
724
788
  const sessionName = data.name || file.replace('.json', '');
725
- // Derive title: use stored title, else first user message, else session name
789
+ // Title priority: AI-generated one-liner > stored title > first
790
+ // user message > session name. aiTitle reads far better in
791
+ // /sessions + /recall ("OAuth2 migration" vs "help me with the…").
726
792
  const firstUserMsg = data.history?.find(m => m.role === 'user');
727
- const title = data.title
793
+ const title = data.aiTitle
794
+ || data.title
728
795
  || (firstUserMsg ? firstUserMsg.content.replace(/\n/g, ' ').trim().slice(0, 60) : null)
729
796
  || sessionName;
730
797
  sessions.push({
@@ -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) {
@@ -369,13 +369,21 @@ export async function handleCommand(command, args, ctx) {
369
369
  ctx.app.notify('No saved sessions');
370
370
  return;
371
371
  }
372
- ctx.app.showList('Load Session', sessions.map(s => s.name), (index) => {
372
+ // Show the readable title (AI-generated > stored > first-message >
373
+ // name) with a short date for disambiguation, instead of the raw
374
+ // session id. Loading still keys off the index → session mapping.
375
+ const labels = sessions.map(s => {
376
+ const date = new Date(s.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
377
+ const title = s.title && s.title !== s.name ? s.title : s.name;
378
+ return `${title} · ${date} · ${s.messageCount} msg`;
379
+ });
380
+ ctx.app.showList('Load Session', labels, (index) => {
373
381
  const selected = sessions[index];
374
382
  const loaded = loadSession(selected.name, ctx.projectPath);
375
383
  if (loaded) {
376
384
  ctx.app.setMessages(loaded);
377
385
  ctx.setSessionId(selected.name);
378
- ctx.app.notify(`Loaded: ${selected.name}`);
386
+ ctx.app.notify(`Loaded: ${selected.title || selected.name}`);
379
387
  }
380
388
  else {
381
389
  ctx.app.notify('Failed to load session');
@@ -589,6 +597,49 @@ Format: use headers per category, only include categories where you found issues
589
597
  }
590
598
  break;
591
599
  }
600
+ case 'recall': {
601
+ // Cross-session search (vs /search which is current-session only).
602
+ // Flags: --resume (load top match), --summarize (LLM recap).
603
+ const wantResume = args.includes('--resume');
604
+ const wantSummarize = args.includes('--summarize');
605
+ const query = args.filter(a => a !== '--resume' && a !== '--summarize').join(' ');
606
+ if (!query) {
607
+ ctx.app.notify('Usage: /recall <query> [--resume | --summarize]');
608
+ return;
609
+ }
610
+ const { recallSessions, formatRecall, summarizeRecall } = await import('../utils/recall.js');
611
+ const matches = recallSessions(query, ctx.projectPath);
612
+ if (matches.length === 0) {
613
+ ctx.app.addMessage({ role: 'system', content: formatRecall(query, matches) });
614
+ break;
615
+ }
616
+ if (wantResume) {
617
+ // Load the top match directly — skip the list + /sessions dance.
618
+ const top = matches[0];
619
+ const loaded = loadSession(top.session.name, ctx.projectPath);
620
+ if (loaded) {
621
+ ctx.app.setMessages(loaded);
622
+ ctx.setSessionId(top.session.name);
623
+ ctx.app.notify(`Resumed: ${top.session.title} (${top.session.name})`);
624
+ }
625
+ else {
626
+ ctx.app.notify(`Couldn't load ${top.session.name}.`);
627
+ }
628
+ break;
629
+ }
630
+ if (wantSummarize) {
631
+ ctx.app.notify('Summarizing matching sessions…');
632
+ const summary = await summarizeRecall(query, matches, ctx.projectPath);
633
+ const header = formatRecall(query, matches);
634
+ const block = summary
635
+ ? `${header}\n\n---\n\n### Summary\n\n${summary}`
636
+ : header;
637
+ ctx.app.addMessage({ role: 'system', content: block });
638
+ break;
639
+ }
640
+ ctx.app.addMessage({ role: 'system', content: formatRecall(query, matches) });
641
+ break;
642
+ }
592
643
  case 'export': {
593
644
  const messages = ctx.app.getMessages();
594
645
  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',
@@ -378,8 +378,8 @@ Codeep - AI-powered coding assistant TUI
378
378
  Usage:
379
379
  codeep Start interactive chat
380
380
  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
381
+ codeep account sync Pull keys + personalities + commands from codeep.dev
382
+ codeep account push Push local keys + personalities + commands to codeep.dev
383
383
  codeep acp Start ACP server (for Zed editor integration)
384
384
  codeep --version Show version
385
385
  codeep --help Show this help
@@ -411,14 +411,26 @@ Commands (in chat):
411
411
  }
412
412
  const count = Object.keys(keys).length;
413
413
  if (count === 0) {
414
- console.log(' no keys found.\n Add keys at codeep.dev/dashboard\n');
414
+ console.log(' no keys found.\n Add keys at codeep.dev/dashboard');
415
415
  }
416
416
  else {
417
417
  for (const [provider, key] of Object.entries(keys)) {
418
418
  setApiKey(key, provider);
419
419
  }
420
- console.log(` synced ${count} key${count !== 1 ? 's' : ''}.\n`);
420
+ console.log(` synced ${count} key${count !== 1 ? 's' : ''}.`);
421
421
  }
422
+ // Also pull portable personal config — personalities + custom
423
+ // commands. Additive merge (never clobbers local files).
424
+ const { pullPersonalities, pullCommands } = await import('../utils/codeepCloud.js');
425
+ const pCount = await pullPersonalities();
426
+ if (typeof pCount === 'number' && pCount > 0) {
427
+ console.log(` Pulled ${pCount} personalit${pCount === 1 ? 'y' : 'ies'}.`);
428
+ }
429
+ const cCount = await pullCommands();
430
+ if (typeof cCount === 'number' && cCount > 0) {
431
+ console.log(` Pulled ${cCount} custom command${cCount === 1 ? '' : 's'}.`);
432
+ }
433
+ console.log('');
422
434
  process.exit(0);
423
435
  }
424
436
  if (sub === 'push') {
@@ -444,7 +456,18 @@ Commands (in chat):
444
456
  }
445
457
  process.stdout.write(` Pushing ${count} key${count !== 1 ? 's' : ''} to codeep.dev...`);
446
458
  const ok = await pushKeys(keys);
447
- console.log(ok ? ' done.\n' : ' failed.\n');
459
+ console.log(ok ? ' done.' : ' failed.');
460
+ // Also push portable personal config.
461
+ const { pushPersonalities, pushCommands } = await import('../utils/codeepCloud.js');
462
+ const pCount = await pushPersonalities();
463
+ if (typeof pCount === 'number' && pCount > 0) {
464
+ console.log(` Pushed ${pCount} personalit${pCount === 1 ? 'y' : 'ies'}.`);
465
+ }
466
+ const cCount = await pushCommands();
467
+ if (typeof cCount === 'number' && cCount > 0) {
468
+ console.log(` Pushed ${cCount} custom command${cCount === 1 ? '' : 's'}.`);
469
+ }
470
+ console.log('');
448
471
  process.exit(ok ? 0 : 1);
449
472
  }
450
473
  const { runAccountFlow } = await import('../utils/codeepCloud.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)
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeep",
3
- "version": "2.0.4",
3
+ "version": "2.1.0",
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",