Skip to content

Commit 4a1345c

Browse files
authored
agent-writeback-note-quality-gate
Enforce structured memory writeback quality gate
2 parents f8f25ef + 886d584 commit 4a1345c

16 files changed

Lines changed: 10273 additions & 104 deletions

server/src/cli/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {
66
DeleteNoteReviewResponse,
77
RecallForTaskRequest,
88
RecallForTaskResponse,
9+
ValidateTaskMemoryRequest,
10+
ValidateTaskMemoryResponse,
911
WriteTaskMemoryRequest,
1012
WriteTaskMemoryResponse,
1113
} from '@chatcrystal/shared';
@@ -352,6 +354,14 @@ export class CrystalClient {
352354
return this.request<RecallForTaskResponse>('POST', '/api/memory/recall', body);
353355
}
354356

357+
async validateTaskMemory(body: ValidateTaskMemoryRequest) {
358+
return this.request<ValidateTaskMemoryResponse>(
359+
'POST',
360+
'/api/memory/validate',
361+
body,
362+
);
363+
}
364+
355365
async writeTaskMemory(body: WriteTaskMemoryRequest) {
356366
return this.request<WriteTaskMemoryResponse>(
357367
'POST',

server/src/cli/mcp/server.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { z } from 'zod';
44
import { CrystalClient } from '../client.js';
55
import {
66
RecallForTaskRequestShape,
7+
ValidateTaskMemoryRequestShape,
78
WriteTaskMemoryRequestShape,
89
} from '../../services/memory/schemas.js';
910

@@ -106,9 +107,24 @@ export async function startMcpServer(baseUrl: string) {
106107
},
107108
);
108109

110+
server.tool(
111+
'validate_task_memory',
112+
'Preflight a task memory candidate without side effects. Use this before write_task_memory when available. Returns materialized ChatCrystal note fields, acceptance, reason, and warnings.',
113+
ValidateTaskMemoryRequestShape,
114+
async (input) => {
115+
const data = await client.validateTaskMemory(input);
116+
return {
117+
content: [{
118+
type: 'text' as const,
119+
text: JSON.stringify(data, null, 2),
120+
}],
121+
};
122+
},
123+
);
124+
109125
server.tool(
110126
'write_task_memory',
111-
'Persist a task memory with idempotent auto-writeback semantics.',
127+
'Persist a task memory only when it can become a high-quality ChatCrystal note: specific title, concrete summary, meaningful key conclusions, and a durable reusable lesson such as a pitfall, fix, decision, pattern, or symptom-to-resolution mapping. Do not write one-time environment checks, version/status reports, ordinary progress logs, or vague robustness claims. Weak auto writebacks are skipped by core validation and recorded only as receipts.',
112128
WriteTaskMemoryRequestShape,
113129
async (input) => {
114130
const data = await client.writeTaskMemory(input);

server/src/routes/memory.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { FastifyInstance } from 'fastify';
22
import { ZodError } from 'zod';
3+
import { validateTaskMemory } from '../services/memory/preflight.js';
34
import { recallForTask } from '../services/memory/recall.js';
45
import { writeTaskMemory } from '../services/memory/writeback.js';
56

@@ -20,6 +21,22 @@ export async function memoryRoutes(app: FastifyInstance) {
2021
}
2122
});
2223

24+
app.post('/api/memory/validate', async (req, reply) => {
25+
try {
26+
const data = validateTaskMemory(req.body);
27+
return { success: true, data };
28+
} catch (error) {
29+
reply.status(error instanceof ZodError ? 400 : 500);
30+
return {
31+
success: false,
32+
error:
33+
error instanceof Error
34+
? error.message
35+
: 'Invalid memory validation request',
36+
};
37+
}
38+
});
39+
2340
app.post('/api/memory/writeback', async (req, reply) => {
2441
try {
2542
const data = await writeTaskMemory(req.body);

server/src/services/embedding.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'node:path';
55
import test from 'node:test';
66
import { LocalIndex } from 'vectra';
77
import {
8+
buildNoteEmbeddingText,
89
committedVectraIdsForNote,
910
currentVectraIdsCommitted,
1011
deleteVectraItemsForNote,
@@ -17,6 +18,117 @@ function createTempDir() {
1718
return mkdtempSync(join(tmpdir(), 'chatcrystal-embedding-'));
1819
}
1920

21+
test('buildNoteEmbeddingText includes structured agent writeback memory signals', () => {
22+
const text = buildNoteEmbeddingText({
23+
title: 'Server readiness race causes ECONNREFUSED',
24+
summary: 'Requests must wait for server readiness before client calls.',
25+
keyConclusionsJson: JSON.stringify([
26+
'Await readiness before issuing HTTP requests.',
27+
'Visible key conclusions stay searchable.',
28+
]),
29+
codeSnippetsJson: JSON.stringify([
30+
{ description: 'Readiness helper wraps Fastify startup.' },
31+
{ code: 'console.log("code-only body remains searchable")' },
32+
]),
33+
tagsText: 'readiness testing',
34+
sourceType: 'agent-writeback',
35+
rawPayloadJson: JSON.stringify({
36+
root_cause: 'Client calls raced server startup.',
37+
resolution: 'Block request setup until server readiness resolves.',
38+
pitfalls: ['Do not fire API requests before Fastify ready.'],
39+
reusable_patterns: ['Share a readiness helper across HTTP tests.'],
40+
decisions: ['Keep readiness helper in test utilities.'],
41+
}),
42+
errorSignaturesJson: JSON.stringify(['ECONNREFUSED 127.0.0.1:3721']),
43+
filesTouchedJson: JSON.stringify(['server/src/test/readiness.ts']),
44+
});
45+
46+
assert.match(text, /Server readiness race causes ECONNREFUSED/);
47+
assert.match(text, /Requests must wait for server readiness/);
48+
assert.match(text, /Await readiness before issuing HTTP requests/);
49+
assert.match(text, /Visible key conclusions stay searchable/);
50+
assert.match(text, /readiness testing/);
51+
assert.match(text, /Readiness helper wraps Fastify startup/);
52+
assert.match(text, /Root cause: Client calls raced server startup\./);
53+
assert.match(text, /Resolution: Block request setup until server readiness resolves\./);
54+
assert.match(text, /Pitfall: Do not fire API requests before Fastify ready\./);
55+
assert.match(text, /Pattern: Share a readiness helper across HTTP tests\./);
56+
assert.match(text, /Decision: Keep readiness helper in test utilities\./);
57+
assert.match(text, /Error signature: ECONNREFUSED 127\.0\.0\.1:3721/);
58+
assert.match(text, /File: server\/src\/test\/readiness\.ts/);
59+
});
60+
61+
test('buildNoteEmbeddingText includes bounded code snippet bodies for memory notes', () => {
62+
const longBody = 'x'.repeat(1005);
63+
const text = buildNoteEmbeddingText({
64+
title: 'Readiness helper memory',
65+
summary: 'Code evidence should match the validation preview.',
66+
keyConclusionsJson: '[]',
67+
codeSnippetsJson: JSON.stringify([
68+
{
69+
language: 'ts',
70+
code: "await fastify.ready();\nawait client.get('/api/status');",
71+
},
72+
{
73+
code: longBody,
74+
},
75+
]),
76+
tagsText: null,
77+
sourceType: 'agent-writeback',
78+
rawPayloadJson: '{}',
79+
errorSignaturesJson: '[]',
80+
filesTouchedJson: '[]',
81+
});
82+
83+
assert.match(
84+
text,
85+
/Code snippet \(ts\): await fastify\.ready\(\); await client\.get\('\/api\/status'\);/,
86+
);
87+
assert.match(text, new RegExp(`Code snippet \\(text\\): ${'x'.repeat(1000)}(?!x)`));
88+
});
89+
90+
test('buildNoteEmbeddingText ignores malformed JSON defensively', () => {
91+
assert.doesNotThrow(() => {
92+
buildNoteEmbeddingText({
93+
title: 'Malformed memory payload',
94+
summary: 'Embedding text still includes stable fields.',
95+
keyConclusionsJson: '{not-json',
96+
codeSnippetsJson: 'also-not-json',
97+
tagsText: null,
98+
sourceType: 'agent-writeback',
99+
rawPayloadJson: '{"root_cause"',
100+
errorSignaturesJson: '[broken',
101+
filesTouchedJson: '{broken',
102+
});
103+
});
104+
});
105+
106+
test('buildNoteEmbeddingText skips raw memory payload details for imported conversations', () => {
107+
const text = buildNoteEmbeddingText({
108+
title: 'Imported conversation note',
109+
summary: 'Imported summaries still embed visible note fields.',
110+
keyConclusionsJson: JSON.stringify(['Visible imported conclusion.']),
111+
codeSnippetsJson: '[]',
112+
tagsText: null,
113+
sourceType: 'imported-conversation',
114+
rawPayloadJson: JSON.stringify({
115+
root_cause: 'SHOULD_NOT_EMBED_ROOT_CAUSE',
116+
reusable_patterns: ['SHOULD_NOT_EMBED_PATTERN'],
117+
decisions: ['SHOULD_NOT_EMBED_DECISION'],
118+
}),
119+
errorSignaturesJson: JSON.stringify(['SHOULD_NOT_EMBED_SIGNATURE']),
120+
filesTouchedJson: JSON.stringify(['SHOULD_NOT_EMBED_FILE']),
121+
});
122+
123+
assert.match(text, /Imported conversation note/);
124+
assert.match(text, /Visible imported conclusion/);
125+
assert.equal(text.includes('SHOULD_NOT_EMBED_ROOT_CAUSE'), false);
126+
assert.equal(text.includes('SHOULD_NOT_EMBED_PATTERN'), false);
127+
assert.equal(text.includes('SHOULD_NOT_EMBED_DECISION'), false);
128+
assert.equal(text.includes('SHOULD_NOT_EMBED_SIGNATURE'), false);
129+
assert.equal(text.includes('SHOULD_NOT_EMBED_FILE'), false);
130+
});
131+
20132
test('committed vectra ids come from persisted index state, not staged updates', async () => {
21133
const dir = createTempDir();
22134
const index = new LocalIndex(join(dir, 'vectra-index'));

0 commit comments

Comments
 (0)