Skip to content
8 changes: 8 additions & 0 deletions modules/jarvos-memory/docs/MEMORY_PROMOTION_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ raw transcripts. A knowledge unit must have source evidence, a non-sensitive
privacy decision, and `downstreamEligibility.memoryPromotion !== false`.
Sensitive/private or uncited units stay in the secondbrain sidecar layer.

Intellectual origin is a separate gate from privacy and search eligibility. A
knowledge unit marked `human` must also carry `human_evidence_eligible: true`
before it can establish a user fact, preference, decision, belief, or lesson in
durable Memory. `assistant`, `mixed`, and `unknown` units remain searchable and
usable as context, but are rejected by the human-memory promotion gate. Legacy
notes may use the constrained `legacy_author` fallback during the compatibility
period; new programmatic human records require a validated user-source receipt.

### Transcript evidence -> reviewed memory

Transcript evidence is source material, not durable memory. A bounded
Expand Down
8 changes: 8 additions & 0 deletions modules/jarvos-memory/lib/memory-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,16 @@ function getMemoryPaths() {
};
}

function getHindsightConfig() {
return {
apiUrl: process.env.HINDSIGHT_API_URL || 'http://127.0.0.1:8888',
timeoutMs: Number(process.env.HINDSIGHT_TIMEOUT_MS || 1500),
};
}

