Release v4.2.6: Critical bugfix for observation validation

Critical Bugfix:
- Fixed overly defensive observation validation blocking observations from being saved
- Parser now NEVER skips observations - always saves them
- Invalid or missing type defaults to "change" (generic catch-all type)
- Removed validation requiring title, subtitle, and narrative fields
- Prevents critical data loss - partial observations better than no observations

Impact:
- Before: Missing title, subtitle, OR narrative caused entire observation to be discarded
- After: ALL observations preserved regardless of field completeness
- Even partial observations contain valuable data: concepts, files_read, files_modified, facts
- LLMs make mistakes - system must be resilient and save everything
- Consistent with v4.2.5 summary fix

Technical changes:
- Updated src/sdk/parser.ts:52-67 to never skip observations
- Uses "change" as fallback type for invalid/missing types (no schema change)
- Updated ParsedObservation interface to allow null for title, subtitle, narrative
- Updated SessionStore.storeObservation signature to accept nullable fields
- Updated built worker-service.cjs
- Bumped version to 4.2.6 in all metadata files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2025-10-24 21:25:44 -04:00
parent 21c7ab2929
commit 322cb94c43
8 changed files with 59 additions and 34 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "4.2.5",
"version": "4.2.6",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+27 -2
View File
@@ -4,7 +4,7 @@
Claude-mem is a persistent memory compression system that preserves context across Claude Code sessions. It automatically captures tool usage observations, processes them through the Claude Agent SDK, and makes summaries available to future sessions.
**Current Version**: 4.2.5
**Current Version**: 4.2.6
**License**: AGPL-3.0
**Author**: Alex Newman (@thedotmack)
@@ -210,7 +210,32 @@ npm run build && git commit -a -m "Build and update" && git push && cd ~/.claude
## Version History
### v4.2.5 (Current)
### v4.2.6 (Current)
**Breaking Changes**: None (patch version)
**Critical Bugfix**:
- Fixed overly defensive observation validation that was blocking observations from being saved
- Removed validation requiring title, subtitle, and narrative fields
- Parser now NEVER skips observations - always saves them
- Invalid or missing type defaults to "change" (generic catch-all type)
- Prevents critical data loss - partial observations are better than no observations
**Impact**:
- Before: Missing title, subtitle, OR narrative caused entire observation to be discarded
- After: ALL observations preserved regardless of field completeness
- Even partial observations contain valuable data: concepts, files_read, files_modified, facts
- LLMs make mistakes - system must be resilient and save everything
- Consistent with v4.2.5 summary fix - partial data is always better than no data
**Technical Details**:
- Updated `src/sdk/parser.ts:52-67` to never skip observations
- Uses "change" as fallback type for invalid/missing types (no schema change needed)
- Updated ParsedObservation interface to allow null for title, subtitle, narrative
- Database schema already supports nullable fields
- Parser now matches database schema constraints exactly
- Affects `parseObservations()` function used by worker service
### v4.2.5
**Breaking Changes**: None (patch version)
**Critical Bugfix**:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "4.2.5",
"version": "4.2.6",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "4.2.5",
"version": "4.2.6",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+3 -3
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import C from"path";import q from"better-sqlite3";import{join as E,dirname as W,basename as z}from"path";import{homedir as U}from"os";import{existsSync as te,mkdirSync as j}from"fs";import{fileURLToPath as H}from"url";function B(){return typeof __dirname<"u"?__dirname:W(H(import.meta.url))}var Y=B(),u=process.env.CLAUDE_MEM_DATA_DIR||E(U(),".claude-mem"),O=process.env.CLAUDE_CONFIG_DIR||E(U(),".claude"),ne=E(u,"archives"),ie=E(u,"logs"),oe=E(u,"trash"),ae=E(u,"backups"),de=E(u,"settings.json"),w=E(u,"claude-mem.db"),pe=E(O,"settings.json"),ce=E(O,"commands"),Ee=E(O,"CLAUDE.md");function $(p){j(p,{recursive:!0})}function M(){return E(Y,"..","..")}var L=(i=>(i[i.DEBUG=0]="DEBUG",i[i.INFO=1]="INFO",i[i.WARN=2]="WARN",i[i.ERROR=3]="ERROR",i[i.SILENT=4]="SILENT",i))(L||{}),A=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=L[e]??1,this.useColor=process.stdout.isTTY??!1}correlationId(e,s){return`obs-${e}-${s}`}sessionId(e){return`session-${e}`}formatData(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return e.toString();if(typeof e=="object"){if(e instanceof Error)return this.level===0?`${e.message}
import C from"path";import q from"better-sqlite3";import{join as E,dirname as W,basename as z}from"path";import{homedir as U}from"os";import{existsSync as te,mkdirSync as j}from"fs";import{fileURLToPath as H}from"url";function B(){return typeof __dirname<"u"?__dirname:W(H(import.meta.url))}var Y=B(),m=process.env.CLAUDE_MEM_DATA_DIR||E(U(),".claude-mem"),O=process.env.CLAUDE_CONFIG_DIR||E(U(),".claude"),ne=E(m,"archives"),ie=E(m,"logs"),oe=E(m,"trash"),ae=E(m,"backups"),de=E(m,"settings.json"),w=E(m,"claude-mem.db"),pe=E(O,"settings.json"),ce=E(O,"commands"),Ee=E(O,"CLAUDE.md");function $(p){j(p,{recursive:!0})}function M(){return E(Y,"..","..")}var L=(i=>(i[i.DEBUG=0]="DEBUG",i[i.INFO=1]="INFO",i[i.WARN=2]="WARN",i[i.ERROR=3]="ERROR",i[i.SILENT=4]="SILENT",i))(L||{}),A=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=L[e]??1,this.useColor=process.stdout.isTTY??!1}correlationId(e,s){return`obs-${e}-${s}`}sessionId(e){return`session-${e}`}formatData(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return e.toString();if(typeof e=="object"){if(e instanceof Error)return this.level===0?`${e.message}
${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Object.keys(e);return s.length===0?"{}":s.length<=3?JSON.stringify(e):`{${s.length} keys: ${s.slice(0,3).join(", ")}...}`}return String(e)}formatTool(e,s){if(!s)return e;try{let t=typeof s=="string"?JSON.parse(s):s;if(e==="Bash"&&t.command){let r=t.command.length>50?t.command.substring(0,50)+"...":t.command;return`${e}(${r})`}if(e==="Read"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}if(e==="Edit"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}if(e==="Write"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}return e}catch{return e}}log(e,s,t,r,i){if(e<this.level)return;let d=new Date().toISOString().replace("T"," ").substring(0,23),n=L[e].padEnd(5),c=s.padEnd(6),_="";r?.correlationId?_=`[${r.correlationId}] `:r?.sessionId&&(_=`[session-${r.sessionId}] `);let a="";i!=null&&(this.level===0&&typeof i=="object"?a=`
`+JSON.stringify(i,null,2):a=" "+this.formatData(i));let l="";if(r){let{sessionId:G,sdkSessionId:k,correlationId:R,...T}=r;Object.keys(T).length>0&&(l=` {${Object.entries(T).map(([g,b])=>`${g}=${b}`).join(", ")}}`)}let f=`[${d}] [${n}] [${c}] ${_}${t}${l}${a}`;e===3?console.error(f):console.log(f)}debug(e,s,t,r){this.log(0,e,s,t,r)}info(e,s,t,r){this.log(1,e,s,t,r)}warn(e,s,t,r){this.log(2,e,s,t,r)}error(e,s,t,r){this.log(3,e,s,t,r)}dataIn(e,s,t,r){this.info(e,`\u2192 ${s}`,t,r)}dataOut(e,s,t,r){this.info(e,`\u2190 ${s}`,t,r)}success(e,s,t,r){this.info(e,`\u2713 ${s}`,t,r)}failure(e,s,t,r){this.error(e,`\u2717 ${s}`,t,r)}timing(e,s,t,r){this.info(e,`\u23F1 ${s}`,r,{duration:`${t}ms`})}},X=new A;var N=class{db;constructor(){$(u),this.db=new q(w),this.db.pragma("journal_mode = WAL"),this.db.pragma("synchronous = NORMAL"),this.db.pragma("foreign_keys = ON"),this.initializeSchema(),this.ensureWorkerPortColumn(),this.ensurePromptTrackingColumns(),this.removeSessionSummariesUniqueConstraint(),this.addObservationHierarchicalFields(),this.makeObservationsTextNullable(),this.createUserPromptsTable()}initializeSchema(){try{this.db.exec(`
`+JSON.stringify(i,null,2):a=" "+this.formatData(i));let l="";if(r){let{sessionId:G,sdkSessionId:k,correlationId:R,...T}=r;Object.keys(T).length>0&&(l=` {${Object.entries(T).map(([g,b])=>`${g}=${b}`).join(", ")}}`)}let f=`[${d}] [${n}] [${c}] ${_}${t}${l}${a}`;e===3?console.error(f):console.log(f)}debug(e,s,t,r){this.log(0,e,s,t,r)}info(e,s,t,r){this.log(1,e,s,t,r)}warn(e,s,t,r){this.log(2,e,s,t,r)}error(e,s,t,r){this.log(3,e,s,t,r)}dataIn(e,s,t,r){this.info(e,`\u2192 ${s}`,t,r)}dataOut(e,s,t,r){this.info(e,`\u2190 ${s}`,t,r)}success(e,s,t,r){this.info(e,`\u2713 ${s}`,t,r)}failure(e,s,t,r){this.error(e,`\u2717 ${s}`,t,r)}timing(e,s,t,r){this.info(e,`\u23F1 ${s}`,r,{duration:`${t}ms`})}},X=new A;var N=class{db;constructor(){$(m),this.db=new q(w),this.db.pragma("journal_mode = WAL"),this.db.pragma("synchronous = NORMAL"),this.db.pragma("foreign_keys = ON"),this.initializeSchema(),this.ensureWorkerPortColumn(),this.ensurePromptTrackingColumns(),this.removeSessionSummariesUniqueConstraint(),this.addObservationHierarchicalFields(),this.makeObservationsTextNullable(),this.createUserPromptsTable()}initializeSchema(){try{this.db.exec(`
CREATE TABLE IF NOT EXISTS schema_versions (
id INTEGER PRIMARY KEY,
version INTEGER UNIQUE NOT NULL,
@@ -326,5 +326,5 @@ No previous summaries found for this project yet.`;let n=[];e?(n.push(""),n.push
SELECT files_read, files_modified
FROM observations
WHERE sdk_session_id = ?
`).all(a.sdk_session_id),h=new Set,g=new Set,b=m=>{try{return C.isAbsolute(m)?C.relative(t,m):m}catch{return m}};for(let m of T){if(m.files_read)try{let S=JSON.parse(m.files_read);Array.isArray(S)&&S.forEach(I=>h.add(b(I)))}catch{}if(m.files_modified)try{let S=JSON.parse(m.files_modified);Array.isArray(S)&&S.forEach(I=>g.add(b(I)))}catch{}}g.forEach(m=>h.delete(m)),h.size>0&&(e?n.push(`${o.dim}Files Read: ${Array.from(h).join(", ")}${o.reset}`):n.push(`**Files Read:** ${Array.from(h).join(", ")}`)),g.size>0&&(e?n.push(`${o.dim}Files Modified: ${Array.from(g).join(", ")}${o.reset}`):n.push(`**Files Modified:** ${Array.from(g).join(", ")}`))}let R=new Date(a.created_at).toLocaleString();e?n.push(`${o.dim}Date: ${R}${o.reset}`):n.push(`**Date:** ${R}`),e||n.push("")}return e&&(n.push(""),n.push(`${o.gray}${"\u2500".repeat(60)}${o.reset}`)),n.join(`
`).all(a.sdk_session_id),h=new Set,g=new Set,b=u=>{try{return C.isAbsolute(u)?C.relative(t,u):u}catch{return u}};for(let u of T){if(u.files_read)try{let S=JSON.parse(u.files_read);Array.isArray(S)&&S.forEach(I=>h.add(b(I)))}catch{}if(u.files_modified)try{let S=JSON.parse(u.files_modified);Array.isArray(S)&&S.forEach(I=>g.add(b(I)))}catch{}}g.forEach(u=>h.delete(u)),h.size>0&&(e?n.push(`${o.dim}Files Read: ${Array.from(h).join(", ")}${o.reset}`):n.push(`**Files Read:** ${Array.from(h).join(", ")}`)),g.size>0&&(e?n.push(`${o.dim}Files Modified: ${Array.from(g).join(", ")}${o.reset}`):n.push(`**Files Modified:** ${Array.from(g).join(", ")}`))}let R=new Date(a.created_at).toLocaleString();e?n.push(`${o.dim}Date: ${R}${o.reset}`):n.push(`**Date:** ${R}`),e||n.push("")}return e&&(n.push(""),n.push(`${o.gray}${"\u2500".repeat(60)}${o.reset}`)),n.join(`
`)}finally{i.close()}}import{stdin as x}from"process";try{let p=process.argv.includes("--index");if(x.isTTY){let e=D(void 0,!0,p);console.log(e),process.exit(0)}else{let e="";x.on("data",s=>e+=s),x.on("end",()=>{let s=e.trim()?JSON.parse(e):void 0,r={hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:D(s,!1,p)}};console.log(JSON.stringify(r)),process.exit(0)})}}catch(p){console.error(`[claude-mem context-hook error: ${p.message}]`),process.exit(0)}
File diff suppressed because one or more lines are too long
+22 -22
View File
@@ -7,10 +7,10 @@ import { logger } from '../utils/logger.js';
export interface ParsedObservation {
type: string;
title: string;
subtitle: string;
title: string | null;
subtitle: string | null;
facts: string[];
narrative: string;
narrative: string | null;
concepts: string[];
files_read: string[];
files_modified: string[];
@@ -49,39 +49,39 @@ export function parseObservations(text: string, correlationId?: string): ParsedO
const files_read = extractArrayElements(obsContent, 'files_read', 'file');
const files_modified = extractArrayElements(obsContent, 'files_modified', 'file');
// Validate required fields
if (!type || !title || !subtitle || !narrative) {
logger.warn('PARSER', 'Observation missing required fields, skipping', {
correlationId,
hasType: !!type,
hasTitle: !!title,
hasSubtitle: !!subtitle,
hasNarrative: !!narrative
});
continue;
// NOTE FROM THEDOTMACK: ALWAYS save observations - never skip. 10/24/2025
// All fields except type are nullable in schema
// If type is missing or invalid, use "change" as catch-all fallback
// Determine final type
let finalType = 'change'; // Default catch-all
if (type) {
const validTypes = ['bugfix', 'feature', 'refactor', 'change', 'discovery', 'decision'];
if (validTypes.includes(type.trim())) {
finalType = type.trim();
} else {
logger.warn('PARSER', `Invalid observation type: ${type}, using "change"`, { correlationId });
}
} else {
logger.warn('PARSER', 'Observation missing type field, using "change"', { correlationId });
}
// Validate type
const validTypes = ['bugfix', 'feature', 'refactor', 'change', 'discovery', 'decision'];
if (!validTypes.includes(type.trim())) {
logger.warn('PARSER', `Invalid observation type: ${type}, skipping`, { correlationId });
continue;
}
// All other fields are optional - save whatever we have
// Filter out type from concepts array (types and concepts are separate dimensions)
const cleanedConcepts = concepts.filter(c => c !== type.trim());
const cleanedConcepts = concepts.filter(c => c !== finalType);
if (cleanedConcepts.length !== concepts.length) {
logger.warn('PARSER', 'Removed observation type from concepts array', {
correlationId,
type: type.trim(),
type: finalType,
originalConcepts: concepts,
cleanedConcepts
});
}
observations.push({
type: type.trim(),
type: finalType,
title,
subtitle,
facts,
+3 -3
View File
@@ -903,10 +903,10 @@ export class SessionStore {
project: string,
observation: {
type: string;
title: string;
subtitle: string;
title: string | null;
subtitle: string | null;
facts: string[];
narrative: string;
narrative: string | null;
concepts: string[];
files_read: string[];
files_modified: string[];