codeep 2.0.3 → 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 +28 -5
- package/dist/acp/commands.js +23 -0
- package/dist/acp/server.js +1 -0
- package/dist/api/index.js +29 -2
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +71 -4
- package/dist/renderer/App.js +5 -1
- package/dist/renderer/commands.js +87 -2
- package/dist/renderer/components/Help.js +4 -1
- package/dist/renderer/components/Settings.js +10 -0
- package/dist/renderer/main.js +28 -5
- package/dist/utils/agentChat.js +8 -1
- package/dist/utils/codeepCloud.d.ts +4 -0
- package/dist/utils/codeepCloud.js +96 -0
- package/dist/utils/recall.d.ts +41 -0
- package/dist/utils/recall.js +150 -0
- package/dist/utils/sessionTitles.d.ts +25 -0
- package/dist/utils/sessionTitles.js +58 -0
- package/package.json +1 -1
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
|
-
- **
|
|
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
|
|
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
|
|
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
|
|
package/dist/acp/commands.js
CHANGED
|
@@ -600,6 +600,29 @@ Anything else the agent should know — edge cases, gotchas, things to double-ch
|
|
|
600
600
|
}
|
|
601
601
|
}
|
|
602
602
|
// ─── Export ────────────────────────────────────────────────────────────────
|
|
603
|
+
// ─── Cross-session recall (2.1.0) ─────────────────────────────────────────
|
|
604
|
+
case 'recall': {
|
|
605
|
+
const wantSummarize = args.includes('--summarize');
|
|
606
|
+
// --resume is TUI-only (ACP can't swap the client's conversation
|
|
607
|
+
// in place); ignore the flag here and just show results.
|
|
608
|
+
const query = args.filter(a => a !== '--resume' && a !== '--summarize').join(' ');
|
|
609
|
+
if (!query) {
|
|
610
|
+
return { handled: true, response: 'Usage: `/recall <query> [--summarize]` — searches across all saved sessions (vs `/search`, current-session only).' };
|
|
611
|
+
}
|
|
612
|
+
const { recallSessions, formatRecall, summarizeRecall } = await import('../utils/recall.js');
|
|
613
|
+
const matches = recallSessions(query, session.workspaceRoot);
|
|
614
|
+
const header = formatRecall(query, matches);
|
|
615
|
+
if (wantSummarize && matches.length > 0) {
|
|
616
|
+
onChunk('_Summarizing matching sessions…_\n\n');
|
|
617
|
+
const summary = await summarizeRecall(query, matches, session.workspaceRoot);
|
|
618
|
+
return {
|
|
619
|
+
handled: true,
|
|
620
|
+
response: summary ? `${header}\n\n---\n\n### Summary\n\n${summary}` : header,
|
|
621
|
+
streaming: true,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
return { handled: true, response: header };
|
|
625
|
+
}
|
|
603
626
|
// ─── Personalities + insights (2.0.3) ─────────────────────────────────────
|
|
604
627
|
case 'personality': {
|
|
605
628
|
const { formatPersonalityList, findPersonality } = await import('../utils/personalities.js');
|
package/dist/acp/server.js
CHANGED
|
@@ -35,6 +35,7 @@ const AVAILABLE_COMMANDS = [
|
|
|
35
35
|
// Sessions
|
|
36
36
|
{ name: 'session', description: 'List sessions, or: new / load <name>', input: { hint: 'new | load <name>' } },
|
|
37
37
|
{ name: 'save', description: 'Save current session', input: { hint: '[name]' } },
|
|
38
|
+
{ name: 'recall', description: 'Search across ALL saved sessions (cross-session)', input: { hint: '<query> [--summarize]' } },
|
|
38
39
|
// Context
|
|
39
40
|
{ name: 'add', description: 'Add files to agent context', input: { hint: '<file> [file2…]' } },
|
|
40
41
|
{ name: 'drop', description: 'Remove files from context (no args = clear all)', input: { hint: '[file…]' } },
|
package/dist/api/index.js
CHANGED
|
@@ -233,15 +233,42 @@ const LANGUAGE_NAMES = {
|
|
|
233
233
|
'ru': 'Russian (Русский)',
|
|
234
234
|
'hr': 'Croatian (Hrvatski)',
|
|
235
235
|
};
|
|
236
|
+
/**
|
|
237
|
+
* Build the agent's identity sentence from the active provider + model.
|
|
238
|
+
* Codeep is the product; the model underneath varies. Stating it
|
|
239
|
+
* explicitly stops models from guessing their own identity (GLM and
|
|
240
|
+
* others often claim to be Claude because their training data includes
|
|
241
|
+
* Claude transcripts).
|
|
242
|
+
*/
|
|
243
|
+
function buildIdentityLine() {
|
|
244
|
+
const model = String(config.get('model') || '');
|
|
245
|
+
const providerId = String(config.get('provider') || '');
|
|
246
|
+
const known = {
|
|
247
|
+
'z.ai': 'Z.AI', 'z.ai-api': 'Z.AI', 'z.ai-cn': 'Z.AI', 'z.ai-cn-api': 'Z.AI',
|
|
248
|
+
openai: 'OpenAI', anthropic: 'Anthropic', deepseek: 'DeepSeek', google: 'Google',
|
|
249
|
+
minimax: 'MiniMax', 'minimax-api': 'MiniMax', 'minimax-cn': 'MiniMax',
|
|
250
|
+
openrouter: 'OpenRouter', ollama: 'Ollama (local)',
|
|
251
|
+
};
|
|
252
|
+
const providerName = known[providerId] || providerId || 'your configured provider';
|
|
253
|
+
if (model) {
|
|
254
|
+
return `You are Codeep, an AI coding assistant. You are running on the \`${model}\` model via ${providerName}. If asked which model or provider you are, answer truthfully with these details — do not claim to be a different model.`;
|
|
255
|
+
}
|
|
256
|
+
return `You are Codeep, an AI coding assistant.`;
|
|
257
|
+
}
|
|
236
258
|
function getSystemPrompt() {
|
|
237
259
|
const language = config.get('language');
|
|
260
|
+
// Identity line so the model doesn't hallucinate its name when asked
|
|
261
|
+
// "what model are you" — without it, models guess from their training
|
|
262
|
+
// data (e.g. GLM claiming to be Claude). State the truth: the product
|
|
263
|
+
// is Codeep, the underlying model + provider come from config.
|
|
264
|
+
const identity = buildIdentityLine();
|
|
238
265
|
let basePrompt;
|
|
239
266
|
if (language === 'auto') {
|
|
240
|
-
basePrompt =
|
|
267
|
+
basePrompt = `${identity} Always respond in the same language as the user's message. Detect the language of the user's input and reply in that same language.`;
|
|
241
268
|
}
|
|
242
269
|
else {
|
|
243
270
|
const langName = LANGUAGE_NAMES[language] || 'English';
|
|
244
|
-
basePrompt =
|
|
271
|
+
basePrompt = `${identity} Always respond in ${langName}, regardless of what language the user writes in.`;
|
|
245
272
|
}
|
|
246
273
|
// Important: This is CHAT mode, not agent mode
|
|
247
274
|
// The model should NOT pretend to execute tools or create files
|
package/dist/config/index.d.ts
CHANGED
|
@@ -23,6 +23,10 @@ interface ConfigSchema {
|
|
|
23
23
|
plan: 'lite' | 'pro' | 'max';
|
|
24
24
|
language: LanguageCode;
|
|
25
25
|
autoSave: boolean;
|
|
26
|
+
/** Auto-generate an LLM one-liner title for sessions on save. Makes a
|
|
27
|
+
* small background API call (uses the active model) once per session.
|
|
28
|
+
* Default true; set false to avoid any unsolicited API calls. */
|
|
29
|
+
autoSessionTitle: boolean;
|
|
26
30
|
currentSessionId: string;
|
|
27
31
|
temperature: number;
|
|
28
32
|
maxTokens: number;
|
|
@@ -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;
|
package/dist/config/index.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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({
|
package/dist/renderer/App.js
CHANGED
|
@@ -95,6 +95,7 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
95
95
|
'go': 'Execute the pending plan from /plan',
|
|
96
96
|
'personality': 'Switch agent tone: concise / verbose / security / senior-reviewer / etc',
|
|
97
97
|
'insights': 'Activity summary over the last N days (default 7): runs, files, tools, projects',
|
|
98
|
+
'recall': 'Search across ALL saved sessions (cross-session; /search is current-session only)',
|
|
98
99
|
};
|
|
99
100
|
import { helpCategories, keyboardShortcuts } from './components/Help.js';
|
|
100
101
|
import { handleSettingsKey, SETTINGS } from './components/Settings.js';
|
|
@@ -238,6 +239,8 @@ export class App {
|
|
|
238
239
|
'plan', 'go',
|
|
239
240
|
// 2.0.3 — personalities + insights.
|
|
240
241
|
'personality', 'insights',
|
|
242
|
+
// 2.1.0 — cross-session recall.
|
|
243
|
+
'recall',
|
|
241
244
|
'c', 't', 'd', 'r', 'f', 'e', 'o', 'b', 'p',
|
|
242
245
|
];
|
|
243
246
|
constructor(options) {
|
|
@@ -1893,7 +1896,8 @@ export class App {
|
|
|
1893
1896
|
}
|
|
1894
1897
|
// Footer
|
|
1895
1898
|
const scrollInfo = allItems.length > maxVisible ? ` (${this.helpScrollIndex + 1}-${Math.min(this.helpScrollIndex + maxVisible, allItems.length)}/${allItems.length})` : '';
|
|
1896
|
-
this.screen.writeLine(y
|
|
1899
|
+
this.screen.writeLine(y++, `↑↓ scroll • PgUp/PgDn fast scroll • Esc close${scrollInfo}`, fg.gray);
|
|
1900
|
+
this.screen.writeLine(y, 'Full guides → codeep.dev/docs · /docs <command> (e.g. /docs personality)', fg.cyan);
|
|
1897
1901
|
}
|
|
1898
1902
|
/**
|
|
1899
1903
|
* Render inline autocomplete below status bar
|
|
@@ -216,6 +216,40 @@ export async function handleCommand(command, args, ctx) {
|
|
|
216
216
|
runAgentTask(args.join(' '), true, ctx, () => null, () => { });
|
|
217
217
|
break;
|
|
218
218
|
}
|
|
219
|
+
case 'docs': {
|
|
220
|
+
// Open per-command web docs in the system browser. Lets the inline
|
|
221
|
+
// /help stay terse (single-line entries) while users who want the
|
|
222
|
+
// long story get one keystroke away from a real page.
|
|
223
|
+
const cmd = (args[0] ?? '').toLowerCase().replace(/^\//, '');
|
|
224
|
+
const KNOWN = {
|
|
225
|
+
personality: 'https://codeep.dev/docs/agent#personalities',
|
|
226
|
+
personalities: 'https://codeep.dev/docs/agent#personalities',
|
|
227
|
+
insights: 'https://codeep.dev/docs/agent#insights',
|
|
228
|
+
plan: 'https://codeep.dev/docs/agent#plan-mode',
|
|
229
|
+
go: 'https://codeep.dev/docs/agent#plan-mode',
|
|
230
|
+
mcp: 'https://codeep.dev/docs/mcp',
|
|
231
|
+
skills: 'https://codeep.dev/docs/skills',
|
|
232
|
+
checkpoint: 'https://codeep.dev/docs/commands#checkpoints',
|
|
233
|
+
rewind: 'https://codeep.dev/docs/commands#checkpoints',
|
|
234
|
+
hooks: 'https://codeep.dev/docs/commands#hooks',
|
|
235
|
+
commands: 'https://codeep.dev/docs/commands#custom-commands',
|
|
236
|
+
openrouter: 'https://codeep.dev/docs/providers#openrouter',
|
|
237
|
+
memory: 'https://codeep.dev/docs/commands#intelligence',
|
|
238
|
+
profile: 'https://codeep.dev/docs/commands#settings',
|
|
239
|
+
compact: 'https://codeep.dev/docs/commands#session',
|
|
240
|
+
cost: 'https://codeep.dev/docs/dashboard',
|
|
241
|
+
};
|
|
242
|
+
const url = cmd ? (KNOWN[cmd] ?? `https://codeep.dev/docs/commands?q=${encodeURIComponent(cmd)}`) : 'https://codeep.dev/docs';
|
|
243
|
+
try {
|
|
244
|
+
const { default: open } = await import('open');
|
|
245
|
+
await open(url);
|
|
246
|
+
ctx.app.notify(`Opening ${url}`);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
ctx.app.notify(`Couldn't open browser. Visit: ${url}`);
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
219
253
|
case 'insights': {
|
|
220
254
|
const { formatInsights } = await import('../utils/insights.js');
|
|
221
255
|
// Parse `--days N` (default 7). Accept both `--days 30` and `--days=30`.
|
|
@@ -335,13 +369,21 @@ export async function handleCommand(command, args, ctx) {
|
|
|
335
369
|
ctx.app.notify('No saved sessions');
|
|
336
370
|
return;
|
|
337
371
|
}
|
|
338
|
-
|
|
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) => {
|
|
339
381
|
const selected = sessions[index];
|
|
340
382
|
const loaded = loadSession(selected.name, ctx.projectPath);
|
|
341
383
|
if (loaded) {
|
|
342
384
|
ctx.app.setMessages(loaded);
|
|
343
385
|
ctx.setSessionId(selected.name);
|
|
344
|
-
ctx.app.notify(`Loaded: ${selected.name}`);
|
|
386
|
+
ctx.app.notify(`Loaded: ${selected.title || selected.name}`);
|
|
345
387
|
}
|
|
346
388
|
else {
|
|
347
389
|
ctx.app.notify('Failed to load session');
|
|
@@ -555,6 +597,49 @@ Format: use headers per category, only include categories where you found issues
|
|
|
555
597
|
}
|
|
556
598
|
break;
|
|
557
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
|
+
}
|
|
558
643
|
case 'export': {
|
|
559
644
|
const messages = ctx.app.getMessages();
|
|
560
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
|
|
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',
|
package/dist/renderer/main.js
CHANGED
|
@@ -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
|
|
382
|
-
codeep account push Push local
|
|
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
|
|
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' : ''}
|
|
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
|
|
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');
|
package/dist/utils/agentChat.js
CHANGED
|
@@ -182,7 +182,14 @@ export function formatChatHistoryForAgent(history, maxChars = 16000) {
|
|
|
182
182
|
}
|
|
183
183
|
export function getAgentSystemPrompt(projectContext) {
|
|
184
184
|
const root = projectContext.root || process.cwd();
|
|
185
|
-
|
|
185
|
+
// State the real underlying model/provider so "which model are you"
|
|
186
|
+
// gets a truthful answer instead of a hallucinated one.
|
|
187
|
+
const model = String(config.get('model') || '');
|
|
188
|
+
const providerId = String(config.get('provider') || '');
|
|
189
|
+
const identity = model
|
|
190
|
+
? `You are Codeep, an autonomous AI coding agent operating inside this project. The underlying model is \`${model}\` (via ${providerId}). If asked which model or provider you are, answer truthfully with these details. Never claim to be Claude or any other model unless that is genuinely the configured model.`
|
|
191
|
+
: `You are Codeep, an autonomous AI coding agent operating inside this project. Never refer to yourself as Claude or any other AI unless that is genuinely the configured model.`;
|
|
192
|
+
return `${identity}
|
|
186
193
|
|
|
187
194
|
## Tools
|
|
188
195
|
- read_file / write_file / edit_file / delete_file — file ops (prefer edit_file for modifications to keep surrounding content intact)
|
|
@@ -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
|
|
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",
|