diff --git a/src/cli/commands/ingest-commit.ts b/src/cli/commands/ingest-commit.ts index 1d1475f..0deddc5 100644 --- a/src/cli/commands/ingest-commit.ts +++ b/src/cli/commands/ingest-commit.ts @@ -122,6 +122,7 @@ export default defineCommand({ entityName: result.entityName, type: result.type as any, title: result.title, + sourceDetail: 'git-ingest', narrative: result.narrative, facts: result.facts, concepts: result.concepts, diff --git a/src/cli/commands/ingest-log.ts b/src/cli/commands/ingest-log.ts index 39c65da..88d9777 100644 --- a/src/cli/commands/ingest-log.ts +++ b/src/cli/commands/ingest-log.ts @@ -104,6 +104,7 @@ export default defineCommand({ entityName: result.entityName, type: result.type as any, title: result.title, + sourceDetail: 'git-ingest', narrative: result.narrative, facts: result.facts, concepts: result.concepts, diff --git a/src/cli/index.ts b/src/cli/index.ts index e59751d..7be33fa 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -146,6 +146,7 @@ async function runRemember(text: string): Promise { narrative: text, facts: [], projectId: proj.id, + sourceDetail: 'explicit', }); s.stop('Stored'); diff --git a/src/cli/tui/data.ts b/src/cli/tui/data.ts index 12423e9..78d86d9 100644 --- a/src/cli/tui/data.ts +++ b/src/cli/tui/data.ts @@ -299,6 +299,7 @@ export async function storeQuickMemory(text: string): Promise<{ id: number; titl narrative: text, facts: [], projectId: proj.id, + sourceDetail: 'explicit', }); return { id: result.observation.id, title: text.slice(0, 100) }; diff --git a/src/compact/engine.ts b/src/compact/engine.ts index 035a098..78c95e7 100644 --- a/src/compact/engine.ts +++ b/src/compact/engine.ts @@ -103,6 +103,8 @@ export async function compactDetail( lastAccessedAt: '', status: obs.status ?? 'active', source: obs.source ?? 'agent', + sourceDetail: obs.sourceDetail ?? '', + valueCategory: obs.valueCategory ?? '', }); } else { missingRefs.push(ref); diff --git a/src/compact/index-format.ts b/src/compact/index-format.ts index 2757043..94968a2 100644 --- a/src/compact/index-format.ts +++ b/src/compact/index-format.ts @@ -5,6 +5,7 @@ */ import type { IndexEntry, TimelineContext } from '../types.js'; +import { sourceBadge, resolveSourceDetail } from '../memory/disclosure-policy.js'; /** * Format a list of IndexEntries as a compact markdown table. @@ -18,6 +19,23 @@ export function formatIndexTable(entries: IndexEntry[], query?: string, forcePro const lines: string[] = []; + // Tier summary: shown when entries have mixed provenance + const badges = entries.map((e) => sourceBadge(e.sourceDetail, e.source)); + const distinctBadges = new Set(badges.filter(Boolean)); + if (distinctBadges.size > 1 || (distinctBadges.size === 1 && badges.some((b) => !b))) { + const exCount = badges.filter((b) => b === 'ex').length; + const hkCount = badges.filter((b) => b === 'hk').length; + const gitCount = badges.filter((b) => b === 'git').length; + const unknownCount = badges.filter((b) => !b).length; + const parts: string[] = []; + if (exCount > 0) parts.push(`${exCount} explicit`); + if (unknownCount > 0) parts.push(`${unknownCount} legacy`); + if (hkCount > 0) parts.push(`${hkCount} hook`); + if (gitCount > 0) parts.push(`${gitCount} git`); + lines.push(`Sources: ${parts.join(' · ')}`); + lines.push(''); + } + if (query) { lines.push(`Found ${entries.length} observation(s) matching "${query}":`); lines.push(''); @@ -26,9 +44,15 @@ export function formatIndexTable(entries: IndexEntry[], query?: string, forcePro const distinctProjects = [...new Set(entries.map((entry) => entry.projectId).filter(Boolean))]; const hasProject = forceProjectColumn || distinctProjects.length > 1; const hasExplanation = entries.some((entry) => (entry.matchedFields?.length ?? 0) > 0); + // Show Src column when at least one entry has provenance (sourceDetail or legacy source='git') + const hasSrc = entries.some((e) => !!e.sourceDetail || e.source === 'git'); const header = ['ID', 'Time', 'T', 'Title', 'Tokens']; const divider = ['----', '------', '---', '-------', '--------']; + if (hasSrc) { + header.push('Src'); + divider.push('---'); + } if (hasProject) { header.push('Project'); divider.push('---------'); @@ -43,6 +67,7 @@ export function formatIndexTable(entries: IndexEntry[], query?: string, forcePro for (const entry of entries) { const row = [`#${entry.id}`, entry.time, entry.icon, entry.title, `~${entry.tokens}`]; + if (hasSrc) row.push(sourceBadge(entry.sourceDetail, entry.source) || '-'); if (hasProject) row.push(entry.projectId ?? '-'); if (hasExplanation) row.push(entry.matchedFields?.join(', ') ?? '-'); lines.push(`| ${row.join(' | ')} |`); @@ -56,39 +81,62 @@ export function formatIndexTable(entries: IndexEntry[], query?: string, forcePro /** * Format a timeline context around an anchor observation. + * When any entry carries sourceDetail provenance, adds a Src column and + * annotates the anchor with its evidence kind. Falls back to the original + * table format when no provenance is present (backward-compat). */ export function formatTimeline(timeline: TimelineContext): string { if (!timeline.anchorEntry) { return `Observation #${timeline.anchorId} not found.`; } + const anchor = timeline.anchorEntry; + + // Detect provenance across all entries — conditional Src column + // Includes legacy source='git' fallback. + const allEntries = [...timeline.before, anchor, ...timeline.after]; + const hasSrc = allEntries.some((e) => !!e.sourceDetail || e.source === 'git'); + + const tableHeader = hasSrc ? '| ID | Time | T | Title | Tokens | Src |' : '| ID | Time | T | Title | Tokens |'; + const tableDivider = hasSrc ? '|----|------|---|-------|--------|-----|' : '|----|------|---|-------|--------|'; + + const entryRow = (e: IndexEntry): string => { + const base = `| #${e.id} | ${e.time} | ${e.icon} | ${e.title} | ~${e.tokens} |`; + return hasSrc ? `${base} ${sourceBadge(e.sourceDetail, e.source) || '-'} |` : base; + }; + const lines: string[] = []; lines.push(`Timeline around #${timeline.anchorId}:`); + + // Anchor kind annotation — shown when provenance is available (sourceDetail or legacy source='git') + const anchorEffectiveSource = resolveSourceDetail(anchor.sourceDetail, anchor.source); + if (hasSrc && anchorEffectiveSource) { + lines.push(`*Expanding: ${sourceKindLabel(anchorEffectiveSource)}*`); + } lines.push(''); if (timeline.before.length > 0) { lines.push('**Before:**'); - lines.push('| ID | Time | T | Title | Tokens |'); - lines.push('|----|------|---|-------|--------|'); + lines.push(tableHeader); + lines.push(tableDivider); for (const entry of timeline.before) { - lines.push(`| #${entry.id} | ${entry.time} | ${entry.icon} | ${entry.title} | ~${entry.tokens} |`); + lines.push(entryRow(entry)); } lines.push(''); } lines.push('**Anchor:**'); - lines.push('| ID | Time | T | Title | Tokens |'); - lines.push('|----|------|---|-------|--------|'); - const anchor = timeline.anchorEntry; - lines.push(`| #${anchor.id} | ${anchor.time} | ${anchor.icon} | ${anchor.title} | ~${anchor.tokens} |`); + lines.push(tableHeader); + lines.push(tableDivider); + lines.push(entryRow(anchor)); lines.push(''); if (timeline.after.length > 0) { lines.push('**After:**'); - lines.push('| ID | Time | T | Title | Tokens |'); - lines.push('|----|------|---|-------|--------|'); + lines.push(tableHeader); + lines.push(tableDivider); for (const entry of timeline.after) { - lines.push(`| #${entry.id} | ${entry.time} | ${entry.icon} | ${entry.title} | ~${entry.tokens} |`); + lines.push(entryRow(entry)); } lines.push(''); } @@ -99,6 +147,9 @@ export function formatTimeline(timeline: TimelineContext): string { /** * Format full observation details (Layer 3). + * When sourceDetail/valueCategory are present, prepends a provenance header + * that clearly identifies the evidence kind before the main #ID block. + * Backward-compatible: if neither field is set, output is identical to before. */ export function formatObservationDetail(doc: { observationId: number; @@ -111,10 +162,20 @@ export function formatObservationDetail(doc: { createdAt: string; projectId: string; entityName: string; + sourceDetail?: string; + valueCategory?: string; + source?: string; }): string { const icon = getTypeIcon(doc.type); const lines: string[] = []; + // Provenance header — shown before #ID when sourceDetail (or legacy source='git') is set + const header = buildProvenanceHeader(doc.sourceDetail, doc.valueCategory, doc.source); + if (header) { + lines.push(header); + lines.push(''); + } + lines.push(`#${doc.observationId} ${icon} ${doc.title}`); lines.push('='.repeat(50)); lines.push(`Date: ${new Date(doc.createdAt).toLocaleString()}`); @@ -150,6 +211,38 @@ export function formatObservationDetail(doc: { return lines.join('\n'); } +/** + * Build a compact provenance header for detail output. + * Returns empty string when no provenance can be resolved (backward-compat). + * Supports legacy source='git' via resolveSourceDetail fallback. + */ +function buildProvenanceHeader(sourceDetail?: string, valueCategory?: string, source?: string): string { + const sd = resolveSourceDetail(sourceDetail, source); + if (!sd) return ''; + + const label = sourceKindLabel(sd); + const layer = sd === 'git-ingest' ? 'L3 — evidence' + : sd === 'hook' ? 'L1 — activity routing signal' + : 'L2 — durable working context'; + + const lines = [`${label} [${layer}]`]; + + if (valueCategory === 'core') { + lines.push('★ Core — immune to decay'); + } else if (valueCategory === 'ephemeral') { + lines.push('⚠ Ephemeral — short-lived signal'); + } + + return lines.join('\n'); +} + +/** Short label for a resolved sourceDetail value, used in headers and timeline annotations. */ +function sourceKindLabel(sd: string): string { + if (sd === 'git-ingest') return '📌 Git Repository Evidence'; + if (sd === 'hook') return '🔗 Hook Trace'; + return '💾 Explicit Working Memory'; +} + function getTypeIcon(type: string): string { const icons: Record = { 'session-request': '🎯', diff --git a/src/hooks/handler.ts b/src/hooks/handler.ts index 56a53dc..24e30c0 100644 --- a/src/hooks/handler.ts +++ b/src/hooks/handler.ts @@ -523,7 +523,7 @@ export async function runHook(agentOverride?: string): Promise { const projectId = canonicalId; await initObservations(dataDir); - await storeObservation({ ...observation, projectId }); + await storeObservation({ ...observation, projectId, sourceDetail: 'hook' }); // Shadow mode: Formation Pipeline metrics (fire-and-forget, never blocks) try { diff --git a/src/memory/disclosure-policy.ts b/src/memory/disclosure-policy.ts new file mode 100644 index 0000000..4b31aa3 --- /dev/null +++ b/src/memory/disclosure-policy.ts @@ -0,0 +1,75 @@ +/** + * Disclosure Policy + * + * Lightweight helper that classifies an observation or index entry into + * an L1 / L2 / L3 disclosure layer based on its provenance fields. + * + * Rules (phase 2 first-cut): + * L2 — default working-context: explicit, undefined, or core-valued + * L1 — routing signal: hook auto-captures (non-core) + * L3 — evidence layer: git-ingest (non-core), or any other low-trust source + * + * git-ingest defaults to L3 but can be promoted to L2 by valueCategory=core. + * Rules are kept explicit and easy to extend in future phases. + */ + +export type DisclosureLayer = 'L1' | 'L2' | 'L3'; + +export interface ProvenanceFields { + sourceDetail?: string; + valueCategory?: string; + /** Legacy fallback: observations ingested before Phase 1 only have source='git'. */ + source?: string; +} + +/** + * Resolve the effective sourceDetail for an observation, supporting legacy + * observations that only have source='git' and no sourceDetail. + * + * This is the single fallback point — call this instead of reading sourceDetail + * directly whenever provenance classification or display is needed. + */ +export function resolveSourceDetail( + sourceDetail?: string, + source?: string, +): 'explicit' | 'hook' | 'git-ingest' | undefined { + if (sourceDetail === 'explicit' || sourceDetail === 'hook' || sourceDetail === 'git-ingest') { + return sourceDetail; + } + // Legacy git memories: source='git' with no sourceDetail → treat as git-ingest. + if (source === 'git') return 'git-ingest'; + return undefined; +} + +/** + * Classify a single observation or index entry into a disclosure layer. + */ +export function classifyLayer(fields: ProvenanceFields): DisclosureLayer { + const { valueCategory } = fields; + const sd = resolveSourceDetail(fields.sourceDetail, fields.source); + + // Core-valued memories are always promoted to L2, regardless of source. + if (valueCategory === 'core') return 'L2'; + + // Hook auto-captures without core classification → L1 routing signal. + if (sd === 'hook') return 'L1'; + + // Git-ingest (including legacy source='git') defaults to L3. + if (sd === 'git-ingest') return 'L3'; + + // Explicit, undefined/legacy, manual → L2 working context. + return 'L2'; +} + +/** + * Return a compact source badge string for display in search tables. + * Accepts both sourceDetail and legacy source for fallback resolution. + * Keeps existing table structure stable — fits in a narrow column. + */ +export function sourceBadge(sourceDetail?: string, source?: string): string { + const sd = resolveSourceDetail(sourceDetail, source); + if (sd === 'explicit') return 'ex'; + if (sd === 'hook') return 'hk'; + if (sd === 'git-ingest') return 'git'; + return ''; +} diff --git a/src/memory/observations.ts b/src/memory/observations.ts index cacc9b4..0b80440 100644 --- a/src/memory/observations.ts +++ b/src/memory/observations.ts @@ -78,6 +78,8 @@ export async function storeObservation(input: { commitHash?: string; relatedCommits?: string[]; relatedEntities?: string[]; + sourceDetail?: 'explicit' | 'hook' | 'git-ingest'; + valueCategory?: 'core' | 'contextual' | 'ephemeral'; }): Promise<{ observation: Observation; upserted: boolean }> { const now = new Date().toISOString(); @@ -165,6 +167,8 @@ export async function storeObservation(input: { commitHash: input.commitHash, relatedCommits: input.relatedCommits, relatedEntities: input.relatedEntities, + sourceDetail: input.sourceDetail, + valueCategory: input.valueCategory, }; diskObs.push(observation); @@ -202,6 +206,8 @@ export async function storeObservation(input: { commitHash: input.commitHash, relatedCommits: input.relatedCommits, relatedEntities: input.relatedEntities, + sourceDetail: input.sourceDetail, + valueCategory: input.valueCategory, }; observations.push(observation); } @@ -224,6 +230,8 @@ export async function storeObservation(input: { lastAccessedAt: '', status: 'active', source: input.source ?? 'agent', + sourceDetail: input.sourceDetail ?? '', + valueCategory: input.valueCategory ?? '', }; await insertObservation(doc); @@ -288,6 +296,8 @@ async function upsertObservation( topicKey?: string; sessionId?: string; progress?: ProgressInfo; + sourceDetail?: 'explicit' | 'hook' | 'git-ingest'; + valueCategory?: 'core' | 'contextual' | 'ephemeral'; }, now: string, ): Promise { @@ -321,6 +331,8 @@ async function upsertObservation( existing.status = 'active'; if (input.sessionId) existing.sessionId = input.sessionId; if (input.progress) existing.progress = input.progress; + if (input.sourceDetail !== undefined) existing.sourceDetail = input.sourceDetail; + if (input.valueCategory !== undefined) existing.valueCategory = input.valueCategory; // Re-index in Orama WITHOUT embedding first (non-blocking) const doc: MemorixDocument = { @@ -340,6 +352,8 @@ async function upsertObservation( lastAccessedAt: '', status: 'active', source: existing.source ?? 'agent', + sourceDetail: existing.sourceDetail ?? '', + valueCategory: existing.valueCategory ?? '', }; // Remove old doc and insert updated one (with retry for concurrent upsert race) @@ -447,6 +461,8 @@ export async function resolveObservations( lastAccessedAt: '', status, source: obs.source ?? 'agent', + sourceDetail: obs.sourceDetail ?? '', + valueCategory: obs.valueCategory ?? '', }; await insertObservation(doc); // Async embedding update (fire-and-forget) @@ -627,6 +643,8 @@ export async function reindexObservations(): Promise { lastAccessedAt: '', status: obs.status ?? 'active', source: obs.source ?? 'agent', + sourceDetail: obs.sourceDetail ?? '', + valueCategory: obs.valueCategory ?? '', ...(compatibleEmbedding ? { embedding: compatibleEmbedding } : {}), }; await insertObservation(doc); @@ -732,6 +750,8 @@ export async function backfillVectorEmbeddings(): Promise<{ lastAccessedAt: '', status: obs.status ?? 'active', source: obs.source ?? 'agent', + sourceDetail: obs.sourceDetail ?? '', + valueCategory: obs.valueCategory ?? '', embedding, }; await insertObservation(doc); diff --git a/src/memory/retention.ts b/src/memory/retention.ts index 3b09c11..700c682 100644 --- a/src/memory/retention.ts +++ b/src/memory/retention.ts @@ -55,11 +55,24 @@ const TYPE_IMPORTANCE: Record = { const PROTECTED_TAGS = new Set(['keep', 'important', 'pinned', 'critical']); const MIN_ACCESS_FOR_IMMUNITY = 3; +/** + * Get retention period multiplier based on sourceDetail. + * Neutral (1.0) for unknown/undefined sourceDetail — backward-compatible. + */ +function getSourceRetentionMultiplier(doc: MemorixDocument): number { + if (doc.sourceDetail === 'hook') return 0.5; // hook auto-captures: half the retention period + if (doc.sourceDetail === 'git-ingest') return 1.5; // git-backed truth: extend retention + return 1.0; // explicit/undefined: neutral +} + /** * Check if an observation is immune from archiving/decay. * Immune observations maintain a minimum relevance score. */ export function isImmune(doc: MemorixDocument): boolean { + // formation-classified core memories are immune regardless of type + if (doc.valueCategory === 'core') return true; + const importance = getImportanceLevel(doc); if (importance === 'critical' || importance === 'high') return true; if ((doc.accessCount ?? 0) >= MIN_ACCESS_FOR_IMMUNITY) return true; @@ -103,7 +116,7 @@ export function calculateRelevance( const now = referenceTime ?? new Date(); const importance = getImportanceLevel(doc); const base = BASE_IMPORTANCE[importance]; - const retention = RETENTION_DAYS[importance]; + const retention = RETENTION_DAYS[importance] * getSourceRetentionMultiplier(doc); // Age in days const createdAt = new Date(doc.createdAt); @@ -163,7 +176,7 @@ export type RetentionZone = 'active' | 'stale' | 'archive-candidate'; export function getRetentionZone(doc: MemorixDocument, referenceTime?: Date): RetentionZone { const now = referenceTime ?? new Date(); const importance = getImportanceLevel(doc); - const retention = RETENTION_DAYS[importance]; + const retention = RETENTION_DAYS[importance] * getSourceRetentionMultiplier(doc); const createdAt = new Date(doc.createdAt); const ageDays = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24); @@ -253,6 +266,8 @@ export async function archiveExpired( lastAccessedAt: access?.lastAccessedAt ?? '', status: obs.status ?? 'active', source: obs.source ?? 'agent', + sourceDetail: obs.sourceDetail ?? '', + valueCategory: obs.valueCategory ?? '', }; }; diff --git a/src/memory/session.ts b/src/memory/session.ts index 40ab31b..81449c4 100644 --- a/src/memory/session.ts +++ b/src/memory/session.ts @@ -12,6 +12,7 @@ */ import type { Observation, Session } from '../types.js'; +import { classifyLayer } from './disclosure-policy.js'; import { resolveAliases } from '../project/aliases.js'; import { withFileLock } from '../store/file-lock.js'; import { loadObservationsJson, loadSessionsJson, saveSessionsJson } from '../store/persistence.js'; @@ -152,7 +153,7 @@ function isSystemSelfObservation(obs: Observation): boolean { return SYSTEM_SELF_PATTERNS.some((pattern) => pattern.test(text)); } -function scoreObservationForSessionContext(obs: Observation, projectTokens: string[], now = Date.now()): number { +export function scoreObservationForSessionContext(obs: Observation, projectTokens: string[], now = Date.now()): number { let score = TYPE_WEIGHTS[obs.type] ?? 1; const text = stringifyObservation(obs); const ageDays = Math.max(0, (now - new Date(obs.createdAt).getTime()) / (1000 * 60 * 60 * 24)); @@ -186,6 +187,20 @@ function scoreObservationForSessionContext(obs: Observation, projectTokens: stri score -= 15; } + // Source-aware adjustments (neutral when sourceDetail/valueCategory absent — backward-compatible) + if (obs.sourceDetail === 'hook') { + // Hook auto-captures are L1 routing signals, not L2 working context + score -= 3; + if (obs.valueCategory === 'ephemeral') { + // Hook + ephemeral = high-noise auto-capture with no lasting value + score -= 5; + } + } + if (obs.valueCategory === 'core') { + // Formation-classified core memory: high-value, prefer in working context + score += 2; + } + return score; } @@ -275,10 +290,12 @@ export async function endSession( /** * Get formatted context from previous sessions for injection into a new session. * - * Returns a concise summary of: - * 1. Last completed session's summary (if available) - * 2. Top observations from recent sessions - * 3. Active decisions and gotchas + * Returns a layered context packet: + * L1 Routing — recent hook signals + search guidance + * Recent Handoff — last session summary (L2) + * Key Memories — durable explicit working context (L2) + * Session History— orientation log + * L3 Evidence — pointers to git-memory and hook traces (on-demand) */ export async function getSessionContext( projectDir: string, @@ -306,12 +323,64 @@ export async function getSessionContext( } const lines: string[] = []; + const projectTokens = tokenizeProjectId(projectId); + + // ── Partition project observations by disclosure layer ───────────── + const projectObs = allObs + .filter((obs) => aliasSet.has(obs.projectId) && (obs.status ?? 'active') === 'active') + .filter((obs) => !isNoiseObservation(obs) && !isSystemSelfObservation(obs)); + + // L2: durable working context (explicit/undefined/core), priority types only + const l2Obs = projectObs + .filter((obs) => PRIORITY_TYPES.has(obs.type) && classifyLayer(obs) === 'L2') + .map((obs) => ({ obs, score: scoreObservationForSessionContext(obs, projectTokens) })) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return new Date(b.obs.createdAt).getTime() - new Date(a.obs.createdAt).getTime(); + }) + .slice(0, 5) + .map(({ obs }) => obs); + + // L1: recent hook activity signals (titles only, most recent first) + const l1HookObs = projectObs + .filter((obs) => classifyLayer(obs) === 'L1') + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 3); + + // L3: git-ingest evidence count (pointer only, not injected) + const l3GitCount = projectObs.filter((obs) => classifyLayer(obs) === 'L3').length; + const totalHookCount = projectObs.filter((obs) => classifyLayer(obs) === 'L1').length; + + // ── L1 Routing ───────────────────────────────────────────────────── + const hasL1Content = l1HookObs.length > 0 || l3GitCount > 0; + if (hasL1Content) { + lines.push('## L1 Routing'); + lines.push('*Recent activity signals and search guidance for this session.*'); + + if (l1HookObs.length > 0) { + for (const obs of l1HookObs) { + lines.push(`🔗 ${obs.title}`); + } + lines.push(''); + } + + const hints: string[] = []; + if (l3GitCount > 0) { + hints.push(`${l3GitCount} git-memory item(s) available — search \`what-changed\` or by entity/commit`); + } + if (totalHookCount > 0) { + hints.push(`${totalHookCount} hook trace(s) available — use \`memorix_timeline\` for activity expansion`); + } + for (const hint of hints) { + lines.push(`💡 ${hint}`); + } + lines.push(''); + } + // ── L2 Recent Handoff ────────────────────────────────────────────── if (projectSessions.length > 0) { - const last = projectSessions[0]; - // Recent Handoff: the most recent completed session with a real summary. - // If the latest session only ended implicitly, walk back to find one with substance. - let handoff = last; + // Walk back to find the most recent session with a real summary. + let handoff = projectSessions[0]; for (const s of projectSessions) { if (s.summary && s.summary !== '(session ended implicitly by new session start)') { handoff = s; @@ -330,22 +399,11 @@ export async function getSessionContext( lines.push(''); } - const projectTokens = tokenizeProjectId(projectId); - const priorityObs = allObs - .filter((obs) => aliasSet.has(obs.projectId) && PRIORITY_TYPES.has(obs.type) && (obs.status ?? 'active') === 'active') - .filter((obs) => !isNoiseObservation(obs) && !isSystemSelfObservation(obs)) - .map((obs) => ({ obs, score: scoreObservationForSessionContext(obs, projectTokens) })) - .sort((a, b) => { - if (b.score !== a.score) return b.score - a.score; - return new Date(b.obs.createdAt).getTime() - new Date(a.obs.createdAt).getTime(); - }) - .slice(0, 5) - .map(({ obs }) => obs); - - if (priorityObs.length > 0) { + // ── L2 Key Project Memories ──────────────────────────────────────── + if (l2Obs.length > 0) { lines.push('## Key Project Memories'); - lines.push('*Long-term important knowledge — ranked by type and relevance, not recency.*'); - for (const obs of priorityObs) { + lines.push('*Durable working context — explicit decisions, gotchas, and discoveries.*'); + for (const obs of l2Obs) { const emoji = TYPE_EMOJI[obs.type] ?? '📌'; const fact = obs.facts?.[0] ? ` — ${obs.facts[0]}` : ''; lines.push(`${emoji} ${obs.title}${fact}`); @@ -353,6 +411,7 @@ export async function getSessionContext( lines.push(''); } + // ── Session History ──────────────────────────────────────────────── if (projectSessions.length > 1) { lines.push(`## Recent Session History (last ${projectSessions.length})`); lines.push('*Chronological session log — for orientation, not action.*'); @@ -369,6 +428,23 @@ export async function getSessionContext( lines.push(''); } + // ── L3 Evidence Hints ───────────────────────────────────────────── + const l3Lines: string[] = []; + if (l3GitCount > 0) { + l3Lines.push(`📌 ${l3GitCount} git-memory item(s) — use \`memorix_search\` to retrieve repository evidence`); + } + if (totalHookCount > 0) { + l3Lines.push(`🔗 ${totalHookCount} hook trace(s) — use \`memorix_timeline\` for full activity expansion`); + } + if (l3Lines.length > 0) { + lines.push('## L3 Evidence'); + lines.push('*Deeper context available on demand — kept out of working context to stay compact.*'); + for (const l of l3Lines) { + lines.push(l); + } + lines.push(''); + } + return lines.join('\n'); } diff --git a/src/server.ts b/src/server.ts index 69162fd..2fea461 100644 --- a/src/server.ts +++ b/src/server.ts @@ -510,6 +510,7 @@ export async function createMemorixServer( projectId: project.id, topicKey: targetObs.topicKey, progress: progress as import('./types.js').ProgressInfo | undefined, + sourceDetail: 'explicit', }); return { content: [{ @@ -534,6 +535,7 @@ export async function createMemorixServer( projectId: project.id, topicKey: targetObs.topicKey, progress: progress as import('./types.js').ProgressInfo | undefined, + sourceDetail: 'explicit', }); return { content: [{ @@ -605,6 +607,7 @@ export async function createMemorixServer( projectId: project.id, topicKey: targetObs.topicKey, progress: progress as import('./types.js').ProgressInfo | undefined, + sourceDetail: 'explicit', }); compactAction = `🔄 Compact UPDATE: merged into #${decision.targetId} (${decision.reason})`; compactMerged = true; @@ -715,6 +718,8 @@ export async function createMemorixServer( progress: progress as import('./types.js').ProgressInfo | undefined, relatedCommits, relatedEntities, + sourceDetail: 'explicit', + valueCategory: formationResult?.evaluation.category, }); // Add a reference to the entity's observations @@ -1094,6 +1099,7 @@ export async function createMemorixServer( source: 'agent', relatedCommits, relatedEntities, + sourceDetail: 'explicit', }); await graphManager.addObservations([ @@ -1276,18 +1282,20 @@ export async function createMemorixServer( ); /** - * memorix_timeline — Layer 2: Chronological context + * memorix_timeline — Deep retrieval: provenance-aware chronological expansion * - * Shows observations before and after a specific anchor. - * Helps agents understand the temporal context of an observation. + * Natural follow-up after session L1 routing hints (hook traces) or L3 + * evidence pointers (git memory). Distinguishes explicit memory evolution, + * hook activity traces, and git-backed facts via Src column when available. */ server.registerTool( 'memorix_timeline', { title: 'Memory Timeline', description: - 'Get chronological context around a specific observation. ' + - 'Shows what happened before and after the anchor observation.', + 'Deep retrieval: expand chronological context around a specific observation — ' + + 'distinguishes explicit memory evolution, hook activity traces, and git-backed facts. ' + + 'Natural follow-up after session L1 routing hints (hook traces) or L3 evidence pointers (git memory).', inputSchema: { anchorId: z.number().describe('Observation ID to center the timeline on'), depthBefore: z.number().optional().describe('Number of observations before (default: 3)'), @@ -1317,18 +1325,19 @@ export async function createMemorixServer( ); /** - * memorix_detail — Layer 3: Full observation details + * memorix_detail — Layer 3: Provenance-aware full observation details * - * Fetch complete observation content by IDs. - * Only call after filtering via memorix_search / memorix_timeline. - * ~500-1000 tokens per observation. + * Opens explicit memories, hook traces, or git evidence depending on source. + * Output includes a provenance header identifying the evidence kind, value + * category (core / ephemeral), and cross-references to related items. */ server.registerTool( 'memorix_detail', { title: 'Memory Details', description: - 'Fetch full observation details by IDs (~500-1000 tokens each). ' + + 'Fetch full observation details by ID — includes source kind (explicit memory / hook trace / git evidence), ' + + 'value category, and cross-references (~500-1000 tokens each). ' + 'Always use memorix_search first to find relevant IDs, then fetch only what you need. ' + 'For global search results, prefer refs with projectId to avoid cross-project ID ambiguity.', inputSchema: { @@ -1429,6 +1438,8 @@ export async function createMemorixServer( lastAccessedAt: '', status: obs.status ?? 'active', source: obs.source ?? 'agent', + sourceDetail: obs.sourceDetail ?? '', + valueCategory: obs.valueCategory ?? '', })); if (docs.length === 0) { diff --git a/src/store/orama-store.ts b/src/store/orama-store.ts index e58f50f..f042963 100644 --- a/src/store/orama-store.ts +++ b/src/store/orama-store.ts @@ -143,6 +143,8 @@ export async function getDb(): Promise { lastAccessedAt: 'string' as const, status: 'string' as const, source: 'string' as const, + sourceDetail: 'string' as const, + valueCategory: 'string' as const, }; // Dynamic vector dimensions based on provider (384 for local, 1024+ for API) @@ -479,6 +481,8 @@ export async function searchObservations(options: SearchOptions): Promise { + const toIndexEntry = (obs: { + id: number; type: string; title: string; tokens: number; createdAt: string; + source?: string; sourceDetail?: string; valueCategory?: string; + }): IndexEntry => { const obsType = obs.type as ObservationType; return { id: obs.id, @@ -761,6 +768,9 @@ export async function getTimeline( icon: OBSERVATION_ICONS[obsType] ?? '❓', title: obs.title, tokens: obs.tokens, + source: (obs.source as IndexEntry['source']) || undefined, + sourceDetail: (obs.sourceDetail as IndexEntry['sourceDetail']) || undefined, + valueCategory: (obs.valueCategory as IndexEntry['valueCategory']) || undefined, }; }; diff --git a/src/types.ts b/src/types.ts index 8694325..69302c7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,10 @@ export interface Observation { relatedCommits?: string[]; /** Related entity names — explicit cross-references to other memory entities */ relatedEntities?: string[]; + /** Provenance detail: how this observation entered the system */ + sourceDetail?: 'explicit' | 'hook' | 'git-ingest'; + /** Value category from formation pipeline evaluation */ + valueCategory?: 'core' | 'contextual' | 'ephemeral'; } // ============================================================ @@ -161,6 +165,10 @@ export interface IndexEntry { projectId?: string; /** Origin of the memory for source-aware retrieval and display. */ source?: 'agent' | 'git' | 'manual'; + /** Provenance detail for source-aware display */ + sourceDetail?: 'explicit' | 'hook' | 'git-ingest'; + /** Value category for source-aware ranking */ + valueCategory?: 'core' | 'contextual' | 'ephemeral'; /** Explainable recall: why this result matched. */ matchedFields?: string[]; } @@ -231,6 +239,10 @@ export interface MemorixDocument { status: string; /** Origin: agent, git, manual */ source: string; + /** Provenance detail: explicit, hook, or git-ingest */ + sourceDetail?: string; + /** Value category from formation evaluation */ + valueCategory?: string; /** Optional vector embedding for semantic/hybrid retrieval */ embedding?: number[]; } diff --git a/tests/compact/detail-provenance.test.ts b/tests/compact/detail-provenance.test.ts new file mode 100644 index 0000000..95f3422 --- /dev/null +++ b/tests/compact/detail-provenance.test.ts @@ -0,0 +1,126 @@ +/** + * Phase 3: Detail Provenance Header Tests + * + * Verifies that formatObservationDetail() prepends a provenance header + * identifying the evidence kind before the main #ID block, while keeping + * the #ID + title structure stable and backward-compatible. + */ + +import { describe, it, expect } from 'vitest'; +import { formatObservationDetail } from '../../src/compact/index-format.js'; + +function makeDoc(overrides: Partial[0]> = {}) { + return { + observationId: 42, + type: 'gotcha', + title: 'Test observation', + narrative: 'Some narrative.', + facts: '', + filesModified: '', + concepts: '', + createdAt: new Date('2026-01-01T00:00:00Z').toISOString(), + projectId: 'test/project', + entityName: 'auth', + ...overrides, + }; +} + +// ── Provenance header content ───────────────────────────────────────── + +describe('Provenance header: sourceDetail', () => { + it('git-ingest → Git Repository Evidence + L3', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'git-ingest' })); + expect(out).toContain('Git Repository Evidence'); + expect(out).toContain('L3'); + }); + + it('hook → Hook Trace + L1', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'hook' })); + expect(out).toContain('Hook Trace'); + expect(out).toContain('L1'); + }); + + it('explicit → Explicit Working Memory + L2', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'explicit' })); + expect(out).toContain('Explicit Working Memory'); + expect(out).toContain('L2'); + }); + + it('no sourceDetail → no provenance header (backward-compat)', () => { + const out = formatObservationDetail(makeDoc()); + expect(out).not.toContain('Evidence'); + expect(out).not.toContain('Hook Trace'); + expect(out).not.toContain('[L'); + }); + + it('empty-string sourceDetail → no provenance header (backward-compat)', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: '' })); + expect(out).not.toContain('[L'); + }); +}); + +describe('Provenance header: valueCategory', () => { + it('explicit + core → shows ★ Core', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'explicit', valueCategory: 'core' })); + expect(out).toContain('★ Core'); + expect(out).toContain('immune to decay'); + }); + + it('git-ingest + core → shows ★ Core (core promotes to L2 but header still present)', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'git-ingest', valueCategory: 'core' })); + expect(out).toContain('Git Repository Evidence'); + expect(out).toContain('★ Core'); + }); + + it('hook + ephemeral → shows ⚠ Ephemeral', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'hook', valueCategory: 'ephemeral' })); + expect(out).toContain('⚠ Ephemeral'); + expect(out).toContain('short-lived signal'); + }); + + it('explicit + contextual → no valueCategory annotation (neutral)', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'explicit', valueCategory: 'contextual' })); + expect(out).not.toContain('★'); + expect(out).not.toContain('⚠'); + }); +}); + +// ── #ID + title structure stability ────────────────────────────────── + +describe('#ID and title structure stability', () => { + it('#ID is always present after the provenance header', () => { + for (const sd of ['explicit', 'hook', 'git-ingest', undefined]) { + const out = formatObservationDetail(makeDoc({ sourceDetail: sd })); + expect(out).toContain('#42'); + } + }); + + it('title is always present', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'git-ingest', title: 'My title' })); + expect(out).toContain('My title'); + }); + + it('provenance header appears BEFORE #ID line', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'hook' })); + const headerPos = out.indexOf('Hook Trace'); + const idPos = out.indexOf('#42'); + expect(headerPos).toBeLessThan(idPos); + }); + + it('no sourceDetail → first line is #ID line', () => { + const out = formatObservationDetail(makeDoc()); + expect(out.trimStart().startsWith('#42')).toBe(true); + }); + + it('narrative, facts, files still present after provenance header', () => { + const out = formatObservationDetail(makeDoc({ + sourceDetail: 'explicit', + narrative: 'Unique narrative text', + facts: 'fact one\nfact two', + filesModified: 'src/auth.ts', + })); + expect(out).toContain('Unique narrative text'); + expect(out).toContain('fact one'); + expect(out).toContain('src/auth.ts'); + }); +}); diff --git a/tests/compact/search-format.test.ts b/tests/compact/search-format.test.ts new file mode 100644 index 0000000..32e9da6 --- /dev/null +++ b/tests/compact/search-format.test.ts @@ -0,0 +1,182 @@ +/** + * Phase 2: Layered Search Format Tests + * + * Verifies that formatIndexTable() shows Src badge column and tier summary + * when entries have provenance fields, while keeping #ID/title structure stable. + */ + +import { describe, it, expect } from 'vitest'; +import { formatIndexTable } from '../../src/compact/index-format.js'; +import type { IndexEntry } from '../../src/types.js'; + +function makeEntry(overrides: Partial & { id: number }): IndexEntry { + return { + time: '1d ago', + type: 'gotcha', + icon: '🔶', + title: `Entry ${overrides.id}`, + tokens: 100, + score: 1.0, + projectId: 'test/project', + source: 'agent', + ...overrides, + }; +} + +// ── Core structure stability ───────────────────────────────────────── + +describe('Core structure stability', () => { + it('#ID is always present and parseable', () => { + const entries = [ + makeEntry({ id: 12, sourceDetail: 'explicit' }), + makeEntry({ id: 34, sourceDetail: 'hook' }), + ]; + const output = formatIndexTable(entries, 'auth'); + + expect(output).toContain('#12'); + expect(output).toContain('#34'); + // IDs are in table rows + expect(output).toMatch(/\| #12 \|/); + expect(output).toMatch(/\| #34 \|/); + }); + + it('title is preserved in output', () => { + const entries = [ + makeEntry({ id: 1, title: 'JWT expiry gotcha', sourceDetail: 'explicit' }), + ]; + const output = formatIndexTable(entries, 'auth'); + + expect(output).toContain('JWT expiry gotcha'); + }); + + it('token count is preserved', () => { + const entries = [makeEntry({ id: 1, tokens: 250, sourceDetail: 'explicit' })]; + const output = formatIndexTable(entries); + + expect(output).toContain('~250'); + }); + + it('empty entries returns no-result message', () => { + expect(formatIndexTable([], 'auth')).toContain('No observations found matching'); + expect(formatIndexTable([])).toContain('No observations found'); + }); +}); + +// ── Src badge column ───────────────────────────────────────────────── + +describe('Src badge column', () => { + it('shows Src column when entries have sourceDetail', () => { + const entries = [ + makeEntry({ id: 1, sourceDetail: 'explicit' }), + makeEntry({ id: 2, sourceDetail: 'hook' }), + ]; + const output = formatIndexTable(entries); + + expect(output).toContain('| Src |'); + expect(output).toContain('| ex |'); + expect(output).toContain('| hk |'); + }); + + it('shows git badge for git-ingest entries', () => { + const entries = [makeEntry({ id: 1, sourceDetail: 'git-ingest' })]; + const output = formatIndexTable(entries); + + expect(output).toContain('| Src |'); + expect(output).toContain('| git |'); + }); + + it('omits Src column when no entries have sourceDetail', () => { + const entries = [ + makeEntry({ id: 1, sourceDetail: undefined }), + makeEntry({ id: 2, sourceDetail: undefined }), + ]; + const output = formatIndexTable(entries); + + expect(output).not.toContain('| Src |'); + }); + + it('shows dash for entries with no sourceDetail in a mixed set', () => { + const entries = [ + makeEntry({ id: 1, sourceDetail: 'explicit' }), + makeEntry({ id: 2, sourceDetail: undefined }), + ]; + const output = formatIndexTable(entries); + + expect(output).toContain('| Src |'); + // The entry without sourceDetail should show '-' + const rows = output.split('\n').filter((l) => l.includes('| #')); + expect(rows.some((r) => r.includes('| - |'))).toBe(true); + }); +}); + +// ── Tier summary ───────────────────────────────────────────────────── + +describe('Tier summary line', () => { + it('shows tier summary for mixed explicit + hook entries', () => { + const entries = [ + makeEntry({ id: 1, sourceDetail: 'explicit' }), + makeEntry({ id: 2, sourceDetail: 'hook' }), + ]; + const output = formatIndexTable(entries); + + expect(output).toContain('Sources:'); + expect(output).toContain('explicit'); + expect(output).toContain('hook'); + }); + + it('shows tier summary for explicit + git-ingest mix', () => { + const entries = [ + makeEntry({ id: 1, sourceDetail: 'explicit' }), + makeEntry({ id: 2, sourceDetail: 'git-ingest' }), + ]; + const output = formatIndexTable(entries); + + expect(output).toContain('Sources:'); + expect(output).toContain('git'); + }); + + it('suppresses tier summary when all entries have the same badge', () => { + const entries = [ + makeEntry({ id: 1, sourceDetail: 'explicit' }), + makeEntry({ id: 2, sourceDetail: 'explicit' }), + makeEntry({ id: 3, sourceDetail: 'explicit' }), + ]; + const output = formatIndexTable(entries); + + expect(output).not.toContain('Sources:'); + }); + + it('suppresses tier summary when all entries have no sourceDetail', () => { + const entries = [ + makeEntry({ id: 1 }), + makeEntry({ id: 2 }), + ]; + const output = formatIndexTable(entries); + + expect(output).not.toContain('Sources:'); + }); + + it('shows tier summary when sourceDetail is mixed with missing', () => { + const entries = [ + makeEntry({ id: 1, sourceDetail: 'explicit' }), + makeEntry({ id: 2, sourceDetail: undefined }), + ]; + const output = formatIndexTable(entries); + + // Mixed: one has badge, one does not → summary shown + expect(output).toContain('Sources:'); + expect(output).toContain('legacy'); + }); +}); + +// ── Progressive disclosure hint preserved ─────────────────────────── + +describe('Progressive disclosure hint', () => { + it('still shows disclosure hint regardless of badge column presence', () => { + const entries = [makeEntry({ id: 1, sourceDetail: 'explicit' })]; + const output = formatIndexTable(entries); + + expect(output).toContain('Progressive Disclosure'); + expect(output).toContain('memorix_detail'); + }); +}); diff --git a/tests/compact/timeline-provenance.test.ts b/tests/compact/timeline-provenance.test.ts new file mode 100644 index 0000000..21f188a --- /dev/null +++ b/tests/compact/timeline-provenance.test.ts @@ -0,0 +1,192 @@ +/** + * Phase 3: Timeline Provenance Tests + * + * Verifies that formatTimeline() adds Src column and anchor kind annotation + * when entries carry sourceDetail provenance, and falls back to the original + * table format when no provenance is present (backward-compat). + */ + +import { describe, it, expect } from 'vitest'; +import { formatTimeline } from '../../src/compact/index-format.js'; +import type { IndexEntry, TimelineContext } from '../../src/types.js'; + +function makeEntry(overrides: Partial & { id: number }): IndexEntry { + return { + time: '1d ago', + type: 'what-changed', + icon: '🟢', + title: `Entry ${overrides.id}`, + tokens: 80, + projectId: 'test/project', + source: 'agent', + ...overrides, + }; +} + +function makeTimeline( + anchorId: number, + anchor: IndexEntry, + before: IndexEntry[] = [], + after: IndexEntry[] = [], +): TimelineContext { + return { anchorId, anchorEntry: anchor, before, after }; +} + +// ── Backward-compat: no sourceDetail → original format ────────────── + +describe('Backward-compat: no provenance', () => { + it('no Src column when no entries have sourceDetail', () => { + const timeline = makeTimeline( + 10, + makeEntry({ id: 10 }), + [makeEntry({ id: 9 })], + [makeEntry({ id: 11 })], + ); + const out = formatTimeline(timeline); + + expect(out).not.toContain('| Src |'); + expect(out).not.toContain('Expanding:'); + }); + + it('original table header preserved when no provenance', () => { + const timeline = makeTimeline(5, makeEntry({ id: 5 })); + const out = formatTimeline(timeline); + + expect(out).toContain('| ID | Time | T | Title | Tokens |'); + expect(out).not.toContain('| Src |'); + }); + + it('#ID is always present in anchor row', () => { + const timeline = makeTimeline(7, makeEntry({ id: 7 })); + const out = formatTimeline(timeline); + expect(out).toContain('#7'); + }); + + it('returns not-found message for missing anchor', () => { + const timeline: TimelineContext = { anchorId: 99, anchorEntry: null, before: [], after: [] }; + expect(formatTimeline(timeline)).toContain('not found'); + }); +}); + +// ── Src column: shown when provenance present ───────────────────────── + +describe('Src column with provenance', () => { + it('shows Src column when anchor has sourceDetail', () => { + const anchor = makeEntry({ id: 10, sourceDetail: 'hook' }); + const timeline = makeTimeline(10, anchor); + const out = formatTimeline(timeline); + + expect(out).toContain('Src'); + expect(out).toContain('hk'); + }); + + it('shows Src column when a before entry has sourceDetail', () => { + const anchor = makeEntry({ id: 10 }); + const before = makeEntry({ id: 9, sourceDetail: 'explicit' }); + const timeline = makeTimeline(10, anchor, [before]); + const out = formatTimeline(timeline); + + expect(out).toContain('Src'); + }); + + it('Src badge correct for each sourceDetail value', () => { + const anchor = makeEntry({ id: 20, sourceDetail: 'git-ingest' }); + const before = makeEntry({ id: 19, sourceDetail: 'hook' }); + const after = makeEntry({ id: 21, sourceDetail: 'explicit' }); + const timeline = makeTimeline(20, anchor, [before], [after]); + const out = formatTimeline(timeline); + + expect(out).toContain('git'); + expect(out).toContain('hk'); + expect(out).toContain('ex'); + }); + + it('entry without sourceDetail in mixed timeline shows dash badge', () => { + const anchor = makeEntry({ id: 10, sourceDetail: 'explicit' }); + const before = makeEntry({ id: 9 }); // no sourceDetail + const timeline = makeTimeline(10, anchor, [before]); + const out = formatTimeline(timeline); + + // Src column present (anchor has sourceDetail) + expect(out).toContain('Src'); + // No-sourceDetail entry gets '-' + const rows = out.split('\n').filter((l) => l.includes('| #')); + expect(rows.some((r) => r.includes('| - |'))).toBe(true); + }); +}); + +// ── Anchor kind annotation ──────────────────────────────────────────── + +describe('Anchor kind annotation', () => { + it('shows Expanding annotation for hook anchor', () => { + const anchor = makeEntry({ id: 45, sourceDetail: 'hook' }); + const timeline = makeTimeline(45, anchor); + const out = formatTimeline(timeline); + + expect(out).toContain('Expanding:'); + expect(out).toContain('Hook Trace'); + }); + + it('shows Expanding annotation for git-ingest anchor', () => { + const anchor = makeEntry({ id: 22, sourceDetail: 'git-ingest' }); + const timeline = makeTimeline(22, anchor); + const out = formatTimeline(timeline); + + expect(out).toContain('Expanding:'); + expect(out).toContain('Git Repository Evidence'); + }); + + it('shows Expanding annotation for explicit anchor', () => { + const anchor = makeEntry({ id: 12, sourceDetail: 'explicit' }); + const timeline = makeTimeline(12, anchor); + const out = formatTimeline(timeline); + + expect(out).toContain('Expanding:'); + expect(out).toContain('Explicit Working Memory'); + }); + + it('no Expanding annotation when anchor has no sourceDetail', () => { + const anchor = makeEntry({ id: 10 }); // no sourceDetail + const timeline = makeTimeline(10, anchor); + const out = formatTimeline(timeline); + + expect(out).not.toContain('Expanding:'); + }); + + it('Expanding annotation appears before **Anchor:** section', () => { + const anchor = makeEntry({ id: 30, sourceDetail: 'hook' }); + const timeline = makeTimeline(30, anchor); + const out = formatTimeline(timeline); + + const expandPos = out.indexOf('Expanding:'); + const anchorPos = out.indexOf('**Anchor:**'); + expect(expandPos).toBeLessThan(anchorPos); + }); +}); + +// ── Structure stability ─────────────────────────────────────────────── + +describe('Structure stability with provenance', () => { + it('Timeline around #N: header always present', () => { + const anchor = makeEntry({ id: 5, sourceDetail: 'explicit' }); + const out = formatTimeline(makeTimeline(5, anchor)); + expect(out).toContain('Timeline around #5:'); + }); + + it('Before/Anchor/After section labels preserved', () => { + const anchor = makeEntry({ id: 10, sourceDetail: 'hook' }); + const before = makeEntry({ id: 9, sourceDetail: 'hook' }); + const after = makeEntry({ id: 11, sourceDetail: 'hook' }); + const out = formatTimeline(makeTimeline(10, anchor, [before], [after])); + + expect(out).toContain('**Before:**'); + expect(out).toContain('**Anchor:**'); + expect(out).toContain('**After:**'); + }); + + it('Progressive Disclosure hint still present', () => { + const anchor = makeEntry({ id: 10, sourceDetail: 'explicit' }); + const out = formatTimeline(makeTimeline(10, anchor)); + expect(out).toContain('Progressive Disclosure'); + }); +}); diff --git a/tests/integration/release-blockers.test.ts b/tests/integration/release-blockers.test.ts index 9bd1d54..3ea9475 100644 --- a/tests/integration/release-blockers.test.ts +++ b/tests/integration/release-blockers.test.ts @@ -497,8 +497,9 @@ describe('P1: CLI cold-start search finds persisted memories', () => { }; child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); - // Resolve early once search output is complete - if (stdout.includes('Search complete') || stdout.includes('No memories found')) { + // Resolve early only once user-visible terminal output has actually arrived. + // "Search complete" is emitted by the spinner before result lines flush. + if (stdout.includes('Found ') || stdout.includes('No memories found')) { setTimeout(finish, 200); // small grace period for trailing output } }); diff --git a/tests/memory/legacy-git-compat.test.ts b/tests/memory/legacy-git-compat.test.ts new file mode 100644 index 0000000..75fc405 --- /dev/null +++ b/tests/memory/legacy-git-compat.test.ts @@ -0,0 +1,239 @@ +/** + * Phase 3 Compat: Legacy source='git' regression tests + * + * Covers the four surfaces where legacy git memories (source='git', + * no sourceDetail) must behave identically to modern sourceDetail='git-ingest': + * 1. disclosure-policy: classifyLayer / sourceBadge / resolveSourceDetail + * 2. search/table: search badge + tier summary (formatIndexTable) + * 3. detail: provenance header (formatObservationDetail) + * 4. timeline: anchor annotation + Src column (formatTimeline) + * + * session_start path is covered via classifyLayer, which getSessionContext calls. + */ + +import { describe, it, expect } from 'vitest'; +import { classifyLayer, sourceBadge, resolveSourceDetail } from '../../src/memory/disclosure-policy.js'; +import { formatIndexTable, formatObservationDetail, formatTimeline } from '../../src/compact/index-format.js'; +import type { IndexEntry, TimelineContext } from '../../src/types.js'; + +// ── 1. disclosure-policy ──────────────────────────────────────────── + +describe('resolveSourceDetail: legacy source=git fallback', () => { + it('source=git with no sourceDetail → git-ingest', () => { + expect(resolveSourceDetail(undefined, 'git')).toBe('git-ingest'); + }); + + it('source=git is overridden by explicit sourceDetail', () => { + expect(resolveSourceDetail('explicit', 'git')).toBe('explicit'); + expect(resolveSourceDetail('hook', 'git')).toBe('hook'); + expect(resolveSourceDetail('git-ingest', 'git')).toBe('git-ingest'); + }); + + it('no sourceDetail and source=agent → undefined (not treated as git)', () => { + expect(resolveSourceDetail(undefined, 'agent')).toBeUndefined(); + }); + + it('no sourceDetail and no source → undefined', () => { + expect(resolveSourceDetail()).toBeUndefined(); + }); +}); + +describe('classifyLayer: legacy source=git → L3', () => { + it('source=git with no sourceDetail → L3', () => { + expect(classifyLayer({ source: 'git' })).toBe('L3'); + }); + + it('source=git + valueCategory=core → still L2 (core promotion wins)', () => { + expect(classifyLayer({ source: 'git', valueCategory: 'core' })).toBe('L2'); + }); + + it('source=agent with no sourceDetail → L2 (not mistaken for git)', () => { + expect(classifyLayer({ source: 'agent' })).toBe('L2'); + }); + + it('modern sourceDetail=git-ingest → L3 (unchanged)', () => { + expect(classifyLayer({ sourceDetail: 'git-ingest' })).toBe('L3'); + }); + + it('no source, no sourceDetail → L2 (backward-compat for truly unknown obs)', () => { + expect(classifyLayer({})).toBe('L2'); + }); +}); + +describe('sourceBadge: legacy source=git fallback', () => { + it('source=git with no sourceDetail → git badge', () => { + expect(sourceBadge(undefined, 'git')).toBe('git'); + }); + + it('source=git is overridden by modern sourceDetail', () => { + expect(sourceBadge('explicit', 'git')).toBe('ex'); + expect(sourceBadge('hook', 'git')).toBe('hk'); + }); + + it('source=agent → empty badge (not treated as git)', () => { + expect(sourceBadge(undefined, 'agent')).toBe(''); + }); +}); + +// ── 2. search/table: formatIndexTable ─────────────────────────────── + +function makeEntry(id: number, overrides: Partial = {}): IndexEntry { + return { + time: '1d ago', + type: 'what-changed', + icon: '🟢', + title: `Entry ${id}`, + tokens: 80, + projectId: 'test/project', + ...overrides, + id, + }; +} + +describe('formatIndexTable: legacy source=git', () => { + it('shows Src column when entry has source=git (no sourceDetail)', () => { + const entries = [makeEntry(1, { source: 'git' })]; + const out = formatIndexTable(entries); + expect(out).toContain('Src'); + expect(out).toContain('git'); + }); + + it('legacy git entry shows git badge in table row', () => { + const entries = [makeEntry(1, { source: 'git' })]; + const out = formatIndexTable(entries); + // Row should have git badge + const row = out.split('\n').find((l) => l.includes('| #1')); + expect(row).toBeDefined(); + expect(row).toContain('git'); + }); + + it('tier summary includes git count for legacy entries', () => { + const entries = [ + makeEntry(1, { sourceDetail: 'explicit' }), + makeEntry(2, { source: 'git' }), + ]; + const out = formatIndexTable(entries); + // Mixed provenance → tier summary line + expect(out).toContain('Sources:'); + expect(out).toContain('git'); + expect(out).toContain('explicit'); + }); + + it('no Src column when no entries have provenance (backward-compat)', () => { + const entries = [makeEntry(1), makeEntry(2)]; + const out = formatIndexTable(entries); + expect(out).not.toContain('| Src |'); + }); +}); + +// ── 3. detail: formatObservationDetail ────────────────────────────── + +function makeDoc(overrides: Partial[0]> = {}) { + return { + observationId: 55, + type: 'what-changed', + title: 'Legacy git commit', + narrative: 'Fixed null pointer.', + facts: '', + filesModified: '', + concepts: '', + createdAt: new Date('2025-01-01T00:00:00Z').toISOString(), + projectId: 'test/project', + entityName: 'auth', + ...overrides, + }; +} + +describe('formatObservationDetail: legacy source=git', () => { + it('source=git (no sourceDetail) → shows Git Repository Evidence header', () => { + const out = formatObservationDetail(makeDoc({ source: 'git' })); + expect(out).toContain('Git Repository Evidence'); + expect(out).toContain('L3'); + }); + + it('#ID line still present after provenance header', () => { + const out = formatObservationDetail(makeDoc({ source: 'git' })); + expect(out).toContain('#55'); + }); + + it('provenance header before #ID line', () => { + const out = formatObservationDetail(makeDoc({ source: 'git' })); + expect(out.indexOf('Git Repository Evidence')).toBeLessThan(out.indexOf('#55')); + }); + + it('source=git + valueCategory=core → shows Git Evidence header + ★ Core', () => { + const out = formatObservationDetail(makeDoc({ source: 'git', valueCategory: 'core' })); + expect(out).toContain('Git Repository Evidence'); + expect(out).toContain('★ Core'); + }); + + it('modern sourceDetail=git-ingest still works (no regression)', () => { + const out = formatObservationDetail(makeDoc({ sourceDetail: 'git-ingest' })); + expect(out).toContain('Git Repository Evidence'); + }); + + it('no source, no sourceDetail → no provenance header (backward-compat)', () => { + const out = formatObservationDetail(makeDoc()); + expect(out).not.toContain('Evidence'); + expect(out).not.toContain('[L'); + expect(out.trimStart().startsWith('#55')).toBe(true); + }); +}); + +// ── 4. timeline: formatTimeline ────────────────────────────────────── + +function makeTimeline( + anchorId: number, + anchor: IndexEntry, + before: IndexEntry[] = [], + after: IndexEntry[] = [], +): TimelineContext { + return { anchorId, anchorEntry: anchor, before, after }; +} + +describe('formatTimeline: legacy source=git', () => { + it('anchor source=git → shows Expanding: Git Repository Evidence', () => { + const anchor = makeEntry(22, { source: 'git' }); + const out = formatTimeline(makeTimeline(22, anchor)); + expect(out).toContain('Expanding:'); + expect(out).toContain('Git Repository Evidence'); + }); + + it('anchor source=git → Src column present', () => { + const anchor = makeEntry(22, { source: 'git' }); + const out = formatTimeline(makeTimeline(22, anchor)); + expect(out).toContain('Src'); + expect(out).toContain('git'); + }); + + it('before entry with source=git triggers Src column on all rows', () => { + const anchor = makeEntry(22); // no provenance + const before = makeEntry(21, { source: 'git' }); + const out = formatTimeline(makeTimeline(22, anchor, [before])); + expect(out).toContain('Src'); + // Anchor row (no provenance) gets dash + const rows = out.split('\n').filter((l) => l.includes('| #')); + expect(rows.some((r) => r.includes('| - |'))).toBe(true); + // Before row gets git badge + expect(rows.some((r) => r.includes('git'))).toBe(true); + }); + + it('modern sourceDetail=git-ingest still works in timeline (no regression)', () => { + const anchor = makeEntry(10, { sourceDetail: 'git-ingest' }); + const out = formatTimeline(makeTimeline(10, anchor)); + expect(out).toContain('Git Repository Evidence'); + expect(out).toContain('git'); + }); + + it('no source, no sourceDetail → no Src column, no Expanding (backward-compat)', () => { + const anchor = makeEntry(5); + const out = formatTimeline(makeTimeline(5, anchor)); + expect(out).not.toContain('Src'); + expect(out).not.toContain('Expanding:'); + }); +}); + +// ── 5. session_start path: classifyLayer is the gate ──────────────── +// (getSessionContext calls classifyLayer(obs) where obs has source='git') +// Covered by classifyLayer tests above. Full integration covered by +// session-layered.test.ts with explicit provenance. diff --git a/tests/memory/provenance.test.ts b/tests/memory/provenance.test.ts new file mode 100644 index 0000000..6fd0d31 --- /dev/null +++ b/tests/memory/provenance.test.ts @@ -0,0 +1,329 @@ +/** + * Phase 1 Provenance Tests + * + * Covers: + * - sourceDetail / valueCategory schema persistence + * - Backward-compat: old observations without these fields stay neutral + * - Session injection source-aware scoring + * - Retention source-aware decay and immunity + * - Search result provenance exposure + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../src/embedding/provider.js', () => ({ + getEmbeddingProvider: async () => null, + isVectorSearchAvailable: async () => false, + isEmbeddingExplicitlyDisabled: () => true, + resetProvider: () => {}, +})); + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { storeObservation, initObservations, getObservation } from '../../src/memory/observations.js'; +import { resetDb, searchObservations } from '../../src/store/orama-store.js'; +import { isImmune, calculateRelevance, getRetentionZone } from '../../src/memory/retention.js'; +import { scoreObservationForSessionContext } from '../../src/memory/session.js'; +import type { MemorixDocument, Observation } from '../../src/types.js'; + +const PROJECT_ID = 'test/provenance'; + +let testDir: string; + +beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memorix-provenance-')); + await resetDb(); + await initObservations(testDir); +}); + +// ── Schema persistence ──────────────────────────────────────────────── + +describe('Schema persistence', () => { + it('stores and retrieves sourceDetail=explicit', async () => { + const { observation } = await storeObservation({ + entityName: 'auth', + type: 'decision', + title: 'Use JWT for auth', + narrative: 'We chose JWT because it is stateless.', + projectId: PROJECT_ID, + sourceDetail: 'explicit', + }); + expect(observation.sourceDetail).toBe('explicit'); + const loaded = getObservation(observation.id); + expect(loaded?.sourceDetail).toBe('explicit'); + }); + + it('stores and retrieves sourceDetail=hook', async () => { + const { observation } = await storeObservation({ + entityName: 'session', + type: 'what-changed', + title: 'Changed auth.ts', + narrative: 'File edited by agent hook.', + projectId: PROJECT_ID, + sourceDetail: 'hook', + }); + expect(observation.sourceDetail).toBe('hook'); + const loaded = getObservation(observation.id); + expect(loaded?.sourceDetail).toBe('hook'); + }); + + it('stores and retrieves sourceDetail=git-ingest', async () => { + const { observation } = await storeObservation({ + entityName: 'commit-abc', + type: 'what-changed', + title: 'Fix null pointer in parser', + narrative: 'Commit-backed fact.', + projectId: PROJECT_ID, + source: 'git', + sourceDetail: 'git-ingest', + }); + expect(observation.sourceDetail).toBe('git-ingest'); + const loaded = getObservation(observation.id); + expect(loaded?.sourceDetail).toBe('git-ingest'); + }); + + it('stores and retrieves valueCategory', async () => { + const { observation } = await storeObservation({ + entityName: 'arch', + type: 'decision', + title: 'Architecture decision', + narrative: 'This is a core architecture decision with clear rationale.', + projectId: PROJECT_ID, + sourceDetail: 'explicit', + valueCategory: 'core', + }); + expect(observation.valueCategory).toBe('core'); + const loaded = getObservation(observation.id); + expect(loaded?.valueCategory).toBe('core'); + }); + + it('old observations without sourceDetail have undefined (neutral)', async () => { + const { observation } = await storeObservation({ + entityName: 'legacy', + type: 'discovery', + title: 'Legacy observation without sourceDetail', + narrative: 'This simulates a pre-1.0.6 observation.', + projectId: PROJECT_ID, + }); + expect(observation.sourceDetail).toBeUndefined(); + expect(observation.valueCategory).toBeUndefined(); + }); +}); + +// ── Search result provenance exposure ──────────────────────────────── + +describe('Search result provenance', () => { + it('exposes sourceDetail in IndexEntry when set', async () => { + await storeObservation({ + entityName: 'auth', + type: 'decision', + title: 'JWT authentication decision made explicitly', + narrative: 'We chose JWT because it is stateless and scalable.', + projectId: PROJECT_ID, + sourceDetail: 'explicit', + }); + + const entries = await searchObservations({ query: 'JWT authentication', projectId: PROJECT_ID }); + expect(entries.length).toBeGreaterThan(0); + const entry = entries[0]; + expect(entry.sourceDetail).toBe('explicit'); + }); + + it('exposes sourceDetail=hook in IndexEntry', async () => { + await storeObservation({ + entityName: 'session', + type: 'what-changed', + title: 'Hook captured file change in auth module', + narrative: 'Automated hook capture of file modification.', + projectId: PROJECT_ID, + sourceDetail: 'hook', + }); + + const entries = await searchObservations({ query: 'hook captured auth module', projectId: PROJECT_ID }); + expect(entries.length).toBeGreaterThan(0); + const hookEntry = entries.find(e => e.sourceDetail === 'hook'); + expect(hookEntry).toBeDefined(); + }); + + it('exposes valueCategory in IndexEntry when set', async () => { + await storeObservation({ + entityName: 'core-arch', + type: 'decision', + title: 'Core architecture pattern for data pipeline', + narrative: 'Fundamental design decision for the data processing pipeline.', + projectId: PROJECT_ID, + sourceDetail: 'explicit', + valueCategory: 'core', + }); + + const entries = await searchObservations({ query: 'core architecture data pipeline', projectId: PROJECT_ID }); + expect(entries.length).toBeGreaterThan(0); + const entry = entries[0]; + expect(entry.valueCategory).toBe('core'); + }); + + it('undefined sourceDetail/valueCategory does not pollute IndexEntry with empty string', async () => { + await storeObservation({ + entityName: 'plain', + type: 'discovery', + title: 'Plain observation without provenance fields', + narrative: 'A simple observation with no extra provenance metadata.', + projectId: PROJECT_ID, + }); + + const entries = await searchObservations({ query: 'plain observation provenance metadata', projectId: PROJECT_ID }); + expect(entries.length).toBeGreaterThan(0); + // sourceDetail and valueCategory should be undefined (not empty string '') + expect(entries[0].sourceDetail).toBeUndefined(); + expect(entries[0].valueCategory).toBeUndefined(); + }); +}); + +// ── Retention source-aware decay ───────────────────────────────────── + +describe('Retention source-aware decay', () => { + function makeDoc(overrides: Partial): MemorixDocument { + return { + id: 'obs-1', + observationId: 1, + entityName: 'test', + type: 'what-changed', + title: 'Test', + narrative: 'Test narrative', + facts: '', + filesModified: '', + concepts: '', + tokens: 50, + createdAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000).toISOString(), // 25 days old + projectId: PROJECT_ID, + accessCount: 0, + lastAccessedAt: '', + status: 'active', + source: 'agent', + ...overrides, + }; + } + + it('hook observations decay faster than neutral (same type, same age)', () => { + const neutral = makeDoc({ sourceDetail: '' }); + const hook = makeDoc({ sourceDetail: 'hook' }); + + const neutralScore = calculateRelevance(neutral).totalScore; + const hookScore = calculateRelevance(hook).totalScore; + + expect(hookScore).toBeLessThan(neutralScore); + }); + + it('git-ingest observations decay slower than neutral (same type, same age)', () => { + const neutral = makeDoc({ sourceDetail: '' }); + const git = makeDoc({ sourceDetail: 'git-ingest' }); + + const neutralScore = calculateRelevance(neutral).totalScore; + const gitScore = calculateRelevance(git).totalScore; + + expect(gitScore).toBeGreaterThan(neutralScore); + }); + + it('hook observations become archive-candidate sooner than neutral', () => { + // 20 days old — within neutral retention (low=30d) but beyond hook retention (15d) + const docBase = { + type: 'discovery' as const, // importance=low, retentionDays=30 → hook gets 15 + createdAt: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString(), + }; + const neutral = makeDoc({ ...docBase, sourceDetail: '' }); + const hook = makeDoc({ ...docBase, sourceDetail: 'hook' }); + + expect(getRetentionZone(neutral)).not.toBe('archive-candidate'); + expect(getRetentionZone(hook)).toBe('archive-candidate'); + }); + + it('valueCategory=core grants immunity regardless of type', () => { + // discovery with low importance would not normally be immune + const lowImportance = makeDoc({ type: 'discovery', sourceDetail: 'explicit', valueCategory: '' }); + const coreMemory = makeDoc({ type: 'discovery', sourceDetail: 'explicit', valueCategory: 'core' }); + + expect(isImmune(lowImportance)).toBe(false); + expect(isImmune(coreMemory)).toBe(true); + }); + + it('undefined sourceDetail applies neutral multiplier (backward-compat)', () => { + const withUndefined = makeDoc({ sourceDetail: undefined }); + const withEmpty = makeDoc({ sourceDetail: '' }); + + const scoreUndefined = calculateRelevance(withUndefined).totalScore; + const scoreEmpty = calculateRelevance(withEmpty).totalScore; + + expect(scoreUndefined).toBeCloseTo(scoreEmpty, 5); + }); +}); + +// ── Session injection source-aware scoring (direct) ───────────────── + +function makeObs(overrides: Partial): Observation { + return { + id: 1, + entityName: 'test', + type: 'gotcha', + title: 'Test observation', + narrative: 'Test narrative for scoring.', + facts: [], + filesModified: [], + concepts: [], + tokens: 50, + createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), // 2 days old + projectId: PROJECT_ID, + revisionCount: 1, + status: 'active', + hasCausalLanguage: false, + ...overrides, + }; +} + +describe('Session injection source-aware scoring', () => { + it('hook scores lower than explicit (same type, same age, same content)', () => { + const explicit = makeObs({ sourceDetail: 'explicit' }); + const hook = makeObs({ sourceDetail: 'hook' }); + + const scoreExplicit = scoreObservationForSessionContext(explicit, []); + const scoreHook = scoreObservationForSessionContext(hook, []); + + expect(scoreHook).toBeLessThan(scoreExplicit); + expect(scoreExplicit - scoreHook).toBeGreaterThanOrEqual(3); // exact delta from session.ts + }); + + it('hook+ephemeral scores lower than hook alone (compound penalty)', () => { + const hook = makeObs({ sourceDetail: 'hook', valueCategory: undefined }); + const hookEphemeral = makeObs({ sourceDetail: 'hook', valueCategory: 'ephemeral' }); + + const scoreHook = scoreObservationForSessionContext(hook, []); + const scoreHookEphemeral = scoreObservationForSessionContext(hookEphemeral, []); + + expect(scoreHookEphemeral).toBeLessThan(scoreHook); + expect(scoreHook - scoreHookEphemeral).toBeGreaterThanOrEqual(5); // extra -5 from session.ts + }); + + it('core boosts above neutral/undefined (same type, same age)', () => { + const neutral = makeObs({ sourceDetail: 'explicit', valueCategory: undefined }); + const core = makeObs({ sourceDetail: 'explicit', valueCategory: 'core' }); + + const scoreNeutral = scoreObservationForSessionContext(neutral, []); + const scoreCore = scoreObservationForSessionContext(core, []); + + expect(scoreCore).toBeGreaterThan(scoreNeutral); + expect(scoreCore - scoreNeutral).toBeCloseTo(2, 6); // +2 from session.ts, tolerate FP drift + }); + + it('ordering: explicit > hook > hook+ephemeral', () => { + const explicit = makeObs({ sourceDetail: 'explicit' }); + const hook = makeObs({ sourceDetail: 'hook' }); + const hookEphemeral = makeObs({ sourceDetail: 'hook', valueCategory: 'ephemeral' }); + + const scores = [explicit, hook, hookEphemeral].map(o => + scoreObservationForSessionContext(o, []), + ); + + expect(scores[0]).toBeGreaterThan(scores[1]); // explicit > hook + expect(scores[1]).toBeGreaterThan(scores[2]); // hook > hook+ephemeral + }); +}); diff --git a/tests/memory/session-layered.test.ts b/tests/memory/session-layered.test.ts new file mode 100644 index 0000000..1fbe705 --- /dev/null +++ b/tests/memory/session-layered.test.ts @@ -0,0 +1,267 @@ +/** + * Phase 2: Layered Session Context Tests + * + * Verifies that getSessionContext() produces explicit L1/L2/L3 sections + * based on sourceDetail and valueCategory provenance fields. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../src/embedding/provider.js', () => ({ + getEmbeddingProvider: async () => null, + isVectorSearchAvailable: async () => false, + isEmbeddingExplicitlyDisabled: () => true, + resetProvider: () => {}, +})); + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { storeObservation, initObservations } from '../../src/memory/observations.js'; +import { resetDb } from '../../src/store/orama-store.js'; +import { getSessionContext } from '../../src/memory/session.js'; + +const PROJECT_ID = 'test/session-layered'; + +let testDir: string; + +beforeEach(async () => { + testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memorix-session-layered-')); + await resetDb(); + await initObservations(testDir); +}); + +// ── L2: only explicit obs → no L1/L3 sections ─────────────────────── + +describe('L2-only project (all explicit)', () => { + it('shows Key Project Memories but no L1 Routing or L3 Evidence sections', async () => { + await storeObservation({ + entityName: 'auth', + type: 'gotcha', + title: 'JWT tokens expire silently in production', + narrative: 'Critical gotcha about silent expiry.', + projectId: PROJECT_ID, + sourceDetail: 'explicit', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + expect(ctx).toContain('## Key Project Memories'); + expect(ctx).toContain('JWT tokens expire silently'); + expect(ctx).not.toContain('## L1 Routing'); + expect(ctx).not.toContain('## L3 Evidence'); + }); + + it('old observations without sourceDetail enter L2 (backward-compat)', async () => { + await storeObservation({ + entityName: 'db', + type: 'decision', + title: 'Use PostgreSQL for primary store', + narrative: 'Legacy decision without provenance fields.', + projectId: PROJECT_ID, + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + expect(ctx).toContain('## Key Project Memories'); + expect(ctx).toContain('Use PostgreSQL for primary store'); + expect(ctx).not.toContain('## L1 Routing'); + }); +}); + +// ── L1: hook obs → L1 section with titles + routing guidance ──────── + +describe('Hook observations → L1 section', () => { + it('shows L1 Routing section with hook title when hook obs exists', async () => { + await storeObservation({ + entityName: 'file-edit', + type: 'what-changed', + title: 'Edited auth/handler.ts', + narrative: 'Hook-captured file modification.', + projectId: PROJECT_ID, + sourceDetail: 'hook', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + expect(ctx).toContain('## L1 Routing'); + expect(ctx).toContain('Edited auth/handler.ts'); + }); + + it('L1 section includes routing guidance hints', async () => { + await storeObservation({ + entityName: 'file-edit', + type: 'what-changed', + title: 'Edited parser.ts', + narrative: 'Hook auto-capture.', + projectId: PROJECT_ID, + sourceDetail: 'hook', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + expect(ctx).toContain('## L1 Routing'); + expect(ctx).toContain('memorix_timeline'); + }); + + it('hook observations do NOT appear in Key Project Memories (L2 excluded)', async () => { + await storeObservation({ + entityName: 'hook-event', + type: 'gotcha', + title: 'Hook-captured gotcha that should stay in L1', + narrative: 'This should not pollute working context.', + projectId: PROJECT_ID, + sourceDetail: 'hook', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + // Should not be in Key Memories (L2 is source-filtered) + if (ctx.includes('## Key Project Memories')) { + // If the section exists, the hook obs must not be in it + const keyMemSection = ctx.split('## L3 Evidence')[0].split('## Key Project Memories')[1] ?? ''; + expect(keyMemSection).not.toContain('Hook-captured gotcha'); + } + }); + + it('core-valued hook observations ARE promoted to L2', async () => { + await storeObservation({ + entityName: 'arch', + type: 'gotcha', + title: 'Critical core gotcha from hook', + narrative: 'Formation classified this as core.', + projectId: PROJECT_ID, + sourceDetail: 'hook', + valueCategory: 'core', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + // core overrides the hook L1 classification + expect(ctx).toContain('## Key Project Memories'); + expect(ctx).toContain('Critical core gotcha from hook'); + }); +}); + +// ── L3: git-ingest → L3 Evidence pointer (not full content) ───────── + +describe('Git-ingest observations → L3 Evidence hints', () => { + it('shows L3 Evidence section with git pointer when git obs exists', async () => { + await storeObservation({ + entityName: 'commit-abc', + type: 'what-changed', + title: 'Fix null pointer in parser (commit abc1234)', + narrative: 'Git-backed commit fact.', + projectId: PROJECT_ID, + source: 'git', + sourceDetail: 'git-ingest', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + expect(ctx).toContain('## L3 Evidence'); + expect(ctx).toContain('memorix_search'); + }); + + it('git-ingest content does NOT appear in Key Project Memories body', async () => { + await storeObservation({ + entityName: 'commit-abc', + type: 'what-changed', + title: 'Refactor storage layer in commit abc', + narrative: 'Git-backed commit fact — should stay in L3.', + projectId: PROJECT_ID, + source: 'git', + sourceDetail: 'git-ingest', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + if (ctx.includes('## Key Project Memories')) { + const keyMemSection = ctx.split('## L3 Evidence')[0].split('## Key Project Memories')[1] ?? ''; + expect(keyMemSection).not.toContain('Refactor storage layer'); + } + }); + + it('L1 Routing shows git-memory search hint when git obs exists', async () => { + await storeObservation({ + entityName: 'commit-xyz', + type: 'what-changed', + title: 'Add caching layer commit xyz', + narrative: 'Git fact.', + projectId: PROJECT_ID, + sourceDetail: 'git-ingest', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + expect(ctx).toContain('## L1 Routing'); + expect(ctx).toContain('git-memory'); + expect(ctx).toContain('what-changed'); + }); + + it('core-valued git obs are promoted to L2', async () => { + await storeObservation({ + entityName: 'arch', + type: 'decision', + title: 'Core architecture commit: adopt microservices', + narrative: 'Formation classified this git fact as core.', + projectId: PROJECT_ID, + sourceDetail: 'git-ingest', + valueCategory: 'core', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + expect(ctx).toContain('## Key Project Memories'); + expect(ctx).toContain('Core architecture commit: adopt microservices'); + }); +}); + +// ── Mixed: all three layers present ────────────────────────────────── + +describe('Mixed provenance project', () => { + it('produces L1 + L2 + L3 sections in correct order', async () => { + // L2 explicit + await storeObservation({ + entityName: 'auth', + type: 'gotcha', + title: 'JWT expiry is silent', + narrative: 'Explicit working context.', + projectId: PROJECT_ID, + sourceDetail: 'explicit', + }); + + // L1 hook + await storeObservation({ + entityName: 'edit', + type: 'what-changed', + title: 'Edited auth.ts', + narrative: 'Hook capture.', + projectId: PROJECT_ID, + sourceDetail: 'hook', + }); + + // L3 git + await storeObservation({ + entityName: 'commit', + type: 'what-changed', + title: 'Fix auth bug in commit deadbeef', + narrative: 'Git fact.', + projectId: PROJECT_ID, + sourceDetail: 'git-ingest', + }); + + const ctx = await getSessionContext(testDir, PROJECT_ID); + + expect(ctx).toContain('## L1 Routing'); + expect(ctx).toContain('## Key Project Memories'); + expect(ctx).toContain('## L3 Evidence'); + + // Section order: L1 before Key Memories before L3 + const l1Pos = ctx.indexOf('## L1 Routing'); + const l2Pos = ctx.indexOf('## Key Project Memories'); + const l3Pos = ctx.indexOf('## L3 Evidence'); + expect(l1Pos).toBeLessThan(l2Pos); + expect(l2Pos).toBeLessThan(l3Pos); + }); +}); diff --git a/tests/memory/session.test.ts b/tests/memory/session.test.ts index 9e1ee3e..39e0c03 100644 --- a/tests/memory/session.test.ts +++ b/tests/memory/session.test.ts @@ -290,7 +290,7 @@ describe('Session Lifecycle', () => { // Each section has a clarifying subtitle expect(context).toContain('pick up where it left off'); - expect(context).toContain('ranked by type and relevance, not recency'); + expect(context).toContain('Durable working context'); expect(context).toContain('for orientation, not action'); }); });