Files
EverOS/use-cases/claude-code-plugin/hooks/scripts/store-memories.js
Elliot Chen 518b8eca85 chore: initialize EverOS 1.0.0
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.
2026-06-06 07:33:17 +08:00

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);
}