diff --git a/docs/codex-memory-isolation/README.md b/docs/codex-memory-isolation/README.md new file mode 100644 index 0000000..999eb9d --- /dev/null +++ b/docs/codex-memory-isolation/README.md @@ -0,0 +1,60 @@ +# Codex Memory Isolation + +This folder tracks the Codbash fork work for Codex-only, project-isolated memory management. + +## Goal + +Build a local GUI workflow that turns noisy Codex conversation logs into isolated project memory: + +- Sessions remain grouped by repository or working directory, not by unreliable generated titles. +- Each project owns its own `.codex-memory/` directory. +- `.codex-memory/` is ignored by git by default. +- OpenAI APIs generate titles, summaries, decisions, open threads, and embeddings. +- Similar sessions are clustered inside a project first; cross-project similarity is only advisory. +- Deletion remains a backed-up delete, using the Codex deletion path added in `034c481`. + +## Non-Goals + +- Managing Claude, Gemini, Cursor, or other agent histories. +- Storing project memory in `~/.codex/memories` or any global memory folder. +- Auto-merging memory across projects. +- Sending sessions to non-configured external services. +- Replacing Codex's native `resume` and `fork` commands. + +## Documents + +- `implementation-plan.md` - executable development plan for the next build stages. + +## Current Baseline + +Branch: `feat/codex-memory-isolation` + +Completed: + +- Codex session deletion now backs up before deleting. +- Deletion removes Codex session JSONL, `history.jsonl`, `session_index.jsonl`, and attempts to remove the `state_5.sqlite` thread row. +- Test coverage exists in `test/codex-delete.test.js`. + +## Desired Project Memory Layout + +```text +/.codex-memory/ + manifest.json + sessions.index.json + clusters.json + context.md + decisions.md + open-threads.md + summaries/ + .md + embeddings/ + .json +``` + +## Default Safety Rules + +1. Add `.codex-memory/` to the project `.gitignore` during initialization. +2. Keep raw Codex JSONL files out of project memory. +3. Store summaries and embeddings per project, never globally. +4. Before deleting Codex sessions, use the existing backed-up delete path. +5. Treat cross-project similarity as a suggestion, not a merge operation. diff --git a/docs/codex-memory-isolation/implementation-plan.md b/docs/codex-memory-isolation/implementation-plan.md new file mode 100644 index 0000000..401c2a7 --- /dev/null +++ b/docs/codex-memory-isolation/implementation-plan.md @@ -0,0 +1,720 @@ +# Codex Memory Isolation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Codex-only project-isolated memory management to Codbash, including project-local memory files, OpenAI summaries, embeddings, clustering, and GUI review actions. + +**Architecture:** Reuse Codbash's existing Codex session loader, LLM configuration UI, and backed-up Codex delete path. Add a new backend module that writes memory into the selected project's `.codex-memory/` folder, then expose additive API routes and a GUI view for initialization, summarization, embedding, clustering, and deletion review. + +**Tech Stack:** Node.js stdlib, Codbash zero-dependency frontend, existing OpenAI-compatible `chat/completions` client, new OpenAI-compatible `embeddings` client, JSON/Markdown project memory files, `node:test`. + +--- + +## File Structure + +| Path | Responsibility | +|---|---| +| `src/codex-memory.js` | New backend module for project path validation, memory directory initialization, index writing, summary files, embedding files, cluster calculation, and `.gitignore` updates. | +| `src/server.js` | Add `/api/codex-memory/*` routes and reuse existing LLM config loading. | +| `src/data.js` | Export the minimal Codex detail helpers needed by `src/codex-memory.js`; keep session deletion unchanged except for existing backup flow. | +| `src/frontend/app.js` | Add Codex Memory view state, API calls, project selection, summary/embedding/cluster actions, and refresh behavior. | +| `src/frontend/index.html` | Add a sidebar entry or toolbar entry for Codex Memory using the existing sidebar pattern. | +| `src/frontend/styles.css` | Add compact operational UI styles for memory status, clusters, and review rows. | +| `test/codex-memory.test.js` | Unit tests for memory initialization, `.gitignore` update, index writing, summary writing, and clustering. | +| `test/codex-memory-api.test.js` | API tests for init, status, summarize, embeddings, clusters, and write context. | +| `docs/codex-memory-isolation/README.md` | Feature overview and safety rules. | +| `docs/codex-memory-isolation/implementation-plan.md` | This plan. | + +## Data Contracts + +### `.codex-memory/manifest.json` + +```json +{ + "version": 1, + "projectPath": "/home/kk/example-project", + "projectKey": "example-project", + "createdAt": "2026-05-26T00:00:00.000Z", + "updatedAt": "2026-05-26T00:00:00.000Z", + "source": "codbash", + "agent": "codex" +} +``` + +### `.codex-memory/sessions.index.json` + +```json +{ + "version": 1, + "projectPath": "/home/kk/example-project", + "updatedAt": "2026-05-26T00:00:00.000Z", + "sessions": [ + { + "id": "019e1234-1234-7000-8000-123456789abc", + "title": "整理 Codex 记忆管理方案", + "summaryPath": "summaries/019e1234-1234-7000-8000-123456789abc.md", + "embeddingPath": "embeddings/019e1234-1234-7000-8000-123456789abc.json", + "firstTs": 1779796800000, + "lastTs": 1779799800000, + "messages": 42, + "decisionCount": 3, + "openThreadCount": 2, + "deleteRecommendation": "keep", + "clusterId": "cluster-001" + } + ] +} +``` + +### `.codex-memory/clusters.json` + +```json +{ + "version": 1, + "projectPath": "/home/kk/example-project", + "updatedAt": "2026-05-26T00:00:00.000Z", + "threshold": 0.82, + "clusters": [ + { + "id": "cluster-001", + "label": "Codex memory management", + "sessionIds": ["019e1234-1234-7000-8000-123456789abc"], + "deleteCandidates": [], + "reason": "Sessions describe the same Codbash memory isolation work." + } + ] +} +``` + +### Summary Markdown + +```markdown +# 整理 Codex 记忆管理方案 + +Session: `019e1234-1234-7000-8000-123456789abc` +Project: `/home/kk/codedash` +Recommendation: `keep` + +## Summary + +- Built the plan for project-isolated Codex memory management. + +## Decisions + +- Use project-local `.codex-memory/` instead of global Codex memory. + +## Open Threads + +- Add OpenAI embeddings and same-project clustering. + +## Files Mentioned + +- `src/data.js` +- `test/codex-delete.test.js` +``` + +## API Contract + +### `GET /api/codex-memory/status?project=` + +Response: + +```json +{ + "ok": true, + "initialized": true, + "projectPath": "/home/kk/codedash", + "memoryDir": "/home/kk/codedash/.codex-memory", + "summaryCount": 12, + "embeddingCount": 10, + "clusterCount": 4, + "ignoredByGit": true +} +``` + +### `POST /api/codex-memory/init` + +Request: + +```json +{ "project": "/home/kk/codedash" } +``` + +Response: + +```json +{ "ok": true, "memoryDir": "/home/kk/codedash/.codex-memory", "created": true, "gitignoreUpdated": true } +``` + +### `POST /api/codex-memory/rebuild-index` + +Request: + +```json +{ "project": "/home/kk/codedash" } +``` + +Response: + +```json +{ "ok": true, "indexed": 58, "path": "/home/kk/codedash/.codex-memory/sessions.index.json" } +``` + +### `POST /api/codex-memory/summarize` + +Request: + +```json +{ "project": "/home/kk/codedash", "sessionId": "019e1234-1234-7000-8000-123456789abc" } +``` + +Response: + +```json +{ "ok": true, "summaryPath": "summaries/019e1234-1234-7000-8000-123456789abc.md", "title": "整理 Codex 记忆管理方案" } +``` + +### `POST /api/codex-memory/embed` + +Request: + +```json +{ "project": "/home/kk/codedash", "sessionId": "019e1234-1234-7000-8000-123456789abc" } +``` + +Response: + +```json +{ "ok": true, "embeddingPath": "embeddings/019e1234-1234-7000-8000-123456789abc.json", "dimensions": 1536 } +``` + +### `POST /api/codex-memory/recluster` + +Request: + +```json +{ "project": "/home/kk/codedash", "threshold": 0.82 } +``` + +Response: + +```json +{ "ok": true, "clusters": 4, "deleteCandidates": 3 } +``` + +## Task 1: Memory Directory Initialization + +**Files:** +- Create: `src/codex-memory.js` +- Modify: `src/server.js` +- Test: `test/codex-memory.test.js` + +- [ ] **Step 1: Write failing tests for initialization** + +Add tests that create a temp project, initialize memory, and verify the directory layout and `.gitignore` entry. + +```js +test('initProjectMemory creates project-local memory files and ignores them in git', () => { + const project = tmpProject(); + const result = codexMemory.initProjectMemory(project); + assert.equal(result.created, true); + assert.equal(fs.existsSync(path.join(project, '.codex-memory', 'manifest.json')), true); + assert.equal(fs.existsSync(path.join(project, '.codex-memory', 'summaries')), true); + assert.equal(fs.existsSync(path.join(project, '.codex-memory', 'embeddings')), true); + assert.match(fs.readFileSync(path.join(project, '.gitignore'), 'utf8'), /^\.codex-memory\/$/m); +}); +``` + +- [ ] **Step 2: Run the failing test** + +Run: + +```bash +node --test test/codex-memory.test.js +``` + +Expected: FAIL with `Cannot find module '../src/codex-memory'`. + +- [ ] **Step 3: Add `src/codex-memory.js` initialization functions** + +Implement these exports: + +```js +module.exports = { + initProjectMemory, + getProjectMemoryStatus, + ensureGitignoreEntry, + memoryPathsForProject, +}; +``` + +Rules: + +- `project` must be an absolute path. +- `project` must exist and be a directory. +- Memory files must be created under `/.codex-memory`. +- `.gitignore` must contain exactly one `.codex-memory/` entry. +- JSON writes must use `atomicWriteJson`. + +- [ ] **Step 4: Run the initialization test** + +Run: + +```bash +node --test test/codex-memory.test.js +``` + +Expected: PASS. + +- [ ] **Step 5: Add API route tests** + +Create `test/codex-memory-api.test.js` with `POST /api/codex-memory/init` and `GET /api/codex-memory/status` coverage. + +- [ ] **Step 6: Add server routes** + +Modify `src/server.js` to route: + +```text +GET /api/codex-memory/status +POST /api/codex-memory/init +``` + +Use the existing `readBody` and `json` helpers. + +- [ ] **Step 7: Run API tests** + +Run: + +```bash +node --test test/codex-memory-api.test.js +``` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/codex-memory.js src/server.js test/codex-memory.test.js test/codex-memory-api.test.js +git commit -m "feat: initialize project-local codex memory" +``` + +## Task 2: Project Session Index + +**Files:** +- Modify: `src/codex-memory.js` +- Modify: `src/data.js` +- Test: `test/codex-memory.test.js` + +- [ ] **Step 1: Write failing index test** + +Use mocked session objects filtered by `project` or `git_root`. + +```js +test('rebuildProjectIndex writes only sessions for the selected project', () => { + const project = tmpProject(); + codexMemory.initProjectMemory(project); + const sessions = [ + { id: 'codex-a', tool: 'codex', project, git_root: project, first_ts: 1, last_ts: 2, messages: 3 }, + { id: 'claude-a', tool: 'claude', project, git_root: project, first_ts: 1, last_ts: 2, messages: 3 }, + { id: 'codex-b', tool: 'codex', project: '/elsewhere', git_root: '/elsewhere', first_ts: 1, last_ts: 2, messages: 3 } + ]; + const result = codexMemory.rebuildProjectIndex(project, sessions); + assert.equal(result.indexed, 1); + const index = JSON.parse(fs.readFileSync(path.join(project, '.codex-memory', 'sessions.index.json'), 'utf8')); + assert.deepEqual(index.sessions.map(s => s.id), ['codex-a']); +}); +``` + +- [ ] **Step 2: Implement `rebuildProjectIndex(project, sessions)`** + +Behavior: + +- Include only `tool === 'codex'`. +- Match project by `session.git_root === project` or `session.project === project`. +- Keep stable sorted order by `last_ts` descending. +- Preserve existing summary and embedding paths when rebuilding. + +- [ ] **Step 3: Export any needed helpers from `src/data.js`** + +Keep exports narrow: + +```js +module.exports = { + ...existingExports, + loadSessions, + loadSessionDetail, +}; +``` + +`loadSessions` and `loadSessionDetail` already exist; do not expose raw parser internals unless a test requires it. + +- [ ] **Step 4: Add API route** + +Add: + +```text +POST /api/codex-memory/rebuild-index +``` + +Server reads sessions with `loadSessions()`, passes them to `rebuildProjectIndex`. + +- [ ] **Step 5: Run tests** + +Run: + +```bash +node --test test/codex-memory.test.js test/codex-memory-api.test.js +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/codex-memory.js src/data.js src/server.js test/codex-memory.test.js test/codex-memory-api.test.js +git commit -m "feat: index codex sessions per project" +``` + +## Task 3: OpenAI Summary Generation + +**Files:** +- Modify: `src/server.js` +- Modify: `src/codex-memory.js` +- Modify: `src/frontend/app.js` +- Test: `test/codex-memory.test.js` + +- [ ] **Step 1: Write summary writer test** + +```js +test('writeSessionSummary stores markdown and updates index metadata', () => { + const project = tmpProject(); + codexMemory.initProjectMemory(project); + codexMemory.rebuildProjectIndex(project, [{ id: 'codex-a', tool: 'codex', project, first_ts: 1, last_ts: 2, messages: 3 }]); + const result = codexMemory.writeSessionSummary(project, 'codex-a', { + title: '整理 Codex 记忆', + summary: ['Built isolated memory planning.'], + decisions: ['Use project-local .codex-memory/.'], + openThreads: ['Add embeddings.'], + filesMentioned: ['src/data.js'], + deleteRecommendation: 'keep' + }); + assert.equal(result.summaryPath, 'summaries/codex-a.md'); + assert.match(fs.readFileSync(path.join(project, '.codex-memory', 'summaries', 'codex-a.md'), 'utf8'), /整理 Codex 记忆/); +}); +``` + +- [ ] **Step 2: Implement summary markdown writer** + +Add: + +```js +writeSessionSummary(project, sessionId, summary) +``` + +Accepted `deleteRecommendation` values: + +```text +keep +duplicate +low-value +archive +``` + +- [ ] **Step 3: Split LLM calls into reusable functions** + +Current title generation is embedded in `src/server.js`. Extract shared request logic into a server-local helper that supports: + +```js +callChatCompletions(config, messages, options) +``` + +Keep the existing `/api/generate-title` behavior unchanged. + +- [ ] **Step 4: Add summary prompt route** + +Add: + +```text +POST /api/codex-memory/summarize +``` + +Prompt response JSON shape: + +```json +{ + "title": "整理 Codex 记忆管理方案", + "summary": ["Built a fork plan for Codbash Codex memory isolation."], + "decisions": ["Use project-local .codex-memory/ directories."], + "openThreads": ["Add embedding-based clustering."], + "filesMentioned": ["src/data.js", "test/codex-delete.test.js"], + "deleteRecommendation": "keep" +} +``` + +- [ ] **Step 5: Run tests** + +Run: + +```bash +node --test test/codex-memory.test.js +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/server.js src/codex-memory.js src/frontend/app.js test/codex-memory.test.js +git commit -m "feat: summarize codex sessions into project memory" +``` + +## Task 4: Embeddings and Clustering + +**Files:** +- Modify: `src/server.js` +- Modify: `src/codex-memory.js` +- Test: `test/codex-memory.test.js` + +- [ ] **Step 1: Write cosine similarity test** + +```js +test('clusterEmbeddings groups similar sessions above threshold', () => { + const clusters = codexMemory.clusterEmbeddings([ + { sessionId: 'a', vector: [1, 0, 0], title: 'memory plan' }, + { sessionId: 'b', vector: [0.9, 0.1, 0], title: 'memory summary' }, + { sessionId: 'c', vector: [0, 1, 0], title: 'unrelated' } + ], 0.8); + assert.equal(clusters.length, 2); + assert.deepEqual(clusters[0].sessionIds, ['a', 'b']); +}); +``` + +- [ ] **Step 2: Implement embedding file writer** + +Add: + +```js +writeSessionEmbedding(project, sessionId, vector, model) +``` + +File shape: + +```json +{ + "version": 1, + "sessionId": "codex-a", + "model": "text-embedding-3-small", + "dimensions": 1536, + "vector": [0.01, 0.02] +} +``` + +- [ ] **Step 3: Add embeddings API route** + +Add: + +```text +POST /api/codex-memory/embed +``` + +Use OpenAI-compatible `/embeddings` with `config.url`, `config.apiKey`, and a model setting. If no embedding model is configured, default to `text-embedding-3-small`. + +- [ ] **Step 4: Implement project reclustering** + +Add: + +```js +reclusterProject(project, threshold) +``` + +Rules: + +- Read only embeddings in `/.codex-memory/embeddings`. +- Write `clusters.json`. +- Update `clusterId` in `sessions.index.json`. +- Mark `deleteCandidates` only inside same-project clusters. + +- [ ] **Step 5: Run tests** + +Run: + +```bash +node --test test/codex-memory.test.js +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/server.js src/codex-memory.js test/codex-memory.test.js +git commit -m "feat: cluster codex memory with embeddings" +``` + +## Task 5: Codex Memory GUI + +**Files:** +- Modify: `src/frontend/index.html` +- Modify: `src/frontend/app.js` +- Modify: `src/frontend/styles.css` +- Test: `test/frontend-escaping.test.js` + +- [ ] **Step 1: Add navigation entry** + +Add `codex-memory` to the sidebar using the existing sidebar configuration pattern. + +- [ ] **Step 2: Render Codex Memory view** + +View sections: + +```text +Project selector +Memory status +Unprocessed sessions +Summary queue +Embedding queue +Clusters +Delete candidates +``` + +- [ ] **Step 3: Wire frontend actions** + +Add functions: + +```js +initCodexMemory(project) +rebuildCodexMemoryIndex(project) +summarizeCodexMemorySession(project, sessionId) +embedCodexMemorySession(project, sessionId) +reclusterCodexMemory(project) +deleteCodexMemoryCandidates(project, sessionIds) +``` + +- [ ] **Step 4: Keep deletion routed through existing API** + +Use: + +```text +DELETE /api/session/ +``` + +The backend already backs up Codex artifacts before deleting. + +- [ ] **Step 5: Run frontend syntax checks** + +Run: + +```bash +node --check src/frontend/app.js +node --test test/frontend-escaping.test.js +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/frontend/index.html src/frontend/app.js src/frontend/styles.css test/frontend-escaping.test.js +git commit -m "feat: add codex memory review UI" +``` + +## Task 6: Project Context Export + +**Files:** +- Modify: `src/codex-memory.js` +- Modify: `src/server.js` +- Test: `test/codex-memory.test.js` + +- [ ] **Step 1: Write context generation test** + +```js +test('writeProjectContext creates a concise context markdown file', () => { + const project = tmpProject(); + codexMemory.initProjectMemory(project); + const result = codexMemory.writeProjectContext(project, { + decisions: ['Use .codex-memory/ per project.'], + openThreads: ['Connect context.md to Codex startup.'], + recentSummaries: ['Added safe Codex deletion.'] + }); + assert.equal(result.path, 'context.md'); + assert.match(fs.readFileSync(path.join(project, '.codex-memory', 'context.md'), 'utf8'), /Use \.codex-memory\/ per project/); +}); +``` + +- [ ] **Step 2: Implement context writer** + +Content sections: + +```text +# Codex Project Context +## Project Scope +## Current Decisions +## Open Threads +## Recent Session Summaries +## Cautions +``` + +- [ ] **Step 3: Add API route** + +Add: + +```text +POST /api/codex-memory/write-context +``` + +- [ ] **Step 4: Run tests** + +Run: + +```bash +node --test test/codex-memory.test.js test/codex-memory-api.test.js +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/codex-memory.js src/server.js test/codex-memory.test.js test/codex-memory-api.test.js +git commit -m "feat: export codex project context" +``` + +## End-to-End Verification + +Run: + +```bash +node --test test/*.test.js +node bin/cli.js run --port=3847 --no-browser +``` + +Manual verification: + +1. Open `http://localhost:3847`. +2. Open Codex Memory. +3. Select `/home/kk/codedash`. +4. Click `Init Memory`. +5. Confirm `/home/kk/codedash/.codex-memory/manifest.json` exists. +6. Confirm `/home/kk/codedash/.gitignore` contains `.codex-memory/`. +7. Rebuild the index. +8. Summarize one Codex session. +9. Generate its embedding. +10. Recluster. +11. Review delete candidates. +12. Delete one selected candidate and confirm a backup appears under `/home/kk/backup/codex/codbash-deleted/`. + +## Rollback + +Rollback code: + +```bash +git revert +``` + +Rollback local memory files: + +```bash +rm -rf /home/kk/codedash/.codex-memory +``` + +Rollback is safe because project memory files are isolated from Codex source logs and ignored by git by default. diff --git a/src/codex-memory.js b/src/codex-memory.js new file mode 100644 index 0000000..815a190 --- /dev/null +++ b/src/codex-memory.js @@ -0,0 +1,220 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { atomicWriteJson } = require('./atomic'); + +const MEMORY_DIR_NAME = '.codex-memory'; +const GITIGNORE_ENTRY = '.codex-memory/'; + +function validateProjectPath(project) { + if (typeof project !== 'string' || project.length === 0) { + throw new Error('project path is required'); + } + if (!path.isAbsolute(project)) { + throw new Error('project path must be an absolute path'); + } + + const projectPath = path.resolve(project); + let stat; + try { + stat = fs.statSync(projectPath); + } catch { + throw new Error('project path does not exist'); + } + if (!stat.isDirectory()) { + throw new Error('project path must be a directory'); + } + return projectPath; +} + +function memoryPathsForProject(project) { + const projectPath = validateProjectPath(project); + const memoryDir = path.join(projectPath, MEMORY_DIR_NAME); + return { + projectPath, + projectKey: path.basename(projectPath), + memoryDir, + manifest: path.join(memoryDir, 'manifest.json'), + sessionsIndex: path.join(memoryDir, 'sessions.index.json'), + clusters: path.join(memoryDir, 'clusters.json'), + decisions: path.join(memoryDir, 'decisions.md'), + openThreads: path.join(memoryDir, 'open-threads.md'), + summariesDir: path.join(memoryDir, 'summaries'), + embeddingsDir: path.join(memoryDir, 'embeddings'), + gitignore: path.join(projectPath, '.gitignore'), + }; +} + +function pathIsDirectory(filePath) { + try { + return fs.statSync(filePath).isDirectory(); + } catch { + return false; + } +} + +function pathIsFile(filePath) { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +function writeJsonIfMissing(filePath, value) { + if (!fs.existsSync(filePath)) { + atomicWriteJson(filePath, value); + } +} + +function writeTextIfMissing(filePath, value) { + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, value, 'utf8'); + } +} + +function initProjectMemory(project) { + const paths = memoryPathsForProject(project); + const alreadyInitialized = pathIsDirectory(paths.memoryDir) && pathIsFile(paths.manifest); + const now = new Date().toISOString(); + + if (fs.existsSync(paths.memoryDir) && !pathIsDirectory(paths.memoryDir)) { + throw new Error('memory path exists and is not a directory'); + } + + fs.mkdirSync(paths.memoryDir, { recursive: true }); + fs.mkdirSync(paths.summariesDir, { recursive: true }); + fs.mkdirSync(paths.embeddingsDir, { recursive: true }); + + writeJsonIfMissing(paths.manifest, { + version: 1, + projectPath: paths.projectPath, + projectKey: paths.projectKey, + createdAt: now, + updatedAt: now, + source: 'codbash', + agent: 'codex', + }); + + writeJsonIfMissing(paths.sessionsIndex, { + version: 1, + projectPath: paths.projectPath, + updatedAt: now, + sessions: [], + }); + + writeJsonIfMissing(paths.clusters, { + version: 1, + projectPath: paths.projectPath, + updatedAt: now, + clusters: [], + }); + + writeTextIfMissing(paths.decisions, '# Decisions\n\n'); + writeTextIfMissing(paths.openThreads, '# Open Threads\n\n'); + + const gitignore = ensureGitignoreEntry(paths.projectPath); + + return { + ok: true, + projectPath: paths.projectPath, + memoryDir: paths.memoryDir, + created: !alreadyInitialized, + gitignoreUpdated: gitignore.updated, + }; +} + +function ensureGitignoreEntry(project) { + const paths = memoryPathsForProject(project); + let current = ''; + + if (fs.existsSync(paths.gitignore)) { + if (!pathIsFile(paths.gitignore)) { + throw new Error('.gitignore exists and is not a file'); + } + current = fs.readFileSync(paths.gitignore, 'utf8'); + } + + const next = normalizeGitignore(current); + const updated = next !== current; + if (updated) { + fs.writeFileSync(paths.gitignore, next, 'utf8'); + } + + return { + gitignorePath: paths.gitignore, + ignoredByGit: hasGitignoreEntry(paths.projectPath), + updated, + }; +} + +function normalizeGitignore(content) { + const lines = content.split(/\r?\n/); + if (content.endsWith('\n')) lines.pop(); + + let found = false; + const nextLines = []; + for (const line of lines) { + if (line.trim() === GITIGNORE_ENTRY) { + if (!found) { + nextLines.push(GITIGNORE_ENTRY); + found = true; + } + continue; + } + if (line !== '' || content !== '') { + nextLines.push(line); + } + } + + if (!found) { + nextLines.push(GITIGNORE_ENTRY); + } + + return nextLines.join('\n') + '\n'; +} + +function hasGitignoreEntry(project) { + const paths = memoryPathsForProject(project); + if (!pathIsFile(paths.gitignore)) return false; + const content = fs.readFileSync(paths.gitignore, 'utf8'); + return content.split(/\r?\n/).some(line => line.trim() === GITIGNORE_ENTRY); +} + +function getProjectMemoryStatus(project) { + const paths = memoryPathsForProject(project); + const initialized = pathIsDirectory(paths.memoryDir) && pathIsFile(paths.manifest); + return { + ok: true, + initialized, + projectPath: paths.projectPath, + memoryDir: paths.memoryDir, + summaryCount: countFiles(paths.summariesDir), + embeddingCount: countFiles(paths.embeddingsDir), + clusterCount: countClusters(paths.clusters), + ignoredByGit: hasGitignoreEntry(paths.projectPath), + }; +} + +function countFiles(dirPath) { + if (!pathIsDirectory(dirPath)) return 0; + return fs.readdirSync(dirPath, { withFileTypes: true }).filter(entry => entry.isFile()).length; +} + +function countClusters(filePath) { + if (!pathIsFile(filePath)) return 0; + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); + return Array.isArray(data.clusters) ? data.clusters.length : 0; + } catch { + return 0; + } +} + +module.exports = { + initProjectMemory, + getProjectMemoryStatus, + ensureGitignoreEntry, + memoryPathsForProject, +}; diff --git a/src/data.js b/src/data.js index 016c202..69b0067 100644 --- a/src/data.js +++ b/src/data.js @@ -187,6 +187,7 @@ const VSCODE_APP_DATA = process.platform === 'darwin' const VSCODE_WORKSPACE_STORAGE = path.join(VSCODE_APP_DATA, 'User', 'workspaceStorage'); const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl'); const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); +const SAFE_LOCAL_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/; // Scan Claude desktop app's local-agent-mode-sessions for embedded .claude dirs // Structure: ~/Library/Application Support/Claude/local-agent-mode-sessions///local_/.claude/ @@ -2577,6 +2578,187 @@ function parseCodexSessionFile(sessionFile) { }; } +function getCodexDeleteBackupRoot() { + if (process.env.CODEBASH_DELETE_BACKUP_DIR) { + return path.resolve(process.env.CODEBASH_DELETE_BACKUP_DIR); + } + + const userBackup = path.join(ALL_HOMES[0], 'backup', 'codex'); + if (fs.existsSync(userBackup)) return path.join(userBackup, 'codbash-deleted'); + + return path.join(CODEX_DIR, 'backups', 'codbash-deleted'); +} + +function codexLineSessionId(line) { + try { + const entry = JSON.parse(line); + return entry.session_id || entry.sessionId || entry.id || ''; + } catch { + return ''; + } +} + +function collectJsonlLinesForSession(filePath, sessionId) { + if (!fs.existsSync(filePath)) return []; + const matches = []; + for (const line of readLines(filePath)) { + if (codexLineSessionId(line) === sessionId) matches.push(line); + } + return matches; +} + +function removeJsonlLinesForSession(filePath, sessionId) { + if (!fs.existsSync(filePath)) return 0; + const lines = readLines(filePath); + const filtered = lines.filter(line => codexLineSessionId(line) !== sessionId); + const removed = lines.length - filtered.length; + if (removed > 0) { + fs.writeFileSync(filePath, filtered.length ? filtered.join('\n') + '\n' : ''); + } + return removed; +} + +function codexSessionReferencesExist(sessionId) { + return collectJsonlLinesForSession(path.join(CODEX_DIR, 'history.jsonl'), sessionId).length > 0 || + collectJsonlLinesForSession(path.join(CODEX_DIR, 'session_index.jsonl'), sessionId).length > 0; +} + +function writeLinesIfAny(filePath, lines) { + if (!lines || lines.length === 0) return; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, lines.join('\n') + '\n', { mode: 0o600 }); +} + +function copyFileIfExists(source, target) { + if (!source || !fs.existsSync(source)) return false; + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(source, target); + try { fs.chmodSync(target, 0o600); } catch {} + return true; +} + +function safeSqlString(value) { + if (!SAFE_LOCAL_SESSION_ID.test(String(value || ''))) return ''; + return String(value); +} + +function backupCodexThreadRow(sessionId, backupDir) { + const safeId = safeSqlString(sessionId); + if (!safeId) return false; + const stateDb = path.join(CODEX_DIR, 'state_5.sqlite'); + if (!fs.existsSync(stateDb)) return false; + try { + const rows = execFileSync('sqlite3', ['-json', stateDb, `SELECT * FROM threads WHERE id = '${safeId}';`], { + encoding: 'utf8', + timeout: 5000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + if (!rows || rows === '[]') return false; + fs.writeFileSync(path.join(backupDir, 'state-thread.json'), rows + '\n', { mode: 0o600 }); + return true; + } catch { + return false; + } +} + +function backupCodexDeleteArtifacts(sessionId, sessionFile, project) { + const root = getCodexDeleteBackupRoot(); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupDir = path.join(root, `${stamp}-${sessionId.slice(0, 8)}`); + fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); + + const manifest = { + sessionId, + project: project || '', + deletedAt: new Date().toISOString(), + codexDir: CODEX_DIR, + artifacts: [], + }; + + if (copyFileIfExists(sessionFile, path.join(backupDir, 'session.jsonl'))) { + manifest.artifacts.push({ type: 'session', source: sessionFile, backup: 'session.jsonl' }); + } + + const historyFile = path.join(CODEX_DIR, 'history.jsonl'); + const historyLines = collectJsonlLinesForSession(historyFile, sessionId); + if (historyLines.length) { + writeLinesIfAny(path.join(backupDir, 'history.jsonl'), historyLines); + manifest.artifacts.push({ type: 'history', source: historyFile, backup: 'history.jsonl', lines: historyLines.length }); + } + + const indexFile = path.join(CODEX_DIR, 'session_index.jsonl'); + const indexLines = collectJsonlLinesForSession(indexFile, sessionId); + if (indexLines.length) { + writeLinesIfAny(path.join(backupDir, 'session_index.jsonl'), indexLines); + manifest.artifacts.push({ type: 'session_index', source: indexFile, backup: 'session_index.jsonl', lines: indexLines.length }); + } + + if (backupCodexThreadRow(sessionId, backupDir)) { + manifest.artifacts.push({ type: 'state_thread', source: path.join(CODEX_DIR, 'state_5.sqlite'), backup: 'state-thread.json' }); + } + + atomicWriteJson(path.join(backupDir, 'manifest.json'), manifest, { mode: 0o600 }); + return backupDir; +} + +function pruneEmptyDirsUntil(dir, stopDir) { + let current = dir; + const stop = path.resolve(stopDir); + while (current && path.resolve(current) !== stop && path.resolve(current).startsWith(stop + path.sep)) { + try { + if (!fs.existsSync(current) || fs.readdirSync(current).length > 0) return; + fs.rmdirSync(current); + current = path.dirname(current); + } catch { + return; + } + } +} + +function deleteCodexSession(sessionId, project, found) { + const deleted = []; + if (!SAFE_LOCAL_SESSION_ID.test(String(sessionId || ''))) return deleted; + + const sessionFile = found && found.file && fs.existsSync(found.file) ? found.file : ''; + const backupDir = backupCodexDeleteArtifacts(sessionId, sessionFile, project); + deleted.push('backup: ' + backupDir); + + if (sessionFile && fs.existsSync(sessionFile)) { + fs.unlinkSync(sessionFile); + deleted.push('codex session file'); + pruneEmptyDirsUntil(path.dirname(sessionFile), path.join(CODEX_DIR, 'sessions')); + } + + const historyRemoved = removeJsonlLinesForSession(path.join(CODEX_DIR, 'history.jsonl'), sessionId); + if (historyRemoved > 0) deleted.push(`${historyRemoved} codex history entries`); + + const indexRemoved = removeJsonlLinesForSession(path.join(CODEX_DIR, 'session_index.jsonl'), sessionId); + if (indexRemoved > 0) deleted.push(`${indexRemoved} codex index entries`); + + const safeId = safeSqlString(sessionId); + const stateDb = path.join(CODEX_DIR, 'state_5.sqlite'); + if (safeId && fs.existsSync(stateDb)) { + try { + execFileSync('sqlite3', [stateDb, `DELETE FROM threads WHERE id = '${safeId}';`], { + timeout: 5000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }); + deleted.push('codex state thread'); + } catch {} + } + + _sessionsCache = null; + _sessionsCacheTs = 0; + _sessionFileIndex = null; + _sessionFileIndexTs = 0; + _codexDayDirMtimesPending = null; + _codexSessionsDirMtimes = {}; + + return deleted; +} + function scanCodexSessions() { const sessions = []; const codexTitles = parseCodexSessionIndex(CODEX_DIR); @@ -3644,6 +3826,10 @@ function deleteSession(sessionId, project) { const deleted = []; let found = findSessionFile(sessionId, project); + if ((found && found.format === 'codex') || (!found && codexSessionReferencesExist(sessionId))) { + return deleteCodexSession(sessionId, project, found); + } + if (found && found.format === 'qwen' && fs.existsSync(found.file)) { fs.unlinkSync(found.file); deleted.push('session file'); diff --git a/src/server.js b/src/server.js index b406a9d..d6d9ed5 100644 --- a/src/server.js +++ b/src/server.js @@ -12,6 +12,7 @@ const { CHANGELOG } = require('./changelog'); const { getHTML } = require('./html'); const projectsApi = require('./projects'); const settingsApi = require('./settings'); +const codexMemory = require('./codex-memory'); // Element-level allowlist for launch flags. terminals.js currently only checks // for 'skip-permissions'; this set is the surface area we accept from clients. const ALLOWED_LAUNCH_FLAGS = new Set(['skip-permissions']); @@ -143,6 +144,11 @@ function startServer(host, port, openBrowser = true) { // handled } + // ── Codex Memory API ─────────────────── + else if (pathname.startsWith('/api/codex-memory/') && handleCodexMemoryRoute(req, res, parsed, jsonLog)) { + // handled + } + // ── Sessions API ──────────────────────── else if (req.method === 'GET' && pathname === '/api/sessions') { const sessions = loadSessions(); @@ -1216,6 +1222,40 @@ async function handleCloudProxy(req, res, pathname) { } // ── Helpers ───────────────────────────────── +function handleCodexMemoryRoute(req, res, parsed, sendJson = json) { + const pathname = parsed.pathname; + + if (req.method === 'GET' && pathname === '/api/codex-memory/status') { + try { + const project = parsed.searchParams.get('project') || ''; + sendJson(res, codexMemory.getProjectMemoryStatus(project)); + } catch (e) { + sendJson(res, { ok: false, error: e.message }, 400); + } + return true; + } + + if (req.method === 'POST' && pathname === '/api/codex-memory/init') { + readBody(req, body => { + try { + const payload = JSON.parse(body || '{}'); + const result = codexMemory.initProjectMemory(payload.project); + sendJson(res, { + ok: true, + memoryDir: result.memoryDir, + created: result.created, + gitignoreUpdated: result.gitignoreUpdated, + }); + } catch (e) { + sendJson(res, { ok: false, error: e.message }, 400); + } + }); + return true; + } + + return false; +} + function json(res, data, status = 200) { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); @@ -1728,4 +1768,4 @@ ${conversation}`; }); } -module.exports = { startServer, getKnownGitRoots }; +module.exports = { startServer, getKnownGitRoots, handleCodexMemoryRoute }; diff --git a/test/codex-delete.test.js b/test/codex-delete.test.js new file mode 100644 index 0000000..8c5a598 --- /dev/null +++ b/test/codex-delete.test.js @@ -0,0 +1,98 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +function tmpDir() { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-codex-delete-'))); +} + +function writeJsonl(filePath, entries) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n') + '\n'); +} + +function freshDataModule(home, backupDir) { + const dataPath = require.resolve('../src/data'); + delete require.cache[dataPath]; + const oldHome = os.homedir; + const oldBackup = process.env.CODEBASH_DELETE_BACKUP_DIR; + os.homedir = () => home; + process.env.CODEBASH_DELETE_BACKUP_DIR = backupDir; + try { + return require('../src/data'); + } finally { + os.homedir = oldHome; + if (oldBackup === undefined) delete process.env.CODEBASH_DELETE_BACKUP_DIR; + else process.env.CODEBASH_DELETE_BACKUP_DIR = oldBackup; + } +} + +test('deleteSession removes Codex artifacts after creating a backup', () => { + const home = tmpDir(); + const backupRoot = path.join(home, 'backup', 'codex'); + const project = path.join(home, 'work', 'demo'); + fs.mkdirSync(project, { recursive: true }); + + const sid = '019e1234-1234-7000-8000-123456789abc'; + const otherSid = '019e9999-1234-7000-8000-123456789abc'; + const codexDir = path.join(home, '.codex'); + const sessionFile = path.join(codexDir, 'sessions', '2026', '05', '26', `rollout-20260526-${sid}.jsonl`); + writeJsonl(sessionFile, [ + { type: 'session_meta', payload: { id: sid, cwd: project, timestamp: '2026-05-26T12:00:00Z' } }, + { type: 'response_item', payload: { role: 'user', content: [{ type: 'input_text', text: '整理 Codex 记忆' }] } }, + ]); + writeJsonl(path.join(codexDir, 'history.jsonl'), [ + { session_id: sid, ts: 1779796800, text: '整理 Codex 记忆', cwd: project }, + { session_id: otherSid, ts: 1779796900, text: 'keep me', cwd: project }, + ]); + writeJsonl(path.join(codexDir, 'session_index.jsonl'), [ + { id: sid, thread_name: 'messy generated title', updated_at: 1779796800000 }, + { id: otherSid, thread_name: 'keep title', updated_at: 1779796900000 }, + ]); + + const data = freshDataModule(home, backupRoot); + const deleted = data.deleteSession(sid, project); + + assert.equal(fs.existsSync(sessionFile), false); + assert.match(deleted.join('\n'), /backup:/); + assert.match(deleted.join('\n'), /codex session file/); + assert.match(deleted.join('\n'), /1 codex history entries/); + assert.match(deleted.join('\n'), /1 codex index entries/); + + const history = fs.readFileSync(path.join(codexDir, 'history.jsonl'), 'utf8'); + assert.equal(history.includes(sid), false); + assert.equal(history.includes(otherSid), true); + + const index = fs.readFileSync(path.join(codexDir, 'session_index.jsonl'), 'utf8'); + assert.equal(index.includes(sid), false); + assert.equal(index.includes(otherSid), true); + + const backupLine = deleted.find(x => x.startsWith('backup: ')); + const backupDir = backupLine.slice('backup: '.length); + assert.equal(fs.existsSync(path.join(backupDir, 'manifest.json')), true); + assert.equal(fs.existsSync(path.join(backupDir, 'session.jsonl')), true); + assert.equal(fs.readFileSync(path.join(backupDir, 'history.jsonl'), 'utf8').includes(sid), true); + assert.equal(fs.readFileSync(path.join(backupDir, 'session_index.jsonl'), 'utf8').includes(sid), true); +}); + +test('deleteSession handles Codex history-only sessions', () => { + const home = tmpDir(); + const backupRoot = path.join(home, 'backup', 'codex'); + const project = path.join(home, 'work', 'demo'); + fs.mkdirSync(project, { recursive: true }); + + const sid = '019e2234-1234-7000-8000-123456789abc'; + const codexDir = path.join(home, '.codex'); + writeJsonl(path.join(codexDir, 'history.jsonl'), [ + { session_id: sid, ts: 1779796800, text: 'history only', cwd: project }, + ]); + + const data = freshDataModule(home, backupRoot); + const deleted = data.deleteSession(sid, project); + + assert.equal(fs.readFileSync(path.join(codexDir, 'history.jsonl'), 'utf8'), ''); + assert.match(deleted.join('\n'), /backup:/); + assert.match(deleted.join('\n'), /1 codex history entries/); +}); diff --git a/test/codex-memory-api.test.js b/test/codex-memory-api.test.js new file mode 100644 index 0000000..36cfb67 --- /dev/null +++ b/test/codex-memory-api.test.js @@ -0,0 +1,127 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const http = require('http'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { URL } = require('url'); + +const { handleCodexMemoryRoute } = require('../src/server'); + +function makeProject() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-memory-api-'))); + const project = path.join(root, 'demo-project'); + fs.mkdirSync(project); + return { root, project: fs.realpathSync(project) }; +} + +function startTestServer() { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + const parsed = new URL(req.url, 'http://127.0.0.1'); + if (!handleCodexMemoryRoute(req, res, parsed)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: false, error: 'not found' })); + } + }); + server.listen(0, '127.0.0.1', () => { + resolve({ server, port: server.address().port }); + }); + }); +} + +function stopServer(server) { + return new Promise((resolve) => server.close(resolve)); +} + +function request(port, method, urlPath, body) { + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const req = http.request({ + host: '127.0.0.1', + port, + method, + path: urlPath, + headers: payload ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + } : {}, + }, (res) => { + let buf = ''; + res.on('data', chunk => buf += chunk); + res.on('end', () => { + let parsed = null; + try { parsed = buf ? JSON.parse(buf) : null; } catch {} + resolve({ status: res.statusCode, body: parsed, raw: buf }); + }); + }); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +test('GET /api/codex-memory/status returns initialized=false for a fresh project', async () => { + const { root, project } = makeProject(); + const { server, port } = await startTestServer(); + try { + const res = await request(port, 'GET', '/api/codex-memory/status?project=' + encodeURIComponent(project)); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.initialized, false); + assert.equal(res.body.projectPath, project); + assert.equal(res.body.memoryDir, path.join(project, '.codex-memory')); + assert.equal(res.body.summaryCount, 0); + assert.equal(res.body.embeddingCount, 0); + assert.equal(res.body.clusterCount, 0); + assert.equal(res.body.ignoredByGit, false); + } finally { + await stopServer(server); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('POST /api/codex-memory/init initializes memory and status reflects empty counts', async () => { + const { root, project } = makeProject(); + const { server, port } = await startTestServer(); + try { + const init = await request(port, 'POST', '/api/codex-memory/init', { project }); + assert.equal(init.status, 200); + assert.deepEqual(init.body, { + ok: true, + memoryDir: path.join(project, '.codex-memory'), + created: true, + gitignoreUpdated: true, + }); + + const status = await request(port, 'GET', '/api/codex-memory/status?project=' + encodeURIComponent(project)); + assert.equal(status.status, 200); + assert.equal(status.body.ok, true); + assert.equal(status.body.initialized, true); + assert.equal(status.body.summaryCount, 0); + assert.equal(status.body.embeddingCount, 0); + assert.equal(status.body.clusterCount, 0); + assert.equal(status.body.ignoredByGit, true); + } finally { + await stopServer(server); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('Codex memory API returns 400 for invalid project paths', async () => { + const { server, port } = await startTestServer(); + try { + const status = await request(port, 'GET', '/api/codex-memory/status?project=relative/path'); + assert.equal(status.status, 400); + assert.equal(status.body.ok, false); + assert.match(status.body.error, /absolute path/); + + const init = await request(port, 'POST', '/api/codex-memory/init', {}); + assert.equal(init.status, 400); + assert.equal(init.body.ok, false); + assert.match(init.body.error, /project path is required/); + } finally { + await stopServer(server); + } +}); diff --git a/test/codex-memory.test.js b/test/codex-memory.test.js new file mode 100644 index 0000000..b5c0db4 --- /dev/null +++ b/test/codex-memory.test.js @@ -0,0 +1,170 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const codexMemory = require('../src/codex-memory'); + +function makeProject() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codbash-memory-'))); + const project = path.join(root, 'demo-project'); + fs.mkdirSync(project); + return { root, project: fs.realpathSync(project) }; +} + +function countIgnoreEntries(project) { + const gitignore = fs.readFileSync(path.join(project, '.gitignore'), 'utf8'); + return gitignore.split(/\r?\n/).filter(line => line.trim() === '.codex-memory/').length; +} + +test('initProjectMemory creates the complete project-local memory structure', () => { + const { root, project } = makeProject(); + try { + const result = codexMemory.initProjectMemory(project); + const paths = codexMemory.memoryPathsForProject(project); + + assert.equal(result.created, true); + assert.equal(result.memoryDir, path.join(project, '.codex-memory')); + assert.equal(fs.statSync(paths.memoryDir).isDirectory(), true); + assert.equal(fs.statSync(paths.summariesDir).isDirectory(), true); + assert.equal(fs.statSync(paths.embeddingsDir).isDirectory(), true); + assert.equal(fs.existsSync(paths.manifest), true); + assert.equal(fs.existsSync(paths.sessionsIndex), true); + assert.equal(fs.existsSync(paths.clusters), true); + assert.equal(fs.existsSync(paths.decisions), true); + assert.equal(fs.existsSync(paths.openThreads), true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('initProjectMemory writes the required manifest fields', () => { + const { root, project } = makeProject(); + try { + codexMemory.initProjectMemory(project); + const manifest = JSON.parse(fs.readFileSync(path.join(project, '.codex-memory', 'manifest.json'), 'utf8')); + + assert.equal(manifest.version, 1); + assert.equal(manifest.projectPath, project); + assert.equal(manifest.projectKey, 'demo-project'); + assert.equal(manifest.source, 'codbash'); + assert.equal(manifest.agent, 'codex'); + assert.equal(typeof manifest.createdAt, 'string'); + assert.equal(Number.isNaN(Date.parse(manifest.createdAt)), false); + assert.equal(manifest.updatedAt, manifest.createdAt); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('initProjectMemory writes empty index and cluster metadata for the project', () => { + const { root, project } = makeProject(); + try { + codexMemory.initProjectMemory(project); + + const index = JSON.parse(fs.readFileSync(path.join(project, '.codex-memory', 'sessions.index.json'), 'utf8')); + assert.equal(index.version, 1); + assert.equal(index.projectPath, project); + assert.equal(typeof index.updatedAt, 'string'); + assert.deepEqual(index.sessions, []); + + const clusters = JSON.parse(fs.readFileSync(path.join(project, '.codex-memory', 'clusters.json'), 'utf8')); + assert.equal(clusters.version, 1); + assert.equal(clusters.projectPath, project); + assert.equal(typeof clusters.updatedAt, 'string'); + assert.deepEqual(clusters.clusters, []); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('initProjectMemory writes and deduplicates the .gitignore entry without overwriting existing content', () => { + const { root, project } = makeProject(); + try { + fs.writeFileSync(path.join(project, '.gitignore'), 'node_modules/\n.env\n.codex-memory/\n.codex-memory/\n', 'utf8'); + + const first = codexMemory.initProjectMemory(project); + const second = codexMemory.initProjectMemory(project); + const gitignore = fs.readFileSync(path.join(project, '.gitignore'), 'utf8'); + + assert.equal(first.gitignoreUpdated, true); + assert.equal(second.gitignoreUpdated, false); + assert.equal(countIgnoreEntries(project), 1); + assert.match(gitignore, /^node_modules\/$/m); + assert.match(gitignore, /^\.env$/m); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('initProjectMemory preserves existing memory files on repeated initialization', () => { + const { root, project } = makeProject(); + try { + const first = codexMemory.initProjectMemory(project); + const decisions = path.join(first.memoryDir, 'decisions.md'); + const manifest = path.join(first.memoryDir, 'manifest.json'); + const originalManifest = fs.readFileSync(manifest, 'utf8'); + fs.writeFileSync(decisions, 'Existing decision\n', 'utf8'); + + const second = codexMemory.initProjectMemory(project); + + assert.equal(second.created, false); + assert.equal(fs.readFileSync(decisions, 'utf8'), 'Existing decision\n'); + assert.equal(fs.readFileSync(manifest, 'utf8'), originalManifest); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('project path validation rejects empty, relative, missing, and file paths', () => { + const { root, project } = makeProject(); + try { + const filePath = path.join(project, 'not-a-directory.txt'); + fs.writeFileSync(filePath, 'nope\n', 'utf8'); + const missing = path.join(root, 'missing-project'); + + assert.throws(() => codexMemory.initProjectMemory(''), /project path is required/); + assert.throws(() => codexMemory.initProjectMemory('relative/path'), /absolute path/); + assert.throws(() => codexMemory.initProjectMemory(missing), /does not exist/); + assert.throws(() => codexMemory.initProjectMemory(filePath), /must be a directory/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('getProjectMemoryStatus reports uninitialized projects without creating memory', () => { + const { root, project } = makeProject(); + try { + const status = codexMemory.getProjectMemoryStatus(project); + + assert.equal(status.ok, true); + assert.equal(status.initialized, false); + assert.equal(status.projectPath, project); + assert.equal(status.memoryDir, path.join(project, '.codex-memory')); + assert.equal(status.summaryCount, 0); + assert.equal(status.embeddingCount, 0); + assert.equal(status.clusterCount, 0); + assert.equal(status.ignoredByGit, false); + assert.equal(fs.existsSync(status.memoryDir), false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('getProjectMemoryStatus reports initialized projects with empty counts', () => { + const { root, project } = makeProject(); + try { + codexMemory.initProjectMemory(project); + const status = codexMemory.getProjectMemoryStatus(project); + + assert.equal(status.ok, true); + assert.equal(status.initialized, true); + assert.equal(status.summaryCount, 0); + assert.equal(status.embeddingCount, 0); + assert.equal(status.clusterCount, 0); + assert.equal(status.ignoredByGit, true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +});