Skip to content

Commit 5ef9f22

Browse files
authored
Merge pull request #3 from ZengLiangYi/codex/skill-mcp-memory-loop
feat: add task memory recall and writeback loop
2 parents 00ab6c5 + be71f79 commit 5ef9f22

26 files changed

Lines changed: 2531 additions & 14 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## [0.4.6] - 2026-04-16
4+
5+
### Memory Loop
6+
7+
- **Task memory recall + writeback APIs** — Added `/api/memory/recall` and `/api/memory/writeback` with shared request/response contracts for project-first recall, global supplement recall, and agent/manual memory writeback.
8+
- **MCP memory tools** — Added `recall_for_task` and `write_task_memory` to the MCP server so external agents can retrieve and persist task memories through the same server-side contract.
9+
- **Conservative writeback semantics** — Automatic writeback now supports idempotent receipts, pending/completed indexing status, conservative merge decisions, and supplemental relations for strongly related memories.
10+
11+
### Memory Metadata
12+
13+
- **Project-scoped memory model** — Notes now store `project_key`, `scope`, `source_type`, `source_agent`, `task_kind`, `error_signatures`, `files_touched`, and `outcome_type`, with DB bootstrap/backfill for legacy imported notes.
14+
- **Synthetic memory origins** — Added synthetic origin conversations for writeback-created memories while hiding them from the default conversations list.
15+
- **Project identity stability** — Added canonical project-key derivation plus alias support so moved repos and upgraded project identifiers can still recall the same project memories.
16+
17+
### Quality
18+
19+
- **Coverage for memory services and routes** — Added regression tests across schemas, project-key derivation, recall, writeback, backfill, origin creation, decision logic, and HTTP route behavior.
20+
- **Real HTTP + MCP smoke coverage** — Verified the full writeback/recall loop through a real Fastify process and MCP stdio server with a mock embedding backend.
21+
322
## [0.4.0] - 2026-04-10
423

524
### New Data Sources

server/src/cli/client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import { spawn } from 'node:child_process';
22
import { resolve } from 'node:path';
33
import { homedir } from 'node:os';
44
import { writeFileSync, readFileSync, existsSync, unlinkSync, openSync, mkdirSync } from 'node:fs';
5+
import type {
6+
RecallForTaskRequest,
7+
RecallForTaskResponse,
8+
WriteTaskMemoryRequest,
9+
WriteTaskMemoryResponse,
10+
} from '@chatcrystal/shared';
511

612
function getDataDir(): string {
713
if (import.meta.dirname.includes('node_modules')) {
@@ -303,6 +309,18 @@ export class CrystalClient {
303309
}>>('GET', `/api/notes/${id}/relations`);
304310
}
305311

312+
async recallForTask(body: RecallForTaskRequest) {
313+
return this.request<RecallForTaskResponse>('POST', '/api/memory/recall', body);
314+
}
315+
316+
async writeTaskMemory(body: WriteTaskMemoryRequest) {
317+
return this.request<WriteTaskMemoryResponse>(
318+
'POST',
319+
'/api/memory/writeback',
320+
body,
321+
);
322+
}
323+
306324
async listTags() {
307325
return this.request<Array<{ id: number; name: string; count: number }>>('GET', '/api/tags');
308326
}

server/src/cli/mcp/server.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
22
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
33
import { z } from 'zod';
44
import { CrystalClient } from '../client.js';
5+
import {
6+
RecallForTaskRequestShape,
7+
WriteTaskMemoryRequestShape,
8+
} from '../../services/memory/schemas.js';
59

610
export async function startMcpServer(baseUrl: string) {
711
const client = new CrystalClient(baseUrl);
@@ -87,6 +91,36 @@ export async function startMcpServer(baseUrl: string) {
8791
},
8892
);
8993

94+
server.tool(
95+
'recall_for_task',
96+
'Recall project-first and global-supplement memories for a task.',
97+
RecallForTaskRequestShape,
98+
async (input) => {
99+
const data = await client.recallForTask(input);
100+
return {
101+
content: [{
102+
type: 'text' as const,
103+
text: JSON.stringify(data, null, 2),
104+
}],
105+
};
106+
},
107+
);
108+
109+
server.tool(
110+
'write_task_memory',
111+
'Persist a task memory with idempotent auto-writeback semantics.',
112+
WriteTaskMemoryRequestShape,
113+
async (input) => {
114+
const data = await client.writeTaskMemory(input);
115+
return {
116+
content: [{
117+
type: 'text' as const,
118+
text: JSON.stringify(data, null, 2),
119+
}],
120+
};
121+
},
122+
);
123+
90124
// Start stdio transport
91125
const transport = new StdioServerTransport();
92126
await server.connect(transport);

server/src/db/index.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,21 @@ import initSqlJs, { type Database } from 'sql.js';
22
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
33
import { resolve, dirname, join } from 'node:path';
44
import { appConfig } from '../config.js';
5+
import { backfillImportedNoteMetadata } from '../services/memory/backfill.js';
56
import { SCHEMA_SQL } from './schema.js';
67

78
let db: Database | null = null;
89

910
const DB_PATH = resolve(appConfig.dataDir, 'chatcrystal.db');
1011

12+
function ensureColumn(db: Database, table: string, column: string, sql: string) {
13+
const info = db.exec(`PRAGMA table_info(${table})`);
14+
const columns = info[0]?.values.map((row) => String(row[1])) ?? [];
15+
if (!columns.includes(column)) {
16+
db.run(sql);
17+
}
18+
}
19+
1120
export async function initDatabase(): Promise<Database> {
1221
if (db) return db;
1322

@@ -38,16 +47,26 @@ export async function initDatabase(): Promise<Database> {
3847
// Run schema migration
3948
db.exec(SCHEMA_SQL);
4049

41-
// Migrate: add embedding_status column if missing (for existing DBs)
42-
const colCheck = db.exec("PRAGMA table_info(notes)");
43-
const columns = colCheck[0]?.values.map((row) => row[1] as string) ?? [];
44-
if (!columns.includes('embedding_status')) {
45-
db.run("ALTER TABLE notes ADD COLUMN embedding_status TEXT DEFAULT 'pending'");
46-
// Mark notes that already have embeddings as done
47-
db.run(`UPDATE notes SET embedding_status = 'done'
48-
WHERE id IN (SELECT DISTINCT note_id FROM embeddings)`);
49-
console.log('[DB] Migrated: added embedding_status column to notes');
50-
}
50+
ensureColumn(db, 'notes', 'embedding_status', "ALTER TABLE notes ADD COLUMN embedding_status TEXT DEFAULT 'pending'");
51+
ensureColumn(db, 'notes', 'project_key', 'ALTER TABLE notes ADD COLUMN project_key TEXT');
52+
ensureColumn(db, 'notes', 'scope', "ALTER TABLE notes ADD COLUMN scope TEXT DEFAULT 'project'");
53+
ensureColumn(db, 'notes', 'source_type', "ALTER TABLE notes ADD COLUMN source_type TEXT DEFAULT 'imported-conversation'");
54+
ensureColumn(db, 'notes', 'source_agent', "ALTER TABLE notes ADD COLUMN source_agent TEXT DEFAULT 'unknown'");
55+
ensureColumn(db, 'notes', 'task_kind', 'ALTER TABLE notes ADD COLUMN task_kind TEXT');
56+
ensureColumn(db, 'notes', 'error_signatures', 'ALTER TABLE notes ADD COLUMN error_signatures TEXT');
57+
ensureColumn(db, 'notes', 'files_touched', 'ALTER TABLE notes ADD COLUMN files_touched TEXT');
58+
ensureColumn(db, 'notes', 'outcome_type', 'ALTER TABLE notes ADD COLUMN outcome_type TEXT');
59+
ensureColumn(
60+
db,
61+
'writeback_receipts',
62+
'index_status',
63+
"ALTER TABLE writeback_receipts ADD COLUMN index_status TEXT DEFAULT 'pending'",
64+
);
65+
66+
// Mark notes that already have embeddings as done
67+
db.run(`UPDATE notes SET embedding_status = 'done'
68+
WHERE id IN (SELECT DISTINCT note_id FROM embeddings)`);
69+
backfillImportedNoteMetadata(db);
5170

5271
// Persist to disk
5372
saveDatabase();

server/src/db/schema.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,40 @@ CREATE TABLE IF NOT EXISTS notes (
4848
raw_llm_response TEXT,
4949
is_edited INTEGER DEFAULT 0,
5050
embedding_status TEXT DEFAULT 'pending',
51+
project_key TEXT,
52+
scope TEXT DEFAULT 'project',
53+
source_type TEXT DEFAULT 'imported-conversation',
54+
source_agent TEXT DEFAULT 'unknown',
55+
task_kind TEXT,
56+
error_signatures TEXT,
57+
files_touched TEXT,
58+
outcome_type TEXT,
5159
created_at TEXT DEFAULT (datetime('now')),
5260
updated_at TEXT DEFAULT (datetime('now')),
5361
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
5462
);
5563
64+
CREATE TABLE IF NOT EXISTS writeback_receipts (
65+
id INTEGER PRIMARY KEY AUTOINCREMENT,
66+
source_agent TEXT NOT NULL,
67+
source_run_key TEXT NOT NULL,
68+
decision TEXT NOT NULL,
69+
note_id INTEGER,
70+
merged_into_note_id INTEGER,
71+
reason TEXT NOT NULL,
72+
index_status TEXT NOT NULL DEFAULT 'pending',
73+
created_at TEXT DEFAULT (datetime('now')),
74+
UNIQUE(source_agent, source_run_key),
75+
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE SET NULL,
76+
FOREIGN KEY (merged_into_note_id) REFERENCES notes(id) ON DELETE SET NULL
77+
);
78+
79+
CREATE TABLE IF NOT EXISTS project_key_aliases (
80+
alias_key TEXT PRIMARY KEY,
81+
canonical_key TEXT NOT NULL,
82+
created_at TEXT DEFAULT (datetime('now'))
83+
);
84+
5685
CREATE TABLE IF NOT EXISTS tags (
5786
id INTEGER PRIMARY KEY AUTOINCREMENT,
5887
name TEXT UNIQUE NOT NULL
@@ -90,8 +119,10 @@ CREATE INDEX IF NOT EXISTS idx_conversations_source ON conversations(source);
90119
CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(status);
91120
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id);
92121
CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(type);
122+
CREATE INDEX IF NOT EXISTS idx_notes_project_key ON notes(project_key);
93123
CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_id);
94124
CREATE INDEX IF NOT EXISTS idx_embeddings_note ON embeddings(note_id);
125+
CREATE INDEX IF NOT EXISTS idx_project_key_aliases_canonical ON project_key_aliases(canonical_key);
95126
96127
CREATE TABLE IF NOT EXISTS note_relations (
97128
id INTEGER PRIMARY KEY AUTOINCREMENT,

server/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { conversationRoutes } from './routes/conversations.js';
1616
import { noteRoutes } from './routes/notes.js';
1717
import { configRoutes } from './routes/config.js';
1818
import { relationRoutes } from './routes/relations.js';
19+
import { memoryRoutes } from './routes/memory.js';
1920

2021
// Initialize parser adapters (registers built-in adapters)
2122
import './parser/index.js';
@@ -52,6 +53,7 @@ export async function createServer(options?: {
5253
await app.register(noteRoutes);
5354
await app.register(configRoutes);
5455
await app.register(relationRoutes);
56+
await app.register(memoryRoutes);
5557

5658
// Serve frontend in production
5759
// Try multiple possible paths (source layout vs compiled layout)

server/src/routes/conversations.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export async function conversationRoutes(app: FastifyInstance) {
1515
} = req.query as Record<string, string>;
1616

1717
const db = getDatabase();
18-
const conditions: string[] = [];
18+
const conditions: string[] = ["c.source != 'chatcrystal-memory'"];
1919
const params: (string | number)[] = [];
2020

2121
if (source) {
@@ -109,4 +109,3 @@ export async function conversationRoutes(app: FastifyInstance) {
109109
return { success: true };
110110
});
111111
}
112-

server/src/routes/memory.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import Fastify from 'fastify';
4+
import { memoryRoutes } from './memory.js';
5+
6+
test('memory recall returns 400 for validation failures', async () => {
7+
const app = Fastify();
8+
await app.register(memoryRoutes);
9+
10+
const response = await app.inject({
11+
method: 'POST',
12+
url: '/api/memory/recall',
13+
payload: {
14+
mode: 'task',
15+
task: {
16+
task_kind: 'debug',
17+
},
18+
},
19+
});
20+
21+
assert.equal(response.statusCode, 400);
22+
await app.close();
23+
});
24+
25+
test('memory recall returns 500 for internal failures after validation succeeds', async () => {
26+
const app = Fastify();
27+
await app.register(memoryRoutes);
28+
29+
const response = await app.inject({
30+
method: 'POST',
31+
url: '/api/memory/recall',
32+
payload: {
33+
mode: 'task',
34+
task: {
35+
goal: 'Fix flaky timeout',
36+
task_kind: 'debug',
37+
},
38+
},
39+
});
40+
41+
assert.equal(response.statusCode, 500);
42+
await app.close();
43+
});
44+
45+
test('memory writeback returns 500 for internal failures after validation succeeds', async () => {
46+
const app = Fastify();
47+
await app.register(memoryRoutes);
48+
49+
const response = await app.inject({
50+
method: 'POST',
51+
url: '/api/memory/writeback',
52+
payload: {
53+
mode: 'manual',
54+
task: {
55+
goal: 'Capture a reusable fix',
56+
task_kind: 'debug',
57+
},
58+
memory: {
59+
summary: 'Await readiness before requests.',
60+
outcome_type: 'fix',
61+
},
62+
},
63+
});
64+
65+
assert.equal(response.statusCode, 500);
66+
await app.close();
67+
});

server/src/routes/memory.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { FastifyInstance } from 'fastify';
2+
import { ZodError } from 'zod';
3+
import { recallForTask } from '../services/memory/recall.js';
4+
import { writeTaskMemory } from '../services/memory/writeback.js';
5+
6+
export async function memoryRoutes(app: FastifyInstance) {
7+
app.post('/api/memory/recall', async (req, reply) => {
8+
try {
9+
const data = await recallForTask(req.body);
10+
return { success: true, data };
11+
} catch (error) {
12+
reply.status(error instanceof ZodError ? 400 : 500);
13+
return {
14+
success: false,
15+
error:
16+
error instanceof Error
17+
? error.message
18+
: 'Invalid recall request',
19+
};
20+
}
21+
});
22+
23+
app.post('/api/memory/writeback', async (req, reply) => {
24+
try {
25+
const data = await writeTaskMemory(req.body);
26+
return { success: true, data };
27+
} catch (error) {
28+
reply.status(error instanceof ZodError ? 400 : 500);
29+
return {
30+
success: false,
31+
error:
32+
error instanceof Error
33+
? error.message
34+
: 'Invalid writeback request',
35+
};
36+
}
37+
});
38+
}

server/src/routes/notes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ function hydrateNote(row: Record<string, unknown>): Record<string, unknown> {
1010
...row,
1111
key_conclusions: row.key_conclusions ? JSON.parse(row.key_conclusions as string) : [],
1212
code_snippets: row.code_snippets ? JSON.parse(row.code_snippets as string) : [],
13+
error_signatures: row.error_signatures ? JSON.parse(row.error_signatures as string) : [],
14+
files_touched: row.files_touched ? JSON.parse(row.files_touched as string) : [],
1315
tags: row.tags_csv ? (row.tags_csv as string).split(',') : [],
1416
is_edited: Boolean(row.is_edited),
1517
};

0 commit comments

Comments
 (0)