md-first memory extraction framework for AI agents. Markdown is the single source of truth; SQLite holds state and LanceDB provides the rebuildable vector + BM25 + scalar index. The codebase follows a single-direction DDD layering (entrypoints -> service -> memory -> infra, with component / core / config cross-cutting) enforced by import-linter. Engineering surface: - Coding conventions in .claude/rules/ (path-scoped) and workflows in .claude/skills/ (/commit, /new-branch, /pr). - GitHub Actions CI runs make lint + test + integration; pre-commit mirrors the gates locally (ruff, hygiene hooks, gitlint commit-msg). - Commit messages follow Conventional Commits, enforced by gitlint. - make lint also enforces datetime two-zone discipline and OpenAPI drift.
299 lines
9.9 KiB
JavaScript
Executable File
299 lines
9.9 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
process.on('uncaughtException', () => process.exit(0));
|
|
process.on('unhandledRejection', () => process.exit(0));
|
|
|
|
import { readFileSync, existsSync } from 'fs';
|
|
import { isConfigured } from './utils/config.js'; // This loads .env
|
|
import { addMemory } from './utils/evermem-api.js';
|
|
import { debug, setDebugPrefix } from './utils/debug.js';
|
|
|
|
// Set debug prefix for this script
|
|
setDebugPrefix('store');
|
|
|
|
try {
|
|
let input = '';
|
|
for await (const chunk of process.stdin) {
|
|
input += chunk;
|
|
}
|
|
|
|
const hookInput = JSON.parse(input);
|
|
debug('hookInput:', hookInput);
|
|
const transcriptPath = hookInput.transcript_path;
|
|
|
|
// Set cwd from hook input for config.getGroupId()
|
|
if (hookInput.cwd) {
|
|
process.env.EVERMEM_CWD = hookInput.cwd;
|
|
}
|
|
|
|
if (!transcriptPath || !existsSync(transcriptPath) || !isConfigured()) {
|
|
process.exit(0);
|
|
}
|
|
|
|
/**
|
|
* Read transcript file with retry logic
|
|
* Waits for turn_duration marker which indicates the turn is complete
|
|
*/
|
|
async function readTranscriptWithRetry(path, maxRetries = 5, delayMs = 100) {
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
const content = readFileSync(path, 'utf8');
|
|
const lines = content.trim().split('\n');
|
|
|
|
// Check if the last line is turn_duration (indicates turn is complete)
|
|
let isComplete = false;
|
|
try {
|
|
const lastLine = JSON.parse(lines[lines.length - 1]);
|
|
isComplete = lastLine.type === 'system' && lastLine.subtype === 'turn_duration';
|
|
} catch {}
|
|
|
|
debug(`read attempt ${attempt}:`, {
|
|
totalLines: lines.length,
|
|
isComplete,
|
|
lastLineType: (() => {
|
|
try {
|
|
const e = JSON.parse(lines[lines.length - 1]);
|
|
return e.subtype ? `${e.type}/${e.subtype}` : e.type;
|
|
} catch { return 'unknown'; }
|
|
})()
|
|
});
|
|
|
|
if (isComplete) {
|
|
return lines;
|
|
}
|
|
|
|
if (attempt < maxRetries) {
|
|
debug(`turn not complete, waiting ${delayMs}ms before retry...`);
|
|
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
}
|
|
}
|
|
|
|
// Return whatever we have after max retries
|
|
debug('max retries reached, proceeding with current content');
|
|
const content = readFileSync(path, 'utf8');
|
|
return content.trim().split('\n');
|
|
}
|
|
|
|
const lines = await readTranscriptWithRetry(transcriptPath);
|
|
|
|
// Debug: show last 3 lines of the file (just the type)
|
|
debug('last 3 lines types:', lines.slice(-3).map((line, idx) => {
|
|
try {
|
|
const e = JSON.parse(line);
|
|
return { index: lines.length - 3 + idx, type: e.type, subtype: e.subtype, hasContent: !!e.message?.content };
|
|
} catch { return { index: lines.length - 3 + idx, error: 'parse failed' }; }
|
|
}));
|
|
|
|
/**
|
|
* Check if content is meaningful (not just whitespace/newlines)
|
|
* @param {string} text
|
|
* @returns {boolean}
|
|
*/
|
|
function hasContent(text) {
|
|
return text && text.trim().length > 0;
|
|
}
|
|
|
|
/**
|
|
* Extract the last turn's user input and assistant response
|
|
*
|
|
* A Turn = User sends message → Claude responds (may include multiple tool calls)
|
|
* Turn boundary is marked by: {"type":"system","subtype":"turn_duration"}
|
|
*
|
|
* User messages may be:
|
|
* - Original input: {"type":"user","message":{"content":"string"}}
|
|
* - Tool result: {"type":"user","message":{"content":[{"type":"tool_result",...}]}}
|
|
*
|
|
* Assistant messages may contain multiple content blocks:
|
|
* - thinking: Claude's internal reasoning
|
|
* - tool_use: Tool invocations
|
|
* - text: Final response to user (this is what we want)
|
|
*/
|
|
function extractLastTurn(lines) {
|
|
// IMPORTANT: When Stop hook runs, turn_duration for current turn hasn't been written yet.
|
|
// The turn_duration marker is written AFTER the Stop hook completes.
|
|
// So current turn END is always at the end of the file.
|
|
const turnEndIndex = lines.length;
|
|
|
|
// Current turn START is right after the last turn_duration marker.
|
|
// Only turn_duration marks turn boundaries (file-history-snapshot is NOT a turn boundary).
|
|
// If no marker found, start from beginning of file.
|
|
let turnStartIndex = 0;
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
const e = JSON.parse(lines[i]);
|
|
if (e.type === 'system' && e.subtype === 'turn_duration') {
|
|
turnStartIndex = i + 1;
|
|
break;
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
debug('turn range:', { turnStartIndex, turnEndIndex, totalLines: lines.length });
|
|
|
|
// Collect user and assistant content from the turn
|
|
const userTexts = [];
|
|
const assistantTexts = [];
|
|
|
|
// Debug: log each line's type in the turn
|
|
const lineTypes = [];
|
|
|
|
for (let i = turnStartIndex; i < turnEndIndex; i++) {
|
|
try {
|
|
const e = JSON.parse(lines[i]);
|
|
const content = e.message?.content;
|
|
|
|
// Debug: record line type
|
|
const lineInfo = { index: i, type: e.type };
|
|
if (e.type === 'assistant' && Array.isArray(content)) {
|
|
lineInfo.contentTypes = content.map(b => b.type);
|
|
}
|
|
lineTypes.push(lineInfo);
|
|
|
|
if (e.type === 'user') {
|
|
// User message - distinguish between original input and tool_result
|
|
if (typeof content === 'string') {
|
|
// Original user input (plain string)
|
|
userTexts.push(content);
|
|
} else if (Array.isArray(content)) {
|
|
// Check if it's a tool_result (skip) or text blocks (include)
|
|
for (const block of content) {
|
|
if (block.type === 'text' && block.text) {
|
|
userTexts.push(block.text);
|
|
}
|
|
// Skip tool_result - it's part of Claude's workflow, not user input
|
|
}
|
|
}
|
|
}
|
|
|
|
if (e.type === 'assistant') {
|
|
// Assistant message - extract text blocks only
|
|
if (Array.isArray(content)) {
|
|
for (const block of content) {
|
|
if (block.type === 'text' && block.text) {
|
|
assistantTexts.push(block.text);
|
|
}
|
|
// Skip: thinking (internal), tool_use (workflow)
|
|
}
|
|
} else if (typeof content === 'string') {
|
|
assistantTexts.push(content);
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
// Debug: output line types
|
|
debug('line types in turn:', lineTypes);
|
|
debug('assistantTexts count:', assistantTexts.length);
|
|
|
|
return {
|
|
user: userTexts.join('\n\n'),
|
|
assistant: assistantTexts.join('\n\n')
|
|
};
|
|
}
|
|
|
|
// Extract the last turn's content
|
|
const lastTurn = extractLastTurn(lines);
|
|
const lastUser = lastTurn.user;
|
|
const lastAssistant = lastTurn.assistant;
|
|
|
|
debug('extracted:', {
|
|
userLength: lastUser?.length || 0,
|
|
assistantLength: lastAssistant?.length || 0,
|
|
userPreview: lastUser?.slice(0, 100),
|
|
assistantPreview: lastAssistant?.slice(0, 100)
|
|
});
|
|
|
|
// Run both in parallel with Promise.all
|
|
const promises = [];
|
|
const results = [];
|
|
const skipped = [];
|
|
|
|
// Check if user content is meaningful
|
|
if (lastUser) {
|
|
if (hasContent(lastUser)) {
|
|
const len = lastUser.length;
|
|
promises.push(
|
|
addMemory({ content: lastUser, role: 'user', messageId: `u_${Date.now()}` })
|
|
.then(r => results.push({ type: 'USER', len, ...r }))
|
|
.catch(e => results.push({ type: 'USER', len, ok: false, error: e.message }))
|
|
);
|
|
} else {
|
|
skipped.push({ type: 'USER', reason: 'whitespace-only content' });
|
|
}
|
|
}
|
|
|
|
// Check if assistant content is meaningful
|
|
if (lastAssistant) {
|
|
if (hasContent(lastAssistant)) {
|
|
const len = lastAssistant.length;
|
|
promises.push(
|
|
addMemory({ content: lastAssistant, role: 'assistant', messageId: `a_${Date.now()}` })
|
|
.then(r => results.push({ type: 'ASSISTANT', len, ...r }))
|
|
.catch(e => results.push({ type: 'ASSISTANT', len, ok: false, error: e.message }))
|
|
);
|
|
} else {
|
|
skipped.push({ type: 'ASSISTANT', reason: 'whitespace-only content' });
|
|
}
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
|
|
// Check if all calls succeeded
|
|
const allSuccess = results.length > 0 && results.every(r => r.ok && !r.error);
|
|
|
|
// Debug output
|
|
debug('results:', results);
|
|
debug('skipped:', skipped);
|
|
|
|
// Build output message
|
|
let output = '';
|
|
|
|
if (allSuccess) {
|
|
const details = results.map(r => `${r.type.toLowerCase()}: ${r.len}`).join(', ');
|
|
output = `💾 Memory saved (${results.length}) [${details}]`;
|
|
// Add skipped info if any
|
|
if (skipped.length > 0) {
|
|
output += `\n⏭️ Skipped: ${skipped.map(s => `${s.type} (${s.reason})`).join(', ')}`;
|
|
}
|
|
process.stdout.write(JSON.stringify({ systemMessage: output }));
|
|
process.exit(0);
|
|
} else if (results.length === 0 && skipped.length > 0) {
|
|
// All content was skipped
|
|
output = `⏭️ EverMem: No content to save\n`;
|
|
for (const s of skipped) {
|
|
output += ` • ${s.type}: ${s.reason}\n`;
|
|
}
|
|
process.stdout.write(JSON.stringify({ systemMessage: output }));
|
|
process.exit(0);
|
|
} else {
|
|
// Failure: show detailed errors via systemMessage
|
|
function truncateBody(body) {
|
|
if (!body) return body;
|
|
const copy = { ...body };
|
|
if (copy.content && typeof copy.content === 'string' && copy.content.length > 100) {
|
|
copy.content = copy.content.substring(0, 100) + '... [truncated]';
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
output = '💾 EverMem: Save failed\n';
|
|
for (const r of results) {
|
|
if (r.error) {
|
|
output += `${r.type}: ERROR - ${r.error}\n`;
|
|
} else if (!r.ok) {
|
|
output += `${r.type}: FAILED (${r.status})\n`;
|
|
output += `Request: ${JSON.stringify(truncateBody(r.body), null, 2)}\n`;
|
|
output += `Response: ${JSON.stringify(r.response, null, 2)}\n`;
|
|
}
|
|
}
|
|
// Also show skipped if any
|
|
if (skipped.length > 0) {
|
|
output += `⏭️ Skipped: ${skipped.map(s => `${s.type} (${s.reason})`).join(', ')}\n`;
|
|
}
|
|
process.stdout.write(JSON.stringify({ systemMessage: output }));
|
|
}
|
|
|
|
} catch (e) {
|
|
// Silent on errors
|
|
process.exit(0);
|
|
}
|