module.exports = {
DEFAULT_CLAWD_ROOT,
getClawdRoot,
getMemoryPaths,
getHindsightConfig,
};
7 changes: 7 additions & 0 deletions modules/jarvos-memory/lib/memory-record.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const { getClawdRoot: getWorkspaceRoot } = require('./memory-config');
* @param {string} [params.noteRef] - Link to full note if one was created
* @param {number} [params.confidence] - 0.0-1.0
* @param {string} [params.supersedes] - ID of prior memory this replaces
* @param {object} [params.provenance] - Intellectual-origin declaration from the source unit
* @returns {{ record: object, written: boolean, path: string|null, error: string|null }}
*/
function createMemoryRecord(params = {}) {
Expand Down Expand Up @@ -54,6 +55,9 @@ function createMemoryRecord(params = {}) {
created: now.toISOString(),
confidence: typeof params.confidence === 'number' ? params.confidence : undefined,
supersedes: params.supersedes || undefined,
content_origin: params.provenance?.content_origin || undefined,
content_origin_basis: params.provenance?.content_origin_basis || undefined,
human_evidence_eligible: params.provenance?.human_evidence_eligible === true ? true : undefined,
status: 'active',
};

Expand Down Expand Up @@ -94,6 +98,9 @@ function createMemoryRecord(params = {}) {
record.noteRef ? `note_ref: "${record.noteRef}"` : null,
record.supersedes ? `supersedes: "${record.supersedes}"` : null,
record.confidence != null ? `confidence: ${record.confidence}` : null,
record.content_origin ? `content_origin: ${record.content_origin}` : null,
record.content_origin_basis ? `content_origin_basis: ${record.content_origin_basis}` : null,
record.human_evidence_eligible === true ? 'human_evidence_eligible: true' : null,
'---',
].filter(Boolean).join('\n');

Expand Down
59 changes: 59 additions & 0 deletions modules/jarvos-memory/src/lib/hindsight-adapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use strict';

class HindsightAdapter {
constructor({ apiUrl = 'http://127.0.0.1:8888', timeoutMs = 1500 } = {}) {
this.apiUrl = String(apiUrl).replace(/\/$/, '');
this.timeoutMs = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : 1500;
}

async request(path, { method = 'GET', body } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(`${this.apiUrl}${path}`, {
method,
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) throw new Error(`Hindsight returned HTTP ${response.status}`);
return await response.json();
} finally {
clearTimeout(timer);
}
}

async ping() {
try {
const response = await this.request('/health');
return response?.status === 'ok' || response?.ok === true;
} catch {
return false;
}
}

async recall(query) {
try {
const response = await this.request('/recall', { method: 'POST', body: { query: String(query || '') } });
return {
results: Array.isArray(response?.results)
? response.results.map((item) => typeof item === 'string' ? item : item?.text).filter(Boolean)
: [],
error: null,
};
} catch (error) {
return { results: [], error: error.message };
}
}

async reflect(query) {
try {
const response = await this.request('/reflect', { method: 'POST', body: { query: String(query || '') } });
return { text: typeof response?.text === 'string' ? response.text : null, error: null };
} catch (error) {
return { text: null, error: error.message };
}
}
}

module.exports = { HindsightAdapter };
95 changes: 93 additions & 2 deletions modules/jarvos-memory/src/lib/memory-promotion.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ const {
CORE_MEMORY_CLASSES,
} = require('./memory-schema');

const { createMemoryRecord } = require('./memory-record');
const { createMemoryRecord } = require('../../lib/memory-record');
const { HindsightAdapter } = require('./hindsight-adapter');
const { getHindsightConfig } = require('./memory-config');
const { getHindsightConfig } = require('../../lib/memory-config');
const { contentOriginPairIsValid } = require('../../../jarvos-secondbrain/bridge/provenance/src/content-origin-contract');

function promotionContent(event = {}) {
if (event.knowledgeUnit) return String(event.knowledgeUnit.text || '').trim();
Expand All @@ -45,6 +46,85 @@ function hasKnowledgeUnitEvidence(unit = {}) {
&& unit.evidence.some((entry) => entry?.sourcePath || entry?.quote || entry?.bodySha256 || entry?.ref);
}

function hasUserSourceReceipt(receipt = {}) {
return Boolean(receipt
&& typeof receipt === 'object'
&& receipt.actor === 'user'
&& typeof receipt.capture_event_id === 'string'
&& receipt.capture_event_id.trim()
&& /^[a-f0-9]{64}$/.test(String(receipt.source_digest || ''))
&& /^[a-f0-9]{64}$/.test(String(receipt.content_digest || '')));
}

function contentOriginRecord(unit = {}, event = {}) {
const candidate = unit.provenance || unit;
const eventOrigin = event.content_origin ?? event.contentOrigin;
const eventBasis = event.content_origin_basis ?? event.contentOriginBasis;
const candidateHasDeclaration = candidate.content_origin !== undefined
|| candidate.contentOrigin !== undefined
|| candidate.content_origin_basis !== undefined
|| candidate.contentOriginBasis !== undefined
|| candidate.human_evidence_eligible !== undefined
|| candidate.humanEvidenceEligible !== undefined;
const origin = candidateHasDeclaration
? candidate.content_origin ?? candidate.contentOrigin
: eventOrigin;
const basis = candidateHasDeclaration
? candidate.content_origin_basis ?? candidate.contentOriginBasis
: eventBasis;
const hasDeclaration = candidateHasDeclaration || eventOrigin !== undefined || eventBasis !== undefined
|| event.human_evidence_eligible !== undefined || event.humanEvidenceEligible !== undefined;
if (!hasDeclaration) return null;
const normalizedOrigin = String(origin || 'unknown').trim().toLowerCase();
const normalizedBasis = String(basis || 'unknown').trim().toLowerCase();
const receipt = candidate.user_source
|| candidate.userSource
|| candidate.content_origin_source
|| candidate.contentOriginSource
|| event.user_source
|| event.userSource;
const validPair = normalizedBasis === 'legacy_author'
? ['human', 'assistant', 'mixed'].includes(normalizedOrigin)
: contentOriginPairIsValid(normalizedOrigin, normalizedBasis);
return {
content_origin: normalizedOrigin,
content_origin_basis: normalizedBasis,
human_evidence_eligible: candidateHasDeclaration
? candidate.human_evidence_eligible === true || candidate.humanEvidenceEligible === true
: event.human_evidence_eligible === true || event.humanEvidenceEligible === true,
valid_pair: validPair,
receipt_bound: normalizedOrigin !== 'human'
|| normalizedBasis === 'legacy_author'
|| hasUserSourceReceipt(receipt),
};
}

function humanEvidenceGate(unit, event) {
const provenance = contentOriginRecord(unit, event);
if (!provenance) return null;
if (!provenance.valid_pair) {
return {
provenance,
reason: `content origin '${provenance.content_origin}' has an invalid basis '${provenance.content_origin_basis}'`,
};
}
if (provenance.content_origin === 'human'
&& provenance.content_origin_basis !== 'legacy_author'
&& !provenance.receipt_bound) {
return {
provenance,
reason: 'human evidence requires a user-source receipt',
};
}
if (provenance.content_origin !== 'human' || provenance.human_evidence_eligible !== true) {
return {
provenance,
reason: `content origin '${provenance.content_origin}' is context-only for human-memory promotion`,
};
}
return { provenance, reason: null };
}

function isRawTranscriptSource(source = {}) {
if (typeof source === 'string') return source === 'transcript' || source === 'raw-transcript';
return source.type === 'transcript' || source.kind === 'transcript' || source.raw === true;
Expand Down Expand Up @@ -72,6 +152,10 @@ function reviewKnowledgeUnitCandidate(event = {}) {
if (!hasKnowledgeUnitEvidence(unit)) {
return { shouldPromote: false, memoryClass: null, reason: 'knowledgeUnit promotion requires source evidence' };
}
const evidenceGate = humanEvidenceGate(unit, event);
if (evidenceGate?.reason) {
return { shouldPromote: false, memoryClass: null, reason: evidenceGate.reason };
}
if (unit.privacyDecision?.excludedFromPromotion || unit.privacyDecision?.tier === 'secret' || unit.privacyDecision?.tier === 'sensitive') {
return { shouldPromote: false, memoryClass: null, reason: `knowledgeUnit privacy tier '${unit.privacyDecision?.tier || 'unknown'}' is not eligible for promotion` };
}
Expand Down Expand Up @@ -124,6 +208,11 @@ function reviewCandidate(event = {}) {
return { shouldPromote: false, memoryClass: null, reason: 'raw source-backed captures must promote through cited knowledgeUnit references' };
}

const evidenceGate = humanEvidenceGate({}, event);
if (evidenceGate?.reason) {
return { shouldPromote: false, memoryClass: null, reason: evidenceGate.reason };
}

const text = promotionContent(event);
if (!text) {
return { shouldPromote: false, memoryClass: null, reason: 'no content to promote' };
Expand Down Expand Up @@ -208,6 +297,7 @@ function promoteCandidate(event = {}) {

const content = promotionContent(event);
const unit = event.knowledgeUnit || null;
const provenance = contentOriginRecord(unit || {}, event);
const result = createMemoryRecord({
class: review.memoryClass,
content,
Expand All @@ -216,6 +306,7 @@ function promoteCandidate(event = {}) {
noteRef: event.noteRef || unit?.source?.path,
confidence: event.confidence || unit?.confidence,
supersedes: event.supersedes,
provenance,
});

if (result.error) {
Expand Down
65 changes: 65 additions & 0 deletions modules/jarvos-memory/test/memory-promotion.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@ describe('knowledgeUnit promotion gates', () => {
downstreamEligibility: {
memoryPromotion: true,
},
content_origin: 'human',
content_origin_basis: 'legacy_author',
human_evidence_eligible: true,
...overrides,
};
}
Expand Down Expand Up @@ -362,6 +365,68 @@ describe('knowledgeUnit promotion gates', () => {
assert.match(review.reason, /memoryPromotion is false/);
});

it('rejects assistant-generated knowledge units from human memory', () => {
const review = reviewCandidate({
knowledgeUnit: knowledgeUnit({
content_origin: 'assistant',
content_origin_basis: 'assistant_generated',
human_evidence_eligible: false,
}),
});
assert.equal(review.shouldPromote, false);
assert.match(review.reason, /context-only/);
});

it('rejects explicit human knowledge units that omit their source receipt', () => {
const review = reviewCandidate({
knowledgeUnit: knowledgeUnit({
content_origin: 'human',
content_origin_basis: 'user_derived',
human_evidence_eligible: true,
content_origin_source: undefined,
}),
});
assert.equal(review.shouldPromote, false);
assert.match(review.reason, /user-source receipt/);
});

it('rejects mixed and unknown knowledge units from human memory', () => {
for (const provenance of [
{ content_origin: 'mixed', content_origin_basis: 'mixed_composition', human_evidence_eligible: false },
{ content_origin: 'unknown', content_origin_basis: 'unknown', human_evidence_eligible: false },
]) {
const review = reviewCandidate({ knowledgeUnit: knowledgeUnit(provenance) });
assert.equal(review.shouldPromote, false);
assert.match(review.reason, /context-only/);
}
});

it('does not let an event envelope override an ineligible unit decision', () => {
const review = reviewCandidate({
human_evidence_eligible: true,
knowledgeUnit: knowledgeUnit({ human_evidence_eligible: false }),
});
assert.equal(review.shouldPromote, false);
assert.match(review.reason, /context-only/);
});

it('rejects a human unit whose basis does not match its origin', () => {
const review = reviewCandidate({
knowledgeUnit: knowledgeUnit({
content_origin: 'human',
content_origin_basis: 'assistant_generated',
human_evidence_eligible: true,
}),
});
assert.equal(review.shouldPromote, false);
assert.match(review.reason, /invalid basis/);
});

it('rejects an explicitly non-human direct event while preserving legacy undeclared events during compatibility', () => {
assert.equal(reviewCandidate({ text: 'Assistant preference', salienceClass: 'preference', content_origin: 'assistant', content_origin_basis: 'assistant_generated' }).shouldPromote, false);
assert.equal(reviewCandidate({ text: 'Legacy preference', salienceClass: 'preference' }).shouldPromote, true);
});

it('promotes cited knowledge units through the local file path', () => {
const result = promoteCandidate({
knowledgeUnit: knowledgeUnit({
Expand Down
Loading
Loading