Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/commands/ingest-commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/cli/commands/ingest-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ async function runRemember(text: string): Promise<void> {
narrative: text,
facts: [],
projectId: proj.id,
sourceDetail: 'explicit',
});

s.stop('Stored');
Expand Down
1 change: 1 addition & 0 deletions src/cli/tui/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down
2 changes: 2 additions & 0 deletions src/compact/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
113 changes: 103 additions & 10 deletions src/compact/index-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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('');
Expand All @@ -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('---------');
Expand All @@ -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(' | ')} |`);
Expand All @@ -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('');
}
Expand All @@ -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;
Expand All @@ -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()}`);
Expand Down Expand Up @@ -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<string, string> = {
'session-request': '🎯',
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ export async function runHook(agentOverride?: string): Promise<void> {
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 {
Expand Down
75 changes: 75 additions & 0 deletions src/memory/disclosure-policy.ts
Original file line number Diff line number Diff line change
@@ -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';
}
Comment on lines +7 to +62
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header comment says L3 includes “git-ingest (non-core), or any other low-trust source”, but classifyLayer() currently routes any unknown sourceDetail to L2. Either update the comment to match the implementation (only git-ingest ⇒ L3) or extend the logic to treat unknown/non-enumerated sourceDetail values as L3 so future sources don’t get accidentally promoted into L2.

Copilot uses AI. Check for mistakes.

/**
* 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 '';
}
Loading
Loading