Cambium Memory v1 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship @cambium/memory v1 — a four-layer memory substrate (Substrate files, epigenetic Marks, Daily files, Compost) that lets Obadiah recall what it knows about Joshua across sessions, proven end-to-end with a consolidation flow that turns a line in today’s daily log into a queryable, evidence-linked mark.
⚠️ PLAN REVISION NOTICE — READ BEFORE STARTING TASK 7 (or any later task referencing
SubstrateReader)What changed: After this plan was written, the Memory spec and the new Spore+Routing spec were revised so that the Substrate layer reads from a list of entries rather than a single hardcoded directory. Each entry in the list is either a directory (all
*.mdchildren become substrate) or a single.mdfile (that file itself becomes substrate). Directories and individual files can be mixed in the same list.This matches Joshua’s real deployment, where substrate lives in four places: 1.
/Users/joshua/Projects/Obadiah/memory/long-term/(directory, 5 files) 2./Users/joshua/Projects/Obadiah/personal-context/(directory, 10 numbered files) 3./Users/joshua/Projects/Obadiah/SOUL.md(single file — Obadiah identity DNA) 4./Users/joshua/Projects/Obadiah/USER.md(single file — operational context)Source of truth for the list:
spore.paths.substrate— a field on the Spore config loaded by the brain process at boot. Seedocs/superpowers/specs/2026-04-11-cambium-spore-and-discord-routing-design.md.Required adjustments to the plan tasks below
Task 7 (Substrate Reader) — constructor change: - OLD:
new SubstrateReader(rootDir: string)which assumed<rootDir>/long-term/*.md- NEW:new SubstrateReader(entries: string[])where each entry is either a directory or a file path - Implementation: for each entry, callstat()— ifisDirectory(), read its*.mdchildren; ifisFile(), read the file itself -SubstrateEntry.fileshould become the absolute path, not the relativelong-term/identity.mdform. This disambiguates files with the same basename across different source directories (e.g.memory/long-term/identity.mdvspersonal-context/01-identity.md) - Test fixtures: extend the setup to include BOTH a directory of markdown files AND at least one top-level single-file entry (e.g. a fakeSOUL.mdwritten to the tmpdir root). Verify the reader walks both uniformly and returns entries from both. - Test assertions that previously usedexpect(files).toEqual(['long-term/identity.md', 'long-term/workflow.md'])should switch to absolute paths or basename matchers:expect(files.some(f => f.endsWith('identity.md'))).toBe(true).Task 10 (recall): update the test setup to construct
SubstrateReaderwith an array:new SubstrateReader([dir])instead ofnew SubstrateReader(dir). Substrate result assertions should match against absolute paths or basenames, not the oldlong-term/<name>.mdrelative form.Task 12 (integration test): same constructor adjustment. Fixture setup can remain a single-directory tmpdir since Task 7 already covers the mixed dir+file case. Just wrap in an array.
Task 14 (Obadiah wiring): the brain process (start-rhythms.ts) will have already loaded
spore.paths.substratefor its own reasons (see the Spore+Routing plan). Pass that array directly:new SubstrateReader(spore.paths.substrate). Do NOT hardcode any path.Task 15 (CLI): the
obadiah memory substratesubcommand should print entries with their absolute paths (or paths relative to CWD for readability). Semantically unchanged; only the display format differs.Anywhere else substrate paths appear: same principle — a list, not a single dir. The
substrate_refsfield onMemoryMark(JSONB string array) continues to reference individual files (not directories), which is fine — that field’s semantics don’t change.Why this note instead of rewriting the task bodies inline: The plan is ~2400 lines and rewriting Task 7 from scratch risked introducing subtle bugs in the test code. A prominent revision note at the top + executor judgment is safer. If you’re executing Task 7, read this note first, apply the constructor change, update the test fixtures to exercise mixed dir+file entries, and let the downstream tasks inherit the new signature with the one-line constructor adjustments above.
Architecture: New workspace package @cambium/memory. A MemoryStore interface holds Marks, Reinforcements, and Seeds (in-memory for v1 unit tests, Drizzle-backed schema for later). Substrate and Daily readers wrap the filesystem at /Users/joshua/Projects/Obadiah/memory/ (substrate is read-only; daily supports append + archive). A Compost reader wraps @cambium/genesis‘s CompostingProcess (read-only). A single recall() function blends all four layers into a structured RecallResult. Two signal receptors — memory.observation and memory.seed_planted — turn signals into marks and seeds. A consolidation receptor processes memory.consolidate signals (fired by a new Rhythms template at 03:00 Berlin) using a mock LLM extractor in v1.
Tech Stack: TypeScript strict, ESM, Drizzle ORM, yaml package, Vitest, Postgres. Depends on @cambium/core, @cambium/db, @cambium/signals, @cambium/genesis.
Scope adjustment from spec: Three honest narrowings relative to the design doc:
- No real LLM in v1. Task 14 (consolidation receptor) uses a
LLMExtractorinterface with aMockLLMExtractorimplementation that returns deterministic extractions from fixture text. Real provider binding (Anthropic/OpenAI) is deferred to SEED-002. - In-memory store at the edge. Tasks 6-14 test against
InMemoryMemoryStore. The Drizzle schema and migration land in Task 5, but aPgMemoryStoreimplementation is a follow-up (same pattern as Rhythms v1: schema ready, store implementation deferred). The Obadiah wiring (Task 15) uses the in-memory store. - Compost subject matching is text-based, not structured. Exploration of
@cambium/genesisrevealed thatCompostRecordhas nosubject,affinity, or tags — onlytemplate_id,failure_patterns[].pattern,successful_strategies[], andtemplate_improvements[](all free text) plusrouting_hints[]. The Compost reader in Task 9 therefore filters records by substring match of the recall subject against the concatenated text fields of each record. This is intentionally loose in v1; structured compost tagging is a potential follow-up on the genesis side.
Deferred items (see .planning/seeds/ and .planning/backlog/):
- SEED-002 — Real LLM provider for consolidation and summary field on recall()
- BL-005 — Loom memory page (CLI + programmatic is sufficient for v1)
- BL-006 — Vector / semantic search over marks (exact-prefix subject matching covers v1 load)
- PgMemoryStore — Drizzle-backed store implementation (schema lands in Task 5)
- Cross-persona memory sharing — out of scope; shared workspace makes it a no-op today
- Writing back to substrate files from accepted seeds — permanent non-goal per the spec
File Structure
New files:
packages/memory/
├── package.json
├── tsconfig.json
├── vitest.config.ts
└── src/
├── index.ts — barrel export
├── types.ts — MemoryMark, MemoryMarkReinforcement, MemorySeed, EvidenceRefs, MarkState, SeedState
├── subject.ts — subjectMatches(prefix, candidate) pure helper
├── subject.test.ts
├── strength.ts — computeCurrentStrength(mark, reinforcements, now)
├── strength.test.ts
├── memory-store.ts — MemoryStore interface + InMemoryMemoryStore
├── memory-store.test.ts
├── substrate-reader.ts — reads long-term/*.md with cache + fs.watch invalidation
├── substrate-reader.test.ts
├── daily-reader.ts — reads/appends daily/YYYY-MM-DD.md, archive-move
├── daily-reader.test.ts
├── compost-reader.ts — thin wrapper around CompostingProcess
├── compost-reader.test.ts
├── recall.ts — blended query function
├── recall.test.ts
├── receptors.ts — memory.observation + memory.seed_planted + memory.consolidate
├── receptors.test.ts
├── llm-extractor.ts — LLMExtractor interface + MockLLMExtractor
├── llm-extractor.test.ts
└── integration.test.ts — real SignalBus: observation → mark → recall
packages/memory/test/fixtures/
├── long-term/
│ ├── identity.md
│ └── workflow.md
└── daily/
├── 2026-04-11.md
└── archive/.gitkeep
deployments/obadiah/rhythms/
└── consolidation.yaml — daily 03:00 Berlin
deployments/obadiah/bridge/
├── start-memory.ts — boot script: wires receptors onto SignalBus
└── cambium-bridge.ts — MODIFIED: add memory CLI subcommands
Modified files:
packages/db/src/schema.ts — add memoryMarks, memoryMarkReinforcements, memorySeeds tables
packages/db/drizzle/ — new generated migration SQL
deployments/obadiah/bridge/cambium-bridge.ts — add `memory recall|mark|seeds|substrate` subcommands
Task 1: Scaffold @cambium/memory Package
Files:
- Create: packages/memory/package.json
- Create: packages/memory/tsconfig.json
- Create: packages/memory/vitest.config.ts
- Create: packages/memory/src/index.ts
- [ ] Step 1: Create package.json
Create packages/memory/package.json:
{
"name": "@cambium/memory",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"dev": "tsc --watch"
},
"dependencies": {
"@cambium/core": "workspace:*",
"@cambium/db": "workspace:*",
"@cambium/signals": "workspace:*",
"@cambium/genesis": "workspace:*",
"drizzle-orm": "^0.36.4"
},
"devDependencies": {
"@types/node": "^25.5.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
- [ ] Step 2: Create tsconfig.json
Create packages/memory/tsconfig.json:
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "test"]
}
- [ ] Step 3: Create vitest.config.ts
Create packages/memory/vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});
- [ ] Step 4: Create empty barrel
Create packages/memory/src/index.ts:
// @cambium/memory — four-layer memory substrate (substrate, marks, daily, compost)
// Exports added as implementation lands.
export {};
- [ ] Step 5: Install and verify package builds
Run from repo root:
pnpm install
pnpm --filter @cambium/memory build
pnpm --filter @cambium/memory typecheck
Expected: both commands exit 0. A dist/ directory appears under packages/memory/ containing index.js and index.d.ts.
- [ ] Step 6: Commit
git add packages/memory/package.json packages/memory/tsconfig.json packages/memory/vitest.config.ts packages/memory/src/index.ts
git commit -m "feat(memory): scaffold @cambium/memory package"
Task 2: Define Core Types
Files:
- Create: packages/memory/src/types.ts
- Modify: packages/memory/src/index.ts
- [ ] Step 1: Create types.ts
Create packages/memory/src/types.ts:
import type { WorkspaceId } from '@cambium/core';
export type MarkId = string & { readonly __brand: 'MarkId' };
export type MarkReinforcementId = string & { readonly __brand: 'MarkReinforcementId' };
export type SeedId = string & { readonly __brand: 'SeedId' };
/** Dot-path subject like "joshua.sleep.bedtime". Stringly typed on purpose. */
export type SubjectPath = string;
/** State of a memory mark. */
export type MarkState = 'active' | 'withered' | 'promoted';
/** State of a seed proposal. */
export type SeedState = 'pending' | 'accepted' | 'rejected' | 'withered';
/**
* Evidence pointers that produced a mark. All fields optional — a mark must
* have at least one populated reference to stay honest, enforced at write time.
*/
export interface EvidenceRefs {
signal_ids?: string[];
daily_file?: string; // e.g. "2026-04-11.md"
line_range?: [number, number]; // 1-indexed, inclusive
compost_ids?: string[]; // CompostRecord IDs
}
/**
* A typed, timestamped, decaying observation about a subject.
*/
export interface MemoryMark {
id: MarkId;
workspace_id: WorkspaceId;
subject: SubjectPath;
observation: string;
evidence: EvidenceRefs;
initial_strength: number; // 0..1 at first_observed_at
decay_rate: number; // half-life in days
observed_by: string; // agent id, or "joshua"
first_observed_at: Date;
last_reinforced_at: Date;
reinforcement_count: number;
affinity: string[];
substrate_refs: string[]; // e.g. ["long-term/workflow.md"]
state: MarkState;
created_at: Date;
updated_at: Date;
}
/**
* A reinforcement event attached to a mark — what confirmed it, when, by whom.
*/
export interface MemoryMarkReinforcement {
id: MarkReinforcementId;
mark_id: MarkId;
reinforced_at: Date;
reinforced_by: string;
evidence: EvidenceRefs;
strength_delta: number;
}
/**
* A proposal that Joshua consider adding a topic to his long-term substrate.
* Seeds never commit anything — Joshua reviews them during molting.
*/
export interface MemorySeed {
id: SeedId;
workspace_id: WorkspaceId;
subject: SubjectPath;
proposed_change: string;
target_file: string; // e.g. "long-term/boundaries.md"
based_on_marks: MarkId[];
planted_at: Date;
planted_by: string;
state: SeedState;
reviewed_at: Date | null;
created_at: Date;
updated_at: Date;
}
/** Input to the unified recall() query. */
export interface RecallInput {
about: SubjectPath;
horizon?: '1d' | '7d' | '30d' | 'all' | Date;
layers?: Array<'substrate' | 'marks' | 'daily' | 'compost'>;
min_strength?: number;
workspace_id: WorkspaceId;
}
/** Structured recall result — blended across all four layers. */
export interface RecallResult {
substrate: Array<{ file: string; excerpt: string; reason: string }>;
marks: Array<{
id: MarkId;
subject: SubjectPath;
observation: string;
current_strength: number;
observed_by: string;
first_observed_at: string;
last_reinforced_at: string;
evidence: EvidenceRefs;
}>;
daily: Array<{ date: string; excerpt: string; line_range: [number, number] }>;
compost: Array<{ template_id: string; learning: string; source_agent_count: number }>;
summary: null; // v1 stub
}
- [ ] Step 2: Export from index.ts
Replace packages/memory/src/index.ts with:
// @cambium/memory — four-layer memory substrate (substrate, marks, daily, compost)
export type {
MarkId,
MarkReinforcementId,
SeedId,
SubjectPath,
MarkState,
SeedState,
EvidenceRefs,
MemoryMark,
MemoryMarkReinforcement,
MemorySeed,
RecallInput,
RecallResult,
} from './types.js';
- [ ] Step 3: Verify typecheck
pnpm --filter @cambium/memory typecheck
Expected: exits 0.
- [ ] Step 4: Commit
git add packages/memory/src/types.ts packages/memory/src/index.ts
git commit -m "feat(memory): define core types (MemoryMark, MemorySeed, EvidenceRefs, RecallInput/Result)"
Task 3: Subject Prefix Matching
Pure TDD — no DB, no filesystem.
Files:
- Create: packages/memory/src/subject.ts
- Create: packages/memory/src/subject.test.ts
- [ ] Step 1: Write failing tests
Create packages/memory/src/subject.test.ts:
import { describe, it, expect } from 'vitest';
import { subjectMatches, normalizeSubject } from './subject.js';
describe('normalizeSubject', () => {
it('lowercases and trims', () => {
expect(normalizeSubject(' Joshua.Sleep ')).toBe('joshua.sleep');
});
it('collapses double dots', () => {
expect(normalizeSubject('joshua..sleep')).toBe('joshua.sleep');
});
it('strips trailing dots', () => {
expect(normalizeSubject('joshua.sleep.')).toBe('joshua.sleep');
});
});
describe('subjectMatches', () => {
it('exact match returns true', () => {
expect(subjectMatches('joshua.sleep', 'joshua.sleep')).toBe(true);
});
it('prefix match returns true on a segment boundary', () => {
expect(subjectMatches('joshua.sleep', 'joshua.sleep.bedtime')).toBe(true);
expect(subjectMatches('joshua', 'joshua.food.breakfast')).toBe(true);
});
it('partial segment match returns false', () => {
// "joshua.sleep" must NOT match "joshua.sleepover"
expect(subjectMatches('joshua.sleep', 'joshua.sleepover')).toBe(false);
});
it('non-matching root returns false', () => {
expect(subjectMatches('joshua.sleep', 'arnold.sleep')).toBe(false);
});
it('is case-insensitive via normalization', () => {
expect(subjectMatches('Joshua.Sleep', 'joshua.sleep.bedtime')).toBe(true);
});
it('empty prefix matches everything', () => {
expect(subjectMatches('', 'joshua.sleep')).toBe(true);
});
});
- [ ] Step 2: Run tests to verify they fail
pnpm --filter @cambium/memory test
Expected: tests fail on module-not-found.
- [ ] Step 3: Implement subject.ts
Create packages/memory/src/subject.ts:
import type { SubjectPath } from './types.js';
/** Lowercase, trim, collapse double dots, strip trailing dots. */
export function normalizeSubject(subject: string): SubjectPath {
return subject
.trim()
.toLowerCase()
.replace(/\.{2,}/g, '.')
.replace(/\.+$/, '');
}
/**
* True if `candidate` starts with `prefix` on a segment boundary.
* `"joshua.sleep"` matches `"joshua.sleep.bedtime"` but NOT `"joshua.sleepover"`.
*/
export function subjectMatches(prefix: string, candidate: string): boolean {
const p = normalizeSubject(prefix);
const c = normalizeSubject(candidate);
if (p === '') return true;
if (c === p) return true;
return c.startsWith(`${p}.`);
}
- [ ] Step 4: Run tests
pnpm --filter @cambium/memory test
Expected: all subject tests pass.
- [ ] Step 5: Export and commit
Add to packages/memory/src/index.ts:
export { subjectMatches, normalizeSubject } from './subject.js';
git add packages/memory/src/subject.ts packages/memory/src/subject.test.ts packages/memory/src/index.ts
git commit -m "feat(memory): segment-boundary subject prefix matching"
Task 4: Mark Strength Computation
Pure TDD — the decay math.
Files:
- Create: packages/memory/src/strength.ts
- Create: packages/memory/src/strength.test.ts
- [ ] Step 1: Write failing tests
Create packages/memory/src/strength.test.ts:
import { describe, it, expect } from 'vitest';
import { computeCurrentStrength, WITHER_THRESHOLD } from './strength.js';
import type { MemoryMark, MemoryMarkReinforcement, MarkId, MarkReinforcementId } from './types.js';
import type { WorkspaceId } from '@cambium/core';
function makeMark(overrides: Partial<MemoryMark> = {}): MemoryMark {
const t0 = new Date('2026-04-11T00:00:00Z');
return {
id: 'm-1' as MarkId,
workspace_id: 'obadiah' as WorkspaceId,
subject: 'joshua.sleep.bedtime',
observation: 'Joshua went to bed after midnight',
evidence: { daily_file: '2026-04-11.md', line_range: [3, 3] },
initial_strength: 1.0,
decay_rate: 7,
observed_by: 'obadiah',
first_observed_at: t0,
last_reinforced_at: t0,
reinforcement_count: 0,
affinity: ['sleep'],
substrate_refs: [],
state: 'active',
created_at: t0,
updated_at: t0,
...overrides,
};
}
describe('computeCurrentStrength', () => {
it('returns initial strength at t0', () => {
const mark = makeMark();
const s = computeCurrentStrength(mark, [], new Date('2026-04-11T00:00:00Z'));
expect(s).toBeCloseTo(1.0, 5);
});
it('halves after one half-life (7 days with decay_rate=7)', () => {
const mark = makeMark();
const s = computeCurrentStrength(mark, [], new Date('2026-04-18T00:00:00Z'));
expect(s).toBeCloseTo(0.5, 3);
});
it('quarters after two half-lives', () => {
const mark = makeMark();
const s = computeCurrentStrength(mark, [], new Date('2026-04-25T00:00:00Z'));
expect(s).toBeCloseTo(0.25, 3);
});
it('adds reinforcement deltas on top of decayed base', () => {
const mark = makeMark();
const reinforcements: MemoryMarkReinforcement[] = [
{
id: 'rf-1' as MarkReinforcementId,
mark_id: 'm-1' as MarkId,
reinforced_at: new Date('2026-04-18T00:00:00Z'),
reinforced_by: 'obadiah',
evidence: {},
strength_delta: 0.2,
},
];
const s = computeCurrentStrength(mark, reinforcements, new Date('2026-04-18T00:00:00Z'));
expect(s).toBeCloseTo(0.7, 3); // 0.5 + 0.2
});
it('clamps to [0, 1]', () => {
const mark = makeMark({ initial_strength: 0.9 });
const reinforcements: MemoryMarkReinforcement[] = [
{
id: 'rf-1' as MarkReinforcementId,
mark_id: 'm-1' as MarkId,
reinforced_at: new Date('2026-04-11T00:00:00Z'),
reinforced_by: 'obadiah',
evidence: {},
strength_delta: 0.5,
},
];
const s = computeCurrentStrength(mark, reinforcements, new Date('2026-04-11T00:00:00Z'));
expect(s).toBeCloseTo(1.0, 5);
});
it('WITHER_THRESHOLD is 0.1', () => {
expect(WITHER_THRESHOLD).toBe(0.1);
});
it('withers far-decayed marks below threshold', () => {
const mark = makeMark({ initial_strength: 0.3, decay_rate: 7 });
// 3 half-lives: 0.3 / 8 = 0.0375 < 0.1
const s = computeCurrentStrength(mark, [], new Date('2026-05-02T00:00:00Z'));
expect(s).toBeLessThan(WITHER_THRESHOLD);
});
});
- [ ] Step 2: Run tests to verify failure
pnpm --filter @cambium/memory test
Expected: failures on module-not-found.
- [ ] Step 3: Implement strength.ts
Create packages/memory/src/strength.ts:
import type { MemoryMark, MemoryMarkReinforcement } from './types.js';
/** Marks below this computed current_strength are considered withered. */
export const WITHER_THRESHOLD = 0.1;
const MS_PER_DAY = 1000 * 60 * 60 * 24;
/**
* current_strength = initial * 2^(-days_since_first_observed / decay_rate)
* + sum(reinforcement_deltas)
* clamped to [0, 1].
*
* Reinforcement deltas are added verbatim — not themselves decayed in v1.
* Per-reinforcement decay is a plausible v2 refinement.
*/
export function computeCurrentStrength(
mark: MemoryMark,
reinforcements: MemoryMarkReinforcement[],
now: Date,
): number {
const daysSince = Math.max(
0,
(now.getTime() - mark.first_observed_at.getTime()) / MS_PER_DAY,
);
const halfLives = mark.decay_rate > 0 ? daysSince / mark.decay_rate : 0;
const decayed = mark.initial_strength * Math.pow(2, -halfLives);
const reinforcementSum = reinforcements
.filter((r) => r.mark_id === mark.id)
.reduce((acc, r) => acc + r.strength_delta, 0);
const raw = decayed + reinforcementSum;
if (raw < 0) return 0;
if (raw > 1) return 1;
return raw;
}
- [ ] Step 4: Run tests
pnpm --filter @cambium/memory test
Expected: all strength tests pass.
- [ ] Step 5: Export and commit
Add to packages/memory/src/index.ts:
export { computeCurrentStrength, WITHER_THRESHOLD } from './strength.js';
git add packages/memory/src/strength.ts packages/memory/src/strength.test.ts packages/memory/src/index.ts
git commit -m "feat(memory): mark strength decay + reinforcement math"
Task 5: Add DB Schema for Memory
Files:
- Modify: packages/db/src/schema.ts (add memoryMarks, memoryMarkReinforcements, memorySeeds)
- Generated: packages/db/drizzle/XXXX_<name>.sql
- [ ] Step 1: Add table definitions to schema
Open packages/db/src/schema.ts. Find the rhythmFires table (around line 399) and append the following three tables after it (before // ===== Providers =====):
// ===== Memory — Marks, Reinforcements, Seeds =====
export const memoryMarks = pgTable('memory_marks', {
id: text('id').primaryKey(),
workspace_id: text('workspace_id').notNull(),
subject: text('subject').notNull(),
observation: text('observation').notNull(),
evidence: jsonb('evidence')
.notNull()
.$type<{
signal_ids?: string[];
daily_file?: string;
line_range?: [number, number];
compost_ids?: string[];
}>()
.default({}),
initial_strength: real('initial_strength').notNull(),
decay_rate: real('decay_rate').notNull(),
observed_by: text('observed_by').notNull(),
first_observed_at: timestamp('first_observed_at', { withTimezone: true }).notNull(),
last_reinforced_at: timestamp('last_reinforced_at', { withTimezone: true }).notNull(),
reinforcement_count: integer('reinforcement_count').notNull().default(0),
affinity: jsonb('affinity').notNull().$type<string[]>().default([]),
substrate_refs: jsonb('substrate_refs').notNull().$type<string[]>().default([]),
state: text('state').notNull().default('active'),
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updated_at: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_memory_marks_workspace').on(table.workspace_id),
index('idx_memory_marks_workspace_subject').on(table.workspace_id, table.subject),
index('idx_memory_marks_state').on(table.state),
index('idx_memory_marks_first_observed').on(table.first_observed_at),
]);
export const memoryMarkReinforcements = pgTable('memory_mark_reinforcements', {
id: text('id').primaryKey(),
mark_id: text('mark_id').notNull().references(() => memoryMarks.id),
reinforced_at: timestamp('reinforced_at', { withTimezone: true }).notNull().defaultNow(),
reinforced_by: text('reinforced_by').notNull(),
evidence: jsonb('evidence')
.notNull()
.$type<{
signal_ids?: string[];
daily_file?: string;
line_range?: [number, number];
compost_ids?: string[];
}>()
.default({}),
strength_delta: real('strength_delta').notNull(),
}, (table) => [
index('idx_memory_reinforcements_mark').on(table.mark_id),
index('idx_memory_reinforcements_reinforced_at').on(table.reinforced_at),
]);
export const memorySeeds = pgTable('memory_seeds', {
id: text('id').primaryKey(),
workspace_id: text('workspace_id').notNull(),
subject: text('subject').notNull(),
proposed_change: text('proposed_change').notNull(),
target_file: text('target_file').notNull(),
based_on_marks: jsonb('based_on_marks').notNull().$type<string[]>().default([]),
planted_at: timestamp('planted_at', { withTimezone: true }).notNull().defaultNow(),
planted_by: text('planted_by').notNull(),
state: text('state').notNull().default('pending'),
reviewed_at: timestamp('reviewed_at', { withTimezone: true }),
created_at: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updated_at: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('idx_memory_seeds_workspace').on(table.workspace_id),
index('idx_memory_seeds_state').on(table.state),
index('idx_memory_seeds_planted_at').on(table.planted_at),
]);
Verify that integer, index, jsonb, real, text, timestamp, and pgTable are already imported at the top of the file (they are, per the imports block lines 1-11 of schema.ts).
- [ ] Step 2: Generate the migration
Run from repo root:
pnpm --filter @cambium/db exec drizzle-kit generate
Expected: a new file appears in packages/db/drizzle/ named XXXX_<some_name>.sql containing CREATE TABLE "memory_marks" ..., CREATE TABLE "memory_mark_reinforcements" ..., and CREATE TABLE "memory_seeds" .... Inspect the generated SQL to confirm all three tables and their indexes are present.
- [ ] Step 3: Apply the migration
docker compose up -d # ensure Postgres is running
pnpm --filter @cambium/db migrate
Expected: migration applies with no errors. Verify the tables exist:
docker compose exec postgres psql -U cambium -d cambium -c "\dt memory_*"
Expected output lists memory_marks, memory_mark_reinforcements, memory_seeds.
- [ ] Step 4: Typecheck db package
pnpm --filter @cambium/db typecheck
Expected: exits 0.
- [ ] Step 5: Commit
git add packages/db/src/schema.ts packages/db/drizzle/
git commit -m "feat(db): add memory_marks, memory_mark_reinforcements, memory_seeds tables"
Task 6: MemoryStore Interface + InMemoryMemoryStore
Files:
- Create: packages/memory/src/memory-store.ts
- Create: packages/memory/src/memory-store.test.ts
- [ ] Step 1: Write failing tests
Create packages/memory/src/memory-store.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryMemoryStore } from './memory-store.js';
import type { MemoryMark, MemorySeed, MarkId, SeedId } from './types.js';
import type { WorkspaceId } from '@cambium/core';
const workspace = 'obadiah' as WorkspaceId;
function makeMark(overrides: Partial<MemoryMark> = {}): MemoryMark {
const t0 = new Date('2026-04-11T00:00:00Z');
return {
id: 'm-1' as MarkId,
workspace_id: workspace,
subject: 'joshua.sleep.bedtime',
observation: 'Joshua stayed up late',
evidence: { daily_file: '2026-04-11.md', line_range: [3, 3] },
initial_strength: 1.0,
decay_rate: 7,
observed_by: 'obadiah',
first_observed_at: t0,
last_reinforced_at: t0,
reinforcement_count: 0,
affinity: ['sleep'],
substrate_refs: [],
state: 'active',
created_at: t0,
updated_at: t0,
...overrides,
};
}
describe('InMemoryMemoryStore — marks', () => {
let store: InMemoryMemoryStore;
beforeEach(() => { store = new InMemoryMemoryStore(); });
it('insertMark persists and findMarkById retrieves', async () => {
await store.insertMark(makeMark());
const m = await store.findMarkById('m-1' as MarkId);
expect(m?.subject).toBe('joshua.sleep.bedtime');
});
it('findMarksBySubjectPrefix returns segment-boundary matches', async () => {
await store.insertMark(makeMark({ id: 'm-a' as MarkId, subject: 'joshua.sleep.bedtime' }));
await store.insertMark(makeMark({ id: 'm-b' as MarkId, subject: 'joshua.sleep.wake' }));
await store.insertMark(makeMark({ id: 'm-c' as MarkId, subject: 'joshua.food.breakfast' }));
await store.insertMark(makeMark({ id: 'm-d' as MarkId, subject: 'joshua.sleepover' }));
const result = await store.findMarksBySubjectPrefix(workspace, 'joshua.sleep');
const ids = result.map(m => m.id).sort();
expect(ids).toEqual(['m-a', 'm-b']);
});
it('filters by state (active only by default)', async () => {
await store.insertMark(makeMark({ id: 'm-a' as MarkId, state: 'active' }));
await store.insertMark(makeMark({ id: 'm-b' as MarkId, state: 'withered' }));
const active = await store.findMarksBySubjectPrefix(workspace, 'joshua');
expect(active.map(m => m.id)).toEqual(['m-a']);
});
it('insertReinforcement links to a mark and updates reinforcement_count', async () => {
await store.insertMark(makeMark());
await store.insertReinforcement({
mark_id: 'm-1' as MarkId,
reinforced_by: 'obadiah',
reinforced_at: new Date('2026-04-12T00:00:00Z'),
evidence: { daily_file: '2026-04-12.md', line_range: [5, 5] },
strength_delta: 0.2,
});
const mark = await store.findMarkById('m-1' as MarkId);
expect(mark?.reinforcement_count).toBe(1);
expect(mark?.last_reinforced_at.toISOString()).toBe('2026-04-12T00:00:00.000Z');
const rfs = await store.findReinforcementsForMark('m-1' as MarkId);
expect(rfs).toHaveLength(1);
expect(rfs[0].strength_delta).toBe(0.2);
});
it('updateMarkState transitions a mark', async () => {
await store.insertMark(makeMark());
await store.updateMarkState('m-1' as MarkId, 'withered');
const m = await store.findMarkById('m-1' as MarkId);
expect(m?.state).toBe('withered');
});
});
describe('InMemoryMemoryStore — seeds', () => {
let store: InMemoryMemoryStore;
beforeEach(() => { store = new InMemoryMemoryStore(); });
function makeSeed(overrides: Partial<MemorySeed> = {}): MemorySeed {
const t0 = new Date('2026-04-11T00:00:00Z');
return {
id: 's-1' as SeedId,
workspace_id: workspace,
subject: 'joshua.food.vegetables',
proposed_change: 'add to long-term/lessons.md: Arnold recommends 180°C, 20 min for vegetables',
target_file: 'long-term/lessons.md',
based_on_marks: ['m-1' as MarkId],
planted_at: t0,
planted_by: 'obadiah',
state: 'pending',
reviewed_at: null,
created_at: t0,
updated_at: t0,
...overrides,
};
}
it('insertSeed persists and findSeedsByWorkspace retrieves', async () => {
await store.insertSeed(makeSeed());
const seeds = await store.findSeedsByWorkspace(workspace);
expect(seeds).toHaveLength(1);
expect(seeds[0].id).toBe('s-1');
});
it('findSeedsByWorkspace filters by state', async () => {
await store.insertSeed(makeSeed({ id: 's-a' as SeedId, state: 'pending' }));
await store.insertSeed(makeSeed({ id: 's-b' as SeedId, state: 'accepted' }));
const pending = await store.findSeedsByWorkspace(workspace, 'pending');
expect(pending.map(s => s.id)).toEqual(['s-a']);
});
it('updateSeedState transitions a seed and sets reviewed_at', async () => {
await store.insertSeed(makeSeed());
const reviewedAt = new Date('2026-04-15T00:00:00Z');
await store.updateSeedState('s-1' as SeedId, 'accepted', reviewedAt);
const seeds = await store.findSeedsByWorkspace(workspace);
expect(seeds[0].state).toBe('accepted');
expect(seeds[0].reviewed_at?.toISOString()).toBe('2026-04-15T00:00:00.000Z');
});
});
- [ ] Step 2: Run tests to verify failure
pnpm --filter @cambium/memory test
Expected: failures on module-not-found.
- [ ] Step 3: Implement memory-store.ts
Create packages/memory/src/memory-store.ts:
import type { WorkspaceId } from '@cambium/core';
import type {
MemoryMark,
MemoryMarkReinforcement,
MemorySeed,
MarkId,
MarkReinforcementId,
MarkState,
SeedId,
SeedState,
EvidenceRefs,
} from './types.js';
import { subjectMatches } from './subject.js';
export interface InsertReinforcementInput {
mark_id: MarkId;
reinforced_by: string;
reinforced_at: Date;
evidence: EvidenceRefs;
strength_delta: number;
}
export interface MemoryStore {
insertMark(mark: MemoryMark): Promise<void>;
findMarkById(id: MarkId): Promise<MemoryMark | null>;
findMarksBySubjectPrefix(
workspace: WorkspaceId,
prefix: string,
opts?: { includeStates?: MarkState[] },
): Promise<MemoryMark[]>;
updateMarkState(id: MarkId, state: MarkState): Promise<void>;
insertReinforcement(input: InsertReinforcementInput): Promise<MemoryMarkReinforcement>;
findReinforcementsForMark(id: MarkId): Promise<MemoryMarkReinforcement[]>;
insertSeed(seed: MemorySeed): Promise<void>;
findSeedsByWorkspace(workspace: WorkspaceId, state?: SeedState): Promise<MemorySeed[]>;
updateSeedState(id: SeedId, state: SeedState, reviewedAt: Date): Promise<void>;
}
/**
* In-memory implementation. Used for unit tests and for v1 Obadiah wiring.
* A Drizzle-backed PgMemoryStore is a follow-up (schema is already in place).
*/
export class InMemoryMemoryStore implements MemoryStore {
private marks = new Map<MarkId, MemoryMark>();
private reinforcements = new Map<MarkReinforcementId, MemoryMarkReinforcement>();
private seeds = new Map<SeedId, MemorySeed>();
private reinforcementCounter = 0;
async insertMark(mark: MemoryMark): Promise<void> {
this.marks.set(mark.id, { ...mark });
}
async findMarkById(id: MarkId): Promise<MemoryMark | null> {
const m = this.marks.get(id);
return m ? { ...m } : null;
}
async findMarksBySubjectPrefix(
workspace: WorkspaceId,
prefix: string,
opts: { includeStates?: MarkState[] } = {},
): Promise<MemoryMark[]> {
const states = opts.includeStates ?? ['active'];
return Array.from(this.marks.values())
.filter(
(m) =>
m.workspace_id === workspace &&
states.includes(m.state) &&
subjectMatches(prefix, m.subject),
)
.map((m) => ({ ...m }));
}
async updateMarkState(id: MarkId, state: MarkState): Promise<void> {
const m = this.marks.get(id);
if (!m) return;
this.marks.set(id, { ...m, state, updated_at: new Date() });
}
async insertReinforcement(input: InsertReinforcementInput): Promise<MemoryMarkReinforcement> {
this.reinforcementCounter += 1;
const rf: MemoryMarkReinforcement = {
id: `rf-${this.reinforcementCounter}` as MarkReinforcementId,
mark_id: input.mark_id,
reinforced_at: input.reinforced_at,
reinforced_by: input.reinforced_by,
evidence: input.evidence,
strength_delta: input.strength_delta,
};
this.reinforcements.set(rf.id, rf);
const mark = this.marks.get(input.mark_id);
if (mark) {
this.marks.set(input.mark_id, {
...mark,
reinforcement_count: mark.reinforcement_count + 1,
last_reinforced_at: input.reinforced_at,
updated_at: new Date(),
});
}
return rf;
}
async findReinforcementsForMark(id: MarkId): Promise<MemoryMarkReinforcement[]> {
return Array.from(this.reinforcements.values())
.filter((r) => r.mark_id === id)
.map((r) => ({ ...r }));
}
async insertSeed(seed: MemorySeed): Promise<void> {
this.seeds.set(seed.id, { ...seed });
}
async findSeedsByWorkspace(workspace: WorkspaceId, state?: SeedState): Promise<MemorySeed[]> {
return Array.from(this.seeds.values())
.filter((s) => s.workspace_id === workspace && (state === undefined || s.state === state))
.map((s) => ({ ...s }));
}
async updateSeedState(id: SeedId, state: SeedState, reviewedAt: Date): Promise<void> {
const s = this.seeds.get(id);
if (!s) return;
this.seeds.set(id, { ...s, state, reviewed_at: reviewedAt, updated_at: new Date() });
}
}
- [ ] Step 4: Run tests
pnpm --filter @cambium/memory test
Expected: all memory-store tests pass.
- [ ] Step 5: Export and commit
Add to packages/memory/src/index.ts:
export {
InMemoryMemoryStore,
type MemoryStore,
type InsertReinforcementInput,
} from './memory-store.js';
git add packages/memory/src/memory-store.ts packages/memory/src/memory-store.test.ts packages/memory/src/index.ts
git commit -m "feat(memory): MemoryStore interface + InMemoryMemoryStore (marks, reinforcements, seeds)"
Task 7: Substrate Reader
Files:
- Create: packages/memory/src/substrate-reader.ts
- Create: packages/memory/src/substrate-reader.test.ts
- Create: packages/memory/test/fixtures/long-term/identity.md
- Create: packages/memory/test/fixtures/long-term/workflow.md
- [ ] Step 1: Create test fixtures
Create packages/memory/test/fixtures/long-term/identity.md:
# Identity
I am Joshua. I build Cambium, a nature-based orchestration platform.
I value my focus time in the afternoons and try to protect it.
Create packages/memory/test/fixtures/long-term/workflow.md:
# Workflow
I prefer to do deep work before lunch.
Meetings are batched on Tuesdays and Thursdays.
- [ ] Step 2: Write failing tests
Create packages/memory/src/substrate-reader.test.ts:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { SubstrateReader } from './substrate-reader.js';
describe('SubstrateReader', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'memory-substrate-'));
mkdirSync(join(dir, 'long-term'), { recursive: true });
writeFileSync(
join(dir, 'long-term', 'identity.md'),
'# Identity\n\nI protect afternoons for focus work.\n',
);
writeFileSync(
join(dir, 'long-term', 'workflow.md'),
'# Workflow\n\nDeep work before lunch.\nMeetings Tuesday and Thursday.\n',
);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('loads all long-term/*.md files into parsed entries', async () => {
const reader = new SubstrateReader(dir);
const entries = await reader.loadAll();
const files = entries.map(e => e.file).sort();
expect(files).toEqual(['long-term/identity.md', 'long-term/workflow.md']);
});
it('findRelevant returns entries whose content contains any matching token', async () => {
const reader = new SubstrateReader(dir);
await reader.loadAll();
const hits = await reader.findRelevant('focus');
expect(hits).toHaveLength(1);
expect(hits[0].file).toBe('long-term/identity.md');
expect(hits[0].excerpt).toContain('focus');
expect(hits[0].reason).toContain('focus');
});
it('findRelevant uses dot-segments from a subject as search tokens', async () => {
const reader = new SubstrateReader(dir);
await reader.loadAll();
// subject "joshua.workflow.meetings" — "meetings" is a token
const hits = await reader.findRelevant('joshua.workflow.meetings');
expect(hits.some(h => h.file === 'long-term/workflow.md')).toBe(true);
});
it('invalidates cache when a file changes', async () => {
const reader = new SubstrateReader(dir);
await reader.loadAll();
const before = await reader.findRelevant('lunch');
expect(before.some(h => h.excerpt.includes('Deep work before lunch'))).toBe(true);
writeFileSync(
join(dir, 'long-term', 'workflow.md'),
'# Workflow\n\nAll-day deep work.\n',
);
reader.invalidate();
await reader.loadAll();
const after = await reader.findRelevant('lunch');
expect(after).toHaveLength(0);
});
it('gracefully returns empty list if long-term/ does not exist', async () => {
const emptyDir = mkdtempSync(join(tmpdir(), 'memory-empty-'));
try {
const reader = new SubstrateReader(emptyDir);
const entries = await reader.loadAll();
expect(entries).toEqual([]);
const hits = await reader.findRelevant('anything');
expect(hits).toEqual([]);
} finally {
rmSync(emptyDir, { recursive: true, force: true });
}
});
});
- [ ] Step 3: Run tests to verify failure
pnpm --filter @cambium/memory test
Expected: failures on module-not-found.
- [ ] Step 4: Implement substrate-reader.ts
Create packages/memory/src/substrate-reader.ts:
import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { normalizeSubject } from './subject.js';
export interface SubstrateEntry {
file: string; // "long-term/identity.md"
content: string;
}
export interface SubstrateHit {
file: string;
excerpt: string;
reason: string;
}
/**
* Reads Joshua's long-term substrate files. Never writes. Cache invalidated
* manually via invalidate() (file-watcher wiring is a Task-15 concern).
*/
export class SubstrateReader {
private cache: SubstrateEntry[] | null = null;
constructor(private readonly rootDir: string) {}
invalidate(): void {
this.cache = null;
}
async loadAll(): Promise<SubstrateEntry[]> {
if (this.cache !== null) return this.cache;
const longTermDir = join(this.rootDir, 'long-term');
let entries: string[];
try {
const s = await stat(longTermDir);
if (!s.isDirectory()) {
this.cache = [];
return this.cache;
}
entries = await readdir(longTermDir);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
this.cache = [];
return this.cache;
}
throw err;
}
const out: SubstrateEntry[] = [];
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
const filepath = join(longTermDir, entry);
const content = await readFile(filepath, 'utf8');
out.push({ file: `long-term/${entry}`, content });
}
out.sort((a, b) => a.file.localeCompare(b.file));
this.cache = out;
return this.cache;
}
/**
* Find substrate entries relevant to a subject or free text. Tokenizes the
* query on dots and whitespace; a hit is any entry containing at least one
* non-trivial token as a case-insensitive substring.
*/
async findRelevant(query: string): Promise<SubstrateHit[]> {
const entries = await this.loadAll();
const tokens = tokenize(query);
if (tokens.length === 0) return [];
const hits: SubstrateHit[] = [];
for (const entry of entries) {
const lower = entry.content.toLowerCase();
const matched = tokens.filter((t) => lower.includes(t));
if (matched.length === 0) continue;
const excerpt = extractExcerpt(entry.content, matched[0]);
hits.push({
file: entry.file,
excerpt,
reason: `matched tokens: ${matched.join(', ')}`,
});
}
return hits;
}
}
function tokenize(query: string): string[] {
const normalized = normalizeSubject(query);
const parts = normalized.split(/[.\s]+/).filter((p) => p.length >= 3);
// Also include the full normalized query if it looks like free text
if (!normalized.includes('.') && normalized.length >= 3 && !parts.includes(normalized)) {
parts.push(normalized);
}
return Array.from(new Set(parts));
}
function extractExcerpt(content: string, token: string): string {
const lines = content.split('\n');
const lower = token.toLowerCase();
const lineIdx = lines.findIndex((l) => l.toLowerCase().includes(lower));
if (lineIdx === -1) return lines.slice(0, 3).join('\n').trim();
const start = Math.max(0, lineIdx - 1);
const end = Math.min(lines.length, lineIdx + 2);
return lines.slice(start, end).join('\n').trim();
}
- [ ] Step 5: Run tests
pnpm --filter @cambium/memory test
Expected: all substrate-reader tests pass.
- [ ] Step 6: Export and commit
Add to packages/memory/src/index.ts:
export { SubstrateReader, type SubstrateEntry, type SubstrateHit } from './substrate-reader.js';
git add packages/memory/src/substrate-reader.ts packages/memory/src/substrate-reader.test.ts packages/memory/test/fixtures/ packages/memory/src/index.ts
git commit -m "feat(memory): SubstrateReader — read-only long-term/*.md with token-based findRelevant"
Task 8: Daily Reader / Appender
Files:
- Create: packages/memory/src/daily-reader.ts
- Create: packages/memory/src/daily-reader.test.ts
- [ ] Step 1: Write failing tests
Create packages/memory/src/daily-reader.test.ts:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { DailyReader } from './daily-reader.js';
describe('DailyReader', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'memory-daily-'));
mkdirSync(join(dir, 'daily'), { recursive: true });
mkdirSync(join(dir, 'daily', 'archive'), { recursive: true });
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('readDay returns entries with 1-indexed line numbers', async () => {
writeFileSync(
join(dir, 'daily', '2026-04-11.md'),
'Woke up at 7am.\nArnold recommended cooking vegetables 20 min at 180°C.\nDeep work on Cambium.\n',
);
const reader = new DailyReader(dir);
const day = await reader.readDay('2026-04-11');
expect(day).not.toBeNull();
expect(day!.date).toBe('2026-04-11');
expect(day!.lines).toHaveLength(3);
expect(day!.lines[1]).toBe('Arnold recommended cooking vegetables 20 min at 180°C.');
});
it('readDay returns null for a missing day', async () => {
const reader = new DailyReader(dir);
const day = await reader.readDay('2026-04-11');
expect(day).toBeNull();
});
it('appendLine creates the file if it does not exist and appends a newline', async () => {
const reader = new DailyReader(dir);
await reader.appendLine('2026-04-11', 'Obadiah logged a completed action.');
const raw = readFileSync(join(dir, 'daily', '2026-04-11.md'), 'utf8');
expect(raw).toBe('Obadiah logged a completed action.\n');
});
it('appendLine appends to an existing file preserving prior content', async () => {
writeFileSync(join(dir, 'daily', '2026-04-11.md'), 'First line.\n');
const reader = new DailyReader(dir);
await reader.appendLine('2026-04-11', 'Second line.');
const raw = readFileSync(join(dir, 'daily', '2026-04-11.md'), 'utf8');
expect(raw).toBe('First line.\nSecond line.\n');
});
it('archive moves daily/YYYY-MM-DD.md to daily/archive/', async () => {
writeFileSync(join(dir, 'daily', '2026-04-10.md'), 'Yesterday content.\n');
const reader = new DailyReader(dir);
await reader.archive('2026-04-10');
expect(existsSync(join(dir, 'daily', '2026-04-10.md'))).toBe(false);
expect(existsSync(join(dir, 'daily', 'archive', '2026-04-10.md'))).toBe(true);
const archived = readFileSync(join(dir, 'daily', 'archive', '2026-04-10.md'), 'utf8');
expect(archived).toBe('Yesterday content.\n');
});
it('readDay respects archive flag to read from daily/archive/', async () => {
writeFileSync(join(dir, 'daily', 'archive', '2026-04-01.md'), 'Old day.\n');
const reader = new DailyReader(dir);
const day = await reader.readDay('2026-04-01', { fromArchive: true });
expect(day?.lines[0]).toBe('Old day.');
});
it('findRelevantLines returns matching lines with 1-indexed line ranges', async () => {
writeFileSync(
join(dir, 'daily', '2026-04-11.md'),
'Woke up.\nArnold cooked vegetables at 180C.\nDeep work.\n',
);
const reader = new DailyReader(dir);
const hits = await reader.findRelevantLines('2026-04-11', ['vegetables']);
expect(hits).toHaveLength(1);
expect(hits[0].line_range).toEqual([2, 2]);
expect(hits[0].excerpt).toContain('vegetables');
});
});
- [ ] Step 2: Run tests to verify failure
pnpm --filter @cambium/memory test
Expected: failures on module-not-found.
- [ ] Step 3: Implement daily-reader.ts
Create packages/memory/src/daily-reader.ts:
import { readFile, writeFile, rename, mkdir, stat, appendFile } from 'node:fs/promises';
import { join, dirname } from 'node:path';
export interface DailyDay {
date: string; // "YYYY-MM-DD"
lines: string[]; // 0-indexed internally, 1-indexed externally
raw: string;
}
export interface DailyHit {
date: string;
excerpt: string;
line_range: [number, number]; // 1-indexed, inclusive
}
export interface ReadDayOptions {
fromArchive?: boolean;
}
/**
* Reads/writes daily files at <rootDir>/daily/YYYY-MM-DD.md.
* Appends are idempotent with respect to file creation. Archive moves the
* file to <rootDir>/daily/archive/.
*/
export class DailyReader {
constructor(private readonly rootDir: string) {}
private pathFor(date: string, fromArchive = false): string {
return fromArchive
? join(this.rootDir, 'daily', 'archive', `${date}.md`)
: join(this.rootDir, 'daily', `${date}.md`);
}
async readDay(date: string, opts: ReadDayOptions = {}): Promise<DailyDay | null> {
const filepath = this.pathFor(date, opts.fromArchive ?? false);
let raw: string;
try {
raw = await readFile(filepath, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw err;
}
const lines = raw.split('\n');
// Trailing newline creates an empty last element — drop it.
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return { date, lines, raw };
}
async appendLine(date: string, line: string): Promise<void> {
const filepath = this.pathFor(date);
await mkdir(dirname(filepath), { recursive: true });
try {
await stat(filepath);
await appendFile(filepath, `${line}\n`, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
await writeFile(filepath, `${line}\n`, 'utf8');
return;
}
throw err;
}
}
async archive(date: string): Promise<void> {
const src = this.pathFor(date);
const dst = this.pathFor(date, true);
await mkdir(dirname(dst), { recursive: true });
await rename(src, dst);
}
/**
* Return lines that contain any of the given lowercase tokens (substring match).
* Adjacent matching lines are merged into a single hit with a [start, end] range.
*/
async findRelevantLines(
date: string,
tokens: string[],
opts: ReadDayOptions = {},
): Promise<DailyHit[]> {
const day = await this.readDay(date, opts);
if (!day) return [];
const hits: DailyHit[] = [];
const lowerTokens = tokens.map((t) => t.toLowerCase()).filter((t) => t.length >= 3);
if (lowerTokens.length === 0) return [];
let start: number | null = null;
for (let i = 0; i < day.lines.length; i++) {
const line = day.lines[i];
const lower = line.toLowerCase();
const hit = lowerTokens.some((t) => lower.includes(t));
if (hit) {
if (start === null) start = i;
} else if (start !== null) {
hits.push({
date,
excerpt: day.lines.slice(start, i).join('\n'),
line_range: [start + 1, i],
});
start = null;
}
}
if (start !== null) {
hits.push({
date,
excerpt: day.lines.slice(start).join('\n'),
line_range: [start + 1, day.lines.length],
});
}
return hits;
}
}
- [ ] Step 4: Run tests
pnpm --filter @cambium/memory test
Expected: all daily-reader tests pass.
- [ ] Step 5: Export and commit
Add to packages/memory/src/index.ts:
export { DailyReader, type DailyDay, type DailyHit, type ReadDayOptions } from './daily-reader.js';
git add packages/memory/src/daily-reader.ts packages/memory/src/daily-reader.test.ts packages/memory/src/index.ts
git commit -m "feat(memory): DailyReader — read/append/archive daily files with 1-indexed line ranges"
Task 9: Compost Reader
Wraps the real CompostingProcess from @cambium/genesis. API shape found during exploration:
CompostingProcess.getTemplateCompost(templateId: string): Promise<CompostRecord[]>CompostRecordhas fields:id,agent_id,template_id,success_rate,failure_patterns[] ({pattern, frequency, context}),successful_strategies: string[],template_improvements: string[],routing_hints[]. Nosubjectoraffinityfield, so matching against a recall subject is necessarily text-based.
The reader takes a list of template IDs to scan (the caller is expected to know which templates are relevant to the workspace — in v1 Obadiah wiring, we’ll pass [] which short-circuits to an empty list; a later task can scan all templates via TemplateRepository).
Files:
- Create: packages/memory/src/compost-reader.ts
- Create: packages/memory/src/compost-reader.test.ts
- [ ] Step 1: Write failing tests
Create packages/memory/src/compost-reader.test.ts:
import { describe, it, expect } from 'vitest';
import { CompostReader, type CompostSource } from './compost-reader.js';
import type { CompostRecord, CompostRecordId, AgentId, AgentTemplateId } from '@cambium/core';
function makeRecord(overrides: Partial<CompostRecord> = {}): CompostRecord {
return {
id: 'cmp-1' as CompostRecordId,
agent_id: 'a-1' as AgentId,
template_id: 'tpl-morning-brief' as AgentTemplateId,
total_tasks: 10,
success_rate: 0.9,
avg_latency_ms: 1200,
total_token_burn: 50000,
lifetime_ms: 86400000,
failure_patterns: [],
successful_strategies: ['Using early morning slot for deep work'],
tool_effectiveness: {},
capability_accuracy: {},
template_improvements: ['Consider adding vegetable cooking tips'],
routing_hints: [],
composted_at: '2026-04-01T00:00:00Z',
reason: 'end of lifetime',
...overrides,
};
}
function makeSource(records: CompostRecord[]): CompostSource {
return {
async listRecordsForTemplates(_templateIds: string[]): Promise<CompostRecord[]> {
return records;
},
};
}
describe('CompostReader', () => {
it('returns empty list when no templates given', async () => {
const reader = new CompostReader(makeSource([]));
const hits = await reader.findRelevant('joshua.food', []);
expect(hits).toEqual([]);
});
it('matches a recall token against successful_strategies', async () => {
const reader = new CompostReader(
makeSource([
makeRecord({
id: 'cmp-a' as CompostRecordId,
successful_strategies: ['Cooking vegetables at 180C produces reliable results'],
}),
]),
);
const hits = await reader.findRelevant('joshua.food.vegetables', ['tpl-morning-brief']);
expect(hits).toHaveLength(1);
expect(hits[0].learning).toContain('vegetables');
expect(hits[0].template_id).toBe('tpl-morning-brief');
});
it('matches against template_improvements and failure_patterns', async () => {
const reader = new CompostReader(
makeSource([
makeRecord({
id: 'cmp-b' as CompostRecordId,
template_improvements: ['Add vegetable timing notes'],
}),
makeRecord({
id: 'cmp-c' as CompostRecordId,
failure_patterns: [
{ pattern: 'confused about vegetable timing', frequency: 3, context: {} },
],
}),
]),
);
const hits = await reader.findRelevant('joshua.food.vegetables', ['tpl-morning-brief']);
expect(hits.length).toBeGreaterThanOrEqual(2);
});
it('aggregates source_agent_count across matching records with the same learning text', async () => {
const reader = new CompostReader(
makeSource([
makeRecord({ id: 'cmp-x' as CompostRecordId, agent_id: 'a-1' as AgentId, successful_strategies: ['Vegetable timing rule'] }),
makeRecord({ id: 'cmp-y' as CompostRecordId, agent_id: 'a-2' as AgentId, successful_strategies: ['Vegetable timing rule'] }),
]),
);
const hits = await reader.findRelevant('vegetables', ['tpl-morning-brief']);
const match = hits.find((h) => h.learning === 'Vegetable timing rule');
expect(match?.source_agent_count).toBe(2);
});
});
- [ ] Step 2: Run tests to verify failure
pnpm --filter @cambium/memory test
Expected: failures on module-not-found.
- [ ] Step 3: Implement compost-reader.ts
Create packages/memory/src/compost-reader.ts:
import type { CompostRecord } from '@cambium/core';
import { normalizeSubject } from './subject.js';
/**
* The minimal shape of a compost source the reader needs. In production this
* is backed by @cambium/genesis's CompostingProcess via a thin adapter:
*
* const source: CompostSource = {
* async listRecordsForTemplates(ids) {
* const all: CompostRecord[] = [];
* for (const id of ids) all.push(...(await compostingProcess.getTemplateCompost(id)));
* return all;
* }
* };
*
* Abstracted so the reader can be unit-tested without spinning up genesis.
*/
export interface CompostSource {
listRecordsForTemplates(templateIds: string[]): Promise<CompostRecord[]>;
}
export interface CompostHit {
template_id: string;
learning: string;
source_agent_count: number;
}
export class CompostReader {
constructor(private readonly source: CompostSource) {}
/**
* Find compost learnings relevant to a subject by substring-matching tokens
* against successful_strategies, template_improvements, and failure_patterns.
*
* Records are grouped by (template_id, learning text), and source_agent_count
* counts the number of distinct agents contributing that learning.
*/
async findRelevant(subjectOrQuery: string, templateIds: string[]): Promise<CompostHit[]> {
if (templateIds.length === 0) return [];
const tokens = tokenize(subjectOrQuery);
if (tokens.length === 0) return [];
const records = await this.source.listRecordsForTemplates(templateIds);
type Key = string;
const groups = new Map<Key, { template_id: string; learning: string; agents: Set<string> }>();
const addHit = (record: CompostRecord, learning: string) => {
const lower = learning.toLowerCase();
if (!tokens.some((t) => lower.includes(t))) return;
const key = `${record.template_id}::${learning}`;
const existing = groups.get(key);
if (existing) {
existing.agents.add(record.agent_id);
} else {
groups.set(key, {
template_id: record.template_id,
learning,
agents: new Set([record.agent_id]),
});
}
};
for (const record of records) {
for (const s of record.successful_strategies) addHit(record, s);
for (const i of record.template_improvements) addHit(record, i);
for (const fp of record.failure_patterns) addHit(record, fp.pattern);
}
return Array.from(groups.values()).map((g) => ({
template_id: g.template_id,
learning: g.learning,
source_agent_count: g.agents.size,
}));
}
}
function tokenize(query: string): string[] {
const normalized = normalizeSubject(query);
const parts = normalized.split(/[.\s]+/).filter((p) => p.length >= 3);
if (!normalized.includes('.') && normalized.length >= 3 && !parts.includes(normalized)) {
parts.push(normalized);
}
return Array.from(new Set(parts));
}
- [ ] Step 4: Run tests
pnpm --filter @cambium/memory test
Expected: all compost-reader tests pass.
- [ ] Step 5: Export and commit
Add to packages/memory/src/index.ts:
export { CompostReader, type CompostSource, type CompostHit } from './compost-reader.js';
git add packages/memory/src/compost-reader.ts packages/memory/src/compost-reader.test.ts packages/memory/src/index.ts
git commit -m "feat(memory): CompostReader — text-token matching against CompostRecord fields"
Task 10: recall() — The Blended Query
Files:
- Create: packages/memory/src/recall.ts
- Create: packages/memory/src/recall.test.ts
- [ ] Step 1: Write failing tests
Create packages/memory/src/recall.test.ts:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { recall } from './recall.js';
import { InMemoryMemoryStore } from './memory-store.js';
import { SubstrateReader } from './substrate-reader.js';
import { DailyReader } from './daily-reader.js';
import { CompostReader, type CompostSource } from './compost-reader.js';
import type { MemoryMark, MarkId } from './types.js';
import type { WorkspaceId } from '@cambium/core';
const workspace = 'obadiah' as WorkspaceId;
describe('recall', () => {
let dir: string;
let store: InMemoryMemoryStore;
let substrate: SubstrateReader;
let daily: DailyReader;
let compost: CompostReader;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'memory-recall-'));
mkdirSync(join(dir, 'long-term'), { recursive: true });
mkdirSync(join(dir, 'daily'), { recursive: true });
writeFileSync(
join(dir, 'long-term', 'lessons.md'),
'# Lessons\n\nArnold knows how to cook vegetables properly.\n',
);
writeFileSync(
join(dir, 'daily', '2026-04-11.md'),
'Morning pages.\nArnold recommended cooking vegetables 20 min at 180C.\nDeep work.\n',
);
store = new InMemoryMemoryStore();
substrate = new SubstrateReader(dir);
daily = new DailyReader(dir);
const emptySource: CompostSource = {
async listRecordsForTemplates() { return []; },
};
compost = new CompostReader(emptySource);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
function makeMark(overrides: Partial<MemoryMark> = {}): MemoryMark {
const t0 = new Date('2026-04-11T00:00:00Z');
return {
id: 'm-1' as MarkId,
workspace_id: workspace,
subject: 'joshua.food.vegetables',
observation: 'Arnold recommended 180C 20 min',
evidence: { daily_file: '2026-04-11.md', line_range: [2, 2] },
initial_strength: 1.0,
decay_rate: 7,
observed_by: 'obadiah',
first_observed_at: t0,
last_reinforced_at: t0,
reinforcement_count: 0,
affinity: ['food'],
substrate_refs: ['long-term/lessons.md'],
state: 'active',
created_at: t0,
updated_at: t0,
...overrides,
};
}
it('returns a structured RecallResult with all four layers', async () => {
await store.insertMark(makeMark());
const result = await recall(
{ about: 'joshua.food', workspace_id: workspace },
{
store,
substrate,
daily,
compost,
compostTemplateIds: [],
dailyDates: ['2026-04-11'],
now: new Date('2026-04-11T10:00:00Z'),
},
);
expect(result.summary).toBeNull();
expect(result.substrate.some((s) => s.file === 'long-term/lessons.md')).toBe(true);
expect(result.marks).toHaveLength(1);
expect(result.marks[0].subject).toBe('joshua.food.vegetables');
expect(result.marks[0].current_strength).toBeCloseTo(1.0, 3);
expect(result.daily.some((d) => d.excerpt.includes('vegetables'))).toBe(true);
expect(result.compost).toEqual([]);
});
it('respects the layers filter', async () => {
await store.insertMark(makeMark());
const result = await recall(
{ about: 'joshua.food', workspace_id: workspace, layers: ['marks'] },
{
store,
substrate,
daily,
compost,
compostTemplateIds: [],
dailyDates: ['2026-04-11'],
now: new Date('2026-04-11T10:00:00Z'),
},
);
expect(result.marks).toHaveLength(1);
expect(result.substrate).toEqual([]);
expect(result.daily).toEqual([]);
expect(result.compost).toEqual([]);
});
it('filters marks below min_strength', async () => {
// A mark from 21 days ago with decay_rate 7 → 3 half-lives → 0.125
await store.insertMark(
makeMark({
id: 'm-old' as MarkId,
first_observed_at: new Date('2026-03-21T00:00:00Z'),
}),
);
const result = await recall(
{ about: 'joshua.food', workspace_id: workspace, min_strength: 0.2 },
{
store,
substrate,
daily,
compost,
compostTemplateIds: [],
dailyDates: ['2026-04-11'],
now: new Date('2026-04-11T00:00:00Z'),
},
);
expect(result.marks).toEqual([]);
});
it('returns marks sorted by current_strength descending', async () => {
await store.insertMark(makeMark({ id: 'm-fresh' as MarkId, first_observed_at: new Date('2026-04-11T00:00:00Z') }));
await store.insertMark(
makeMark({
id: 'm-old' as MarkId,
first_observed_at: new Date('2026-04-04T00:00:00Z'), // 7 days → strength 0.5
}),
);
const result = await recall(
{ about: 'joshua.food', workspace_id: workspace },
{
store,
substrate,
daily,
compost,
compostTemplateIds: [],
dailyDates: ['2026-04-11'],
now: new Date('2026-04-11T00:00:00Z'),
},
);
expect(result.marks.map((m) => m.id)).toEqual(['m-fresh', 'm-old']);
});
});
- [ ] Step 2: Run tests to verify failure
pnpm --filter @cambium/memory test
Expected: failures on module-not-found.
- [ ] Step 3: Implement recall.ts
Create packages/memory/src/recall.ts:
import type { RecallInput, RecallResult, MemoryMark } from './types.js';
import type { MemoryStore } from './memory-store.js';
import type { SubstrateReader } from './substrate-reader.js';
import type { DailyReader } from './daily-reader.js';
import type { CompostReader } from './compost-reader.js';
import { computeCurrentStrength, WITHER_THRESHOLD } from './strength.js';
import { normalizeSubject } from './subject.js';
export interface RecallDeps {
store: MemoryStore;
substrate: SubstrateReader;
daily: DailyReader;
compost: CompostReader;
/** Template IDs to scan for compost. Empty array = skip compost layer. */
compostTemplateIds: string[];
/** Daily dates to scan (YYYY-MM-DD). Typically today in production. */
dailyDates: string[];
/** Injectable now for deterministic tests. */
now?: Date;
}
export async function recall(input: RecallInput, deps: RecallDeps): Promise<RecallResult> {
const now = deps.now ?? new Date();
const layers = new Set(input.layers ?? ['substrate', 'marks', 'daily', 'compost']);
const minStrength = input.min_strength ?? WITHER_THRESHOLD;
const result: RecallResult = {
substrate: [],
marks: [],
daily: [],
compost: [],
summary: null,
};
if (layers.has('substrate')) {
const hits = await deps.substrate.findRelevant(input.about);
result.substrate = hits;
}
if (layers.has('marks')) {
const marks = await deps.store.findMarksBySubjectPrefix(input.workspace_id, input.about);
const scored: Array<{ mark: MemoryMark; strength: number }> = [];
for (const mark of marks) {
const reinforcements = await deps.store.findReinforcementsForMark(mark.id);
const strength = computeCurrentStrength(mark, reinforcements, now);
if (strength >= minStrength) {
scored.push({ mark, strength });
}
}
scored.sort((a, b) => b.strength - a.strength);
result.marks = scored.map(({ mark, strength }) => ({
id: mark.id,
subject: mark.subject,
observation: mark.observation,
current_strength: strength,
observed_by: mark.observed_by,
first_observed_at: mark.first_observed_at.toISOString(),
last_reinforced_at: mark.last_reinforced_at.toISOString(),
evidence: mark.evidence,
}));
}
if (layers.has('daily')) {
const tokens = normalizeSubject(input.about).split('.').filter((t) => t.length >= 3);
for (const date of deps.dailyDates) {
const hits = await deps.daily.findRelevantLines(date, tokens);
result.daily.push(...hits);
}
}
if (layers.has('compost')) {
result.compost = await deps.compost.findRelevant(input.about, deps.compostTemplateIds);
}
return result;
}
- [ ] Step 4: Run tests
pnpm --filter @cambium/memory test
Expected: all recall tests pass.
- [ ] Step 5: Export and commit
Add to packages/memory/src/index.ts:
export { recall, type RecallDeps } from './recall.js';
git add packages/memory/src/recall.ts packages/memory/src/recall.test.ts packages/memory/src/index.ts
git commit -m "feat(memory): recall() blends substrate, marks, daily, compost into RecallResult"
Task 11: memory.observation Receptor
Files:
- Create: packages/memory/src/receptors.ts
- Create: packages/memory/src/receptors.test.ts
- [ ] Step 1: Write failing tests
Create packages/memory/src/receptors.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { createObservationReceptor, createSeedPlantedReceptor } from './receptors.js';
import { InMemoryMemoryStore } from './memory-store.js';
import type { Signal, SignalId, WorkspaceId, ReceptorId } from '@cambium/core';
const workspace = 'obadiah' as WorkspaceId;
function makeSignal(type: string, payload: Record<string, unknown>): Signal {
return {
id: 'sig-1' as SignalId,
type,
class: 'knowledge',
source_layer: 'observer',
source_id: 'agent-observer',
workspace_id: workspace,
intensity: 0.8,
decay_rate: 7 * 86400, // 7 days in seconds
cascade_potential: false,
payload,
affinity: ['food'],
emitted_at: '2026-04-11T10:00:00Z',
};
}
describe('createObservationReceptor', () => {
let store: InMemoryMemoryStore;
beforeEach(() => { store = new InMemoryMemoryStore(); });
it('creates a mark when a memory.observation signal arrives', async () => {
const receptor = createObservationReceptor({
id: 'r-obs' as ReceptorId,
store,
idGenerator: () => 'm-new',
now: () => new Date('2026-04-11T10:00:00Z'),
});
const signal = makeSignal('memory.observation', {
subject: 'joshua.food.vegetables',
observation: 'Arnold said 180C 20 min',
evidence: { daily_file: '2026-04-11.md', line_range: [2, 2] },
affinity: ['food'],
substrate_refs: ['long-term/lessons.md'],
});
await receptor.response(signal);
const marks = await store.findMarksBySubjectPrefix(workspace, 'joshua.food');
expect(marks).toHaveLength(1);
expect(marks[0].subject).toBe('joshua.food.vegetables');
expect(marks[0].observation).toBe('Arnold said 180C 20 min');
expect(marks[0].initial_strength).toBe(0.8);
// signal decay_rate is in seconds, mark decay_rate is in days
expect(marks[0].decay_rate).toBeCloseTo(7, 3);
expect(marks[0].observed_by).toBe('agent-observer');
expect(marks[0].evidence.line_range).toEqual([2, 2]);
expect(marks[0].substrate_refs).toEqual(['long-term/lessons.md']);
});
it('throws if signal payload is missing required fields', async () => {
const receptor = createObservationReceptor({
id: 'r-obs' as ReceptorId,
store,
idGenerator: () => 'm-new',
now: () => new Date(),
});
const bad = makeSignal('memory.observation', { subject: 'joshua.sleep' }); // missing observation, evidence
await expect(receptor.response(bad)).rejects.toThrow(/observation/);
});
});
describe('createSeedPlantedReceptor', () => {
let store: InMemoryMemoryStore;
beforeEach(() => { store = new InMemoryMemoryStore(); });
it('creates a seed row from a memory.seed_planted signal', async () => {
const receptor = createSeedPlantedReceptor({
id: 'r-seed' as ReceptorId,
store,
idGenerator: () => 's-new',
now: () => new Date('2026-04-11T10:00:00Z'),
});
const signal = makeSignal('memory.seed_planted', {
subject: 'joshua.food.vegetables',
proposed_change: 'add to long-term/lessons.md: vegetables at 180C for 20 min',
target_file: 'long-term/lessons.md',
based_on_marks: ['m-1', 'm-2'],
});
await receptor.response(signal);
const seeds = await store.findSeedsByWorkspace(workspace, 'pending');
expect(seeds).toHaveLength(1);
expect(seeds[0].target_file).toBe('long-term/lessons.md');
expect(seeds[0].based_on_marks).toEqual(['m-1', 'm-2']);
expect(seeds[0].planted_by).toBe('agent-observer');
});
});
- [ ] Step 2: Run tests to verify failure
pnpm --filter @cambium/memory test
Expected: failures on module-not-found.
- [ ] Step 3: Implement receptors.ts
Create packages/memory/src/receptors.ts:
import type {
Receptor,
ReceptorId,
Signal,
WorkspaceId,
} from '@cambium/core';
import type { MemoryStore } from './memory-store.js';
import type {
MemoryMark,
MemorySeed,
MarkId,
SeedId,
EvidenceRefs,
} from './types.js';
export interface ReceptorDeps {
id: ReceptorId;
store: MemoryStore;
idGenerator: () => string;
now?: () => Date;
}
const SECONDS_PER_DAY = 86400;
/**
* Receptor that turns `memory.observation` signals into `memory_marks`.
*
* Payload contract:
* subject: string (required)
* observation: string (required)
* evidence: EvidenceRefs (required — at least one field populated)
* affinity: string[] (optional, defaults to signal.affinity)
* substrate_refs: string[] (optional, defaults to [])
*
* Signal.intensity → mark.initial_strength.
* Signal.decay_rate (seconds) → mark.decay_rate (days).
*/
export function createObservationReceptor(deps: ReceptorDeps): Receptor {
const now = deps.now ?? (() => new Date());
return {
id: deps.id,
owner: 'memory',
binds_to: ['memory.observation'],
response: async (signal: Signal) => {
const payload = signal.payload as Record<string, unknown>;
const subject = requireString(payload, 'subject', signal);
const observation = requireString(payload, 'observation', signal);
const evidence = requireEvidence(payload, signal);
const affinity = Array.isArray(payload['affinity'])
? (payload['affinity'] as string[])
: signal.affinity;
const substrateRefs = Array.isArray(payload['substrate_refs'])
? (payload['substrate_refs'] as string[])
: [];
const t = now();
const mark: MemoryMark = {
id: deps.idGenerator() as MarkId,
workspace_id: signal.workspace_id as WorkspaceId,
subject,
observation,
evidence,
initial_strength: signal.intensity,
decay_rate: signal.decay_rate / SECONDS_PER_DAY,
observed_by: signal.source_id,
first_observed_at: t,
last_reinforced_at: t,
reinforcement_count: 0,
affinity,
substrate_refs: substrateRefs,
state: 'active',
created_at: t,
updated_at: t,
};
await deps.store.insertMark(mark);
},
};
}
/**
* Receptor that turns `memory.seed_planted` signals into `memory_seeds`.
*
* Payload contract:
* subject: string
* proposed_change: string
* target_file: string
* based_on_marks: string[] (MarkId values)
*/
export function createSeedPlantedReceptor(deps: ReceptorDeps): Receptor {
const now = deps.now ?? (() => new Date());
return {
id: deps.id,
owner: 'memory',
binds_to: ['memory.seed_planted'],
response: async (signal: Signal) => {
const payload = signal.payload as Record<string, unknown>;
const subject = requireString(payload, 'subject', signal);
const proposed = requireString(payload, 'proposed_change', signal);
const targetFile = requireString(payload, 'target_file', signal);
const based = Array.isArray(payload['based_on_marks'])
? (payload['based_on_marks'] as string[]).map((s) => s as MarkId)
: [];
const t = now();
const seed: MemorySeed = {
id: deps.idGenerator() as SeedId,
workspace_id: signal.workspace_id as WorkspaceId,
subject,
proposed_change: proposed,
target_file: targetFile,
based_on_marks: based,
planted_at: t,
planted_by: signal.source_id,
state: 'pending',
reviewed_at: null,
created_at: t,
updated_at: t,
};
await deps.store.insertSeed(seed);
},
};
}
function requireString(payload: Record<string, unknown>, key: string, signal: Signal): string {
const v = payload[key];
if (typeof v !== 'string' || v.length === 0) {
throw new Error(
`memory receptor: signal ${signal.id} type=${signal.type} missing required string field "${key}"`,
);
}
return v;
}
function requireEvidence(payload: Record<string, unknown>, signal: Signal): EvidenceRefs {
const v = payload['evidence'];
if (typeof v !== 'object' || v === null) {
throw new Error(
`memory receptor: signal ${signal.id} missing required evidence object`,
);
}
const refs = v as EvidenceRefs;
const hasAny =
(refs.signal_ids?.length ?? 0) > 0 ||
!!refs.daily_file ||
!!refs.line_range ||
(refs.compost_ids?.length ?? 0) > 0;
if (!hasAny) {
throw new Error(
`memory receptor: signal ${signal.id} evidence must have at least one populated field`,
);
}
return refs;
}
- [ ] Step 4: Run tests
pnpm --filter @cambium/memory test
Expected: all receptor tests pass.
- [ ] Step 5: Export and commit
Add to packages/memory/src/index.ts:
export {
createObservationReceptor,
createSeedPlantedReceptor,
type ReceptorDeps,
} from './receptors.js';
git add packages/memory/src/receptors.ts packages/memory/src/receptors.test.ts packages/memory/src/index.ts
git commit -m "feat(memory): memory.observation and memory.seed_planted receptors"
Task 12: Integration Test with Real SignalBus
Proves the end-to-end signal → receptor → mark → recall path against the real SignalBus from @cambium/signals.
Files:
- Create: packages/memory/src/integration.test.ts
- [ ] Step 1: Write the test
Create packages/memory/src/integration.test.ts:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { SignalBus, InMemoryPersister } from '@cambium/signals';
import type { ReceptorId, WorkspaceId } from '@cambium/core';
import { InMemoryMemoryStore } from './memory-store.js';
import { SubstrateReader } from './substrate-reader.js';
import { DailyReader } from './daily-reader.js';
import { CompostReader, type CompostSource } from './compost-reader.js';
import { createObservationReceptor } from './receptors.js';
import { recall } from './recall.js';
describe('memory end-to-end: observation signal → mark → recall', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'memory-integration-'));
mkdirSync(join(dir, 'long-term'), { recursive: true });
mkdirSync(join(dir, 'daily'), { recursive: true });
writeFileSync(
join(dir, 'daily', '2026-04-11.md'),
'Morning pages.\nArnold recommended cooking vegetables 20 min at 180C.\n',
);
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('fires memory.observation signal, receptor writes mark, recall surfaces it', async () => {
const workspace = 'obadiah' as WorkspaceId;
const store = new InMemoryMemoryStore();
const persister = new InMemoryPersister();
const bus = new SignalBus({ persister, maxRetries: 1, retryDelayMs: 1 });
const receptor = createObservationReceptor({
id: 'r-memory-obs' as ReceptorId,
store,
idGenerator: () => 'm-test-1',
now: () => new Date('2026-04-11T10:00:00Z'),
});
bus.registerReceptor(receptor);
await bus.createAndEmit({
type: 'memory.observation',
class: 'knowledge',
source_layer: 'test',
source_id: 'test-agent',
workspace_id: workspace,
intensity: 0.9,
decay_rate: 7 * 86400,
payload: {
subject: 'joshua.food.vegetables',
observation: 'Arnold recommended cooking vegetables 20 min at 180°C',
evidence: { daily_file: '2026-04-11.md', line_range: [2, 2] },
affinity: ['food'],
substrate_refs: [],
},
});
// Persist-before-deliver: signal must be in the persister
expect(persister.signals).toHaveLength(1);
expect(persister.signals[0].type).toBe('memory.observation');
// Mark must exist
const marks = await store.findMarksBySubjectPrefix(workspace, 'joshua.food');
expect(marks).toHaveLength(1);
expect(marks[0].id).toBe('m-test-1');
// recall() must surface the mark with the daily line as evidence
const substrate = new SubstrateReader(dir);
const daily = new DailyReader(dir);
const compost = new CompostReader({
async listRecordsForTemplates() { return []; },
} as CompostSource);
const result = await recall(
{ about: 'joshua.food', workspace_id: workspace },
{
store,
substrate,
daily,
compost,
compostTemplateIds: [],
dailyDates: ['2026-04-11'],
now: new Date('2026-04-11T10:00:00Z'),
},
);
expect(result.marks).toHaveLength(1);
expect(result.marks[0].subject).toBe('joshua.food.vegetables');
expect(result.marks[0].evidence.daily_file).toBe('2026-04-11.md');
expect(result.marks[0].evidence.line_range).toEqual([2, 2]);
expect(result.daily.some((d) => d.excerpt.includes('vegetables'))).toBe(true);
});
});
- [ ] Step 2: Run test
pnpm --filter @cambium/memory test
Expected: integration test passes. If InMemoryPersister or SignalBus imports don’t resolve, grep packages/signals/src/index.ts and adjust.
- [ ] Step 3: Commit
git add packages/memory/src/integration.test.ts
git commit -m "test(memory): integration — observation signal → real SignalBus → mark → recall"
Task 13: Consolidation Receptor with Mock LLM
The consolidation receptor handles memory.consolidate signals (fired by a Rhythms template at 03:00 Berlin). It reads today’s daily file, calls an injected LLMExtractor (mocked in v1), creates marks from the extractions, and archives the daily file.
Files:
- Create: packages/memory/src/llm-extractor.ts
- Create: packages/memory/src/llm-extractor.test.ts
- Modify: packages/memory/src/receptors.ts (add createConsolidationReceptor)
- Modify: packages/memory/src/receptors.test.ts (add consolidation tests)
- [ ] Step 1: Define the LLMExtractor interface and mock
Create packages/memory/src/llm-extractor.ts:
import type { EvidenceRefs } from './types.js';
/**
* A candidate observation extracted from a daily log line (or line range).
* The consolidation receptor turns each extraction into a mark.
*/
export interface ExtractedObservation {
subject: string;
observation: string;
line_range: [number, number];
affinity: string[];
substrate_refs: string[];
/** Suggested initial strength in [0, 1]. Defaults to 0.7 if extractor doesn't set it. */
initial_strength?: number;
}
/**
* LLMExtractor: turns a daily file's lines into candidate observations.
* In v1 we use MockLLMExtractor. SEED-002 replaces this with a real provider.
*/
export interface LLMExtractor {
extract(input: { date: string; lines: string[] }): Promise<ExtractedObservation[]>;
}
/**
* Deterministic mock: emits one observation per daily line that starts with a
* recognizable marker, mapping the first token after "subject:" in the line to
* a subject path. Used for tests and v1 Obadiah wiring.
*
* Input line format for mock matching:
* "OBS subject=joshua.food.vegetables Arnold cooked vegetables at 180C"
*
* Real daily lines won't look like this. The mock is a stand-in. SEED-002
* replaces it with a real LLM that can parse natural language.
*/
export class MockLLMExtractor implements LLMExtractor {
constructor(
private readonly fixedExtractions?: (input: { date: string; lines: string[] }) => ExtractedObservation[],
) {}
async extract(input: { date: string; lines: string[] }): Promise<ExtractedObservation[]> {
if (this.fixedExtractions) return this.fixedExtractions(input);
const out: ExtractedObservation[] = [];
for (let i = 0; i < input.lines.length; i++) {
const line = input.lines[i];
const match = line.match(/^OBS\s+subject=([^\s]+)\s+(.+)$/);
if (!match) continue;
out.push({
subject: match[1],
observation: match[2],
line_range: [i + 1, i + 1],
affinity: [],
substrate_refs: [],
initial_strength: 0.7,
});
}
return out;
}
}
/** Helper for tests that want to inject a specific list of extractions. */
export function makeFixedMockExtractor(extractions: ExtractedObservation[]): LLMExtractor {
return new MockLLMExtractor(() => extractions);
}
export type { EvidenceRefs };
- [ ] Step 2: Write failing tests for llm-extractor and consolidation receptor
Create packages/memory/src/llm-extractor.test.ts:
import { describe, it, expect } from 'vitest';
import { MockLLMExtractor, makeFixedMockExtractor } from './llm-extractor.js';
describe('MockLLMExtractor', () => {
it('extracts OBS lines with subject= marker', async () => {
const extractor = new MockLLMExtractor();
const result = await extractor.extract({
date: '2026-04-11',
lines: [
'Morning pages.',
'OBS subject=joshua.food.vegetables Arnold said 180C 20 min',
'Deep work.',
],
});
expect(result).toHaveLength(1);
expect(result[0].subject).toBe('joshua.food.vegetables');
expect(result[0].observation).toBe('Arnold said 180C 20 min');
expect(result[0].line_range).toEqual([2, 2]);
});
it('makeFixedMockExtractor returns injected extractions verbatim', async () => {
const extractor = makeFixedMockExtractor([
{ subject: 'joshua.sleep', observation: 'late', line_range: [1, 1], affinity: [], substrate_refs: [] },
]);
const result = await extractor.extract({ date: '2026-04-11', lines: ['anything'] });
expect(result).toHaveLength(1);
expect(result[0].subject).toBe('joshua.sleep');
});
});
Append to packages/memory/src/receptors.test.ts (inside the existing file, new describe block):
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach } from 'vitest';
import { DailyReader } from './daily-reader.js';
import { createConsolidationReceptor } from './receptors.js';
import { makeFixedMockExtractor } from './llm-extractor.js';
describe('createConsolidationReceptor', () => {
let dir: string;
let store: InMemoryMemoryStore;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'memory-consolidation-'));
mkdirSync(join(dir, 'daily'), { recursive: true });
mkdirSync(join(dir, 'daily', 'archive'), { recursive: true });
writeFileSync(
join(dir, 'daily', '2026-04-11.md'),
'Morning pages.\nArnold said 180C for vegetables.\nDeep work.\n',
);
store = new InMemoryMemoryStore();
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('extracts, creates marks, and archives the daily file', async () => {
const daily = new DailyReader(dir);
const extractor = makeFixedMockExtractor([
{
subject: 'joshua.food.vegetables',
observation: 'Arnold recommends 180C for vegetables',
line_range: [2, 2],
affinity: ['food'],
substrate_refs: [],
initial_strength: 0.8,
},
]);
let counter = 0;
const receptor = createConsolidationReceptor({
id: 'r-consolidate' as ReceptorId,
store,
daily,
extractor,
idGenerator: () => `m-${++counter}`,
now: () => new Date('2026-04-12T01:00:00Z'), // the day AFTER
decayRateDays: 7,
});
const signal = makeSignal('memory.consolidate', {
date: '2026-04-11',
});
await receptor.response(signal);
// Mark created
const marks = await store.findMarksBySubjectPrefix(workspace, 'joshua.food');
expect(marks).toHaveLength(1);
expect(marks[0].subject).toBe('joshua.food.vegetables');
expect(marks[0].evidence.daily_file).toBe('2026-04-11.md');
expect(marks[0].evidence.line_range).toEqual([2, 2]);
expect(marks[0].initial_strength).toBe(0.8);
expect(marks[0].decay_rate).toBe(7);
// File archived
expect(existsSync(join(dir, 'daily', '2026-04-11.md'))).toBe(false);
expect(existsSync(join(dir, 'daily', 'archive', '2026-04-11.md'))).toBe(true);
});
it('is a no-op when the daily file does not exist', async () => {
const daily = new DailyReader(dir);
const extractor = makeFixedMockExtractor([]);
const receptor = createConsolidationReceptor({
id: 'r-consolidate' as ReceptorId,
store,
daily,
extractor,
idGenerator: () => 'm-new',
now: () => new Date('2026-04-12T01:00:00Z'),
decayRateDays: 7,
});
await receptor.response(makeSignal('memory.consolidate', { date: '2099-01-01' }));
const marks = await store.findMarksBySubjectPrefix(workspace, '');
expect(marks).toEqual([]);
});
});
- [ ] Step 3: Run tests to verify failure
pnpm --filter @cambium/memory test
Expected: llm-extractor tests fail on module-not-found; consolidation tests fail because createConsolidationReceptor doesn’t exist yet.
- [ ] Step 4: Implement createConsolidationReceptor
Append to packages/memory/src/receptors.ts (at the bottom, before helpers):
import type { DailyReader } from './daily-reader.js';
import type { LLMExtractor } from './llm-extractor.js';
export interface ConsolidationReceptorDeps {
id: ReceptorId;
store: MemoryStore;
daily: DailyReader;
extractor: LLMExtractor;
idGenerator: () => string;
now?: () => Date;
/** Decay half-life assigned to marks created by consolidation. Default 14 days. */
decayRateDays?: number;
}
/**
* Receptor for `memory.consolidate` signals. Payload: `{ date: "YYYY-MM-DD" }`.
* Reads the daily file, runs the LLM extractor, creates marks, archives the file.
*
* In v1 the extractor is a MockLLMExtractor. Real LLM wiring is SEED-002.
*/
export function createConsolidationReceptor(deps: ConsolidationReceptorDeps): Receptor {
const now = deps.now ?? (() => new Date());
const decayRateDays = deps.decayRateDays ?? 14;
return {
id: deps.id,
owner: 'memory',
binds_to: ['memory.consolidate'],
response: async (signal: Signal) => {
const payload = signal.payload as Record<string, unknown>;
const date = requireString(payload, 'date', signal);
const day = await deps.daily.readDay(date);
if (!day) return;
const extractions = await deps.extractor.extract({
date,
lines: day.lines,
});
const t = now();
for (const ex of extractions) {
const mark: MemoryMark = {
id: deps.idGenerator() as MarkId,
workspace_id: signal.workspace_id as WorkspaceId,
subject: ex.subject,
observation: ex.observation,
evidence: {
daily_file: `${date}.md`,
line_range: ex.line_range,
signal_ids: [signal.id],
},
initial_strength: ex.initial_strength ?? 0.7,
decay_rate: decayRateDays,
observed_by: signal.source_id,
first_observed_at: t,
last_reinforced_at: t,
reinforcement_count: 0,
affinity: ex.affinity,
substrate_refs: ex.substrate_refs,
state: 'active',
created_at: t,
updated_at: t,
};
await deps.store.insertMark(mark);
}
await deps.daily.archive(date);
},
};
}
- [ ] Step 5: Run tests
pnpm --filter @cambium/memory test
Expected: all llm-extractor and consolidation tests pass.
- [ ] Step 6: Export and commit
Add to packages/memory/src/index.ts:
export {
MockLLMExtractor,
makeFixedMockExtractor,
type LLMExtractor,
type ExtractedObservation,
} from './llm-extractor.js';
export {
createConsolidationReceptor,
type ConsolidationReceptorDeps,
} from './receptors.js';
git add packages/memory/src/llm-extractor.ts packages/memory/src/llm-extractor.test.ts packages/memory/src/receptors.ts packages/memory/src/receptors.test.ts packages/memory/src/index.ts
git commit -m "feat(memory): consolidation receptor with MockLLMExtractor (real LLM deferred to SEED-002)"
Task 14: Obadiah Wiring — Consolidation Rhythm + start-memory.ts
Files:
- Create: deployments/obadiah/rhythms/consolidation.yaml
- Create: deployments/obadiah/bridge/start-memory.ts
- [ ] Step 1: Create the consolidation rhythm template
Create deployments/obadiah/rhythms/consolidation.yaml:
# Nightly consolidation — at 03:00 Berlin, fire memory.consolidate with
# yesterday's date so the memory receptor extracts marks and archives the file.
# The date is computed by the consolidation receptor itself (signal payload is
# empty; receptor uses now() - 1 day). A richer approach — payload computed by
# the rhythm loader — is a follow-up when Rhythms learns templated payloads.
name: memory.consolidate
period: daily
phase: "03:00"
timezone: "Europe/Berlin"
signal:
type: memory.consolidate
class: lifecycle
intensity: 1.0
decay_rate: 0
payload:
# date is filled in by the receptor wrapper in start-memory.ts
# (it wraps the raw receptor so the signal can carry today-1)
deployment: "obadiah"
catchup: fire_once
Note: the rhythm signal’s payload.date is filled in by a small wrapper in start-memory.ts that re-emits the canonical memory.consolidate signal with a date. This keeps the rhythm template format unchanged.
- [ ] Step 2: Create the memory boot script
Create deployments/obadiah/bridge/start-memory.ts:
#!/usr/bin/env npx tsx
/**
* Start the Memory receptors for Obadiah.
*
* Run with: npx tsx deployments/obadiah/bridge/start-memory.ts
*
* Wires:
* - memory.observation receptor → marks
* - memory.seed_planted receptor → seeds
* - memory.consolidate receptor → extract marks from today's daily file
*
* All three ride on the same SignalBus instance. Uses InMemoryMemoryStore in v1
* (a Drizzle PgMemoryStore is a follow-up).
*
* The canonical Obadiah memory path is:
* /Users/joshua/Projects/Obadiah/memory/
*/
import { resolve } from 'node:path';
import type { ReceptorId, WorkspaceId } from '@cambium/core';
import { SignalBus, InMemoryPersister } from '@cambium/signals';
import {
InMemoryMemoryStore,
SubstrateReader,
DailyReader,
CompostReader,
type CompostSource,
createObservationReceptor,
createSeedPlantedReceptor,
createConsolidationReceptor,
MockLLMExtractor,
} from '@cambium/memory';
const WORKSPACE = 'obadiah' as WorkspaceId;
const MEMORY_ROOT = '/Users/joshua/Projects/Obadiah/memory';
async function main(): Promise<void> {
console.log('[memory] booting with root', MEMORY_ROOT);
const store = new InMemoryMemoryStore();
const substrate = new SubstrateReader(MEMORY_ROOT);
const daily = new DailyReader(MEMORY_ROOT);
const compost = new CompostReader({
async listRecordsForTemplates() { return []; }, // v1: empty; wire genesis in a follow-up
} as CompostSource);
const extractor = new MockLLMExtractor(); // v1 mock; SEED-002 replaces with real LLM
const persister = new InMemoryPersister();
const bus = new SignalBus({ persister, maxRetries: 3, retryDelayMs: 100 });
bus.registerReceptor(
createObservationReceptor({
id: 'obadiah-memory-observation' as ReceptorId,
store,
idGenerator: () => crypto.randomUUID(),
}),
);
bus.registerReceptor(
createSeedPlantedReceptor({
id: 'obadiah-memory-seed-planted' as ReceptorId,
store,
idGenerator: () => crypto.randomUUID(),
}),
);
bus.registerReceptor(
createConsolidationReceptor({
id: 'obadiah-memory-consolidate' as ReceptorId,
store,
daily,
extractor,
idGenerator: () => crypto.randomUUID(),
decayRateDays: 14,
}),
);
console.log('[memory] receptors registered: observation, seed_planted, consolidate');
console.log('[memory] substrate=', substrate, 'daily=', daily, 'compost=', compost);
console.log('[memory] ready. Send signals via SignalBus to write marks.');
// Keep process alive — in a real deployment this would be integrated with
// the rhythms tick loop (see deployments/obadiah/bridge/start-rhythms.ts).
process.stdin.resume();
const shutdown = () => {
console.log('[memory] shutting down');
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((err) => {
console.error('[memory] fatal:', err);
process.exit(1);
});
- [ ] Step 3: Smoke test the boot script
npx tsx deployments/obadiah/bridge/start-memory.ts
Expected output:
[memory] booting with root /Users/joshua/Projects/Obadiah/memory
[memory] receptors registered: observation, seed_planted, consolidate
[memory] substrate= SubstrateReader { ... } daily= DailyReader { ... } compost= CompostReader { ... }
[memory] ready. Send signals via SignalBus to write marks.
The process stays alive on process.stdin.resume(). Ctrl-C to stop.
- [ ] Step 4: Commit
git add deployments/obadiah/rhythms/consolidation.yaml deployments/obadiah/bridge/start-memory.ts
git commit -m "feat(obadiah): wire memory receptors + consolidation rhythm at 03:00 Berlin"
Task 15: CLI Commands in cambium-bridge.ts
Extends deployments/obadiah/bridge/cambium-bridge.ts with four new memory subcommands. Uses the same Postgres sql client already present in the file for queries against memory_marks / memory_seeds. For v1, these are read-only select queries plus a mark insert (for debugging). The CLI does NOT touch substrate files.
Files:
- Modify: deployments/obadiah/bridge/cambium-bridge.ts
- [ ] Step 1: Add memory command dispatch
Open deployments/obadiah/bridge/cambium-bridge.ts. Find the main command dispatch block (search for context save or agents list). Add a new memory branch with the following subcommands:
// ─── Memory commands ───
async function memoryRecall(subject: string, opts: { horizon?: string; layers?: string }) {
const layerFilter = opts.layers ? opts.layers.split(',').map((s) => s.trim()) : null;
// Marks — direct SQL prefix query; compute current_strength in JS for simplicity
const rows = await sql`
SELECT id, subject, observation, evidence, initial_strength, decay_rate,
observed_by, first_observed_at, last_reinforced_at, state
FROM memory_marks
WHERE workspace_id = 'obadiah'
AND state = 'active'
AND (subject = ${subject} OR subject LIKE ${`${subject}.%`})
ORDER BY first_observed_at DESC
LIMIT 50
`;
const now = Date.now();
const MS_PER_DAY = 86400000;
const marks = rows.map((r: any) => {
const daysSince = Math.max(0, (now - new Date(r.first_observed_at).getTime()) / MS_PER_DAY);
const halfLives = r.decay_rate > 0 ? daysSince / r.decay_rate : 0;
const current_strength = r.initial_strength * Math.pow(2, -halfLives);
return {
id: r.id,
subject: r.subject,
observation: r.observation,
current_strength: Math.round(current_strength * 1000) / 1000,
observed_by: r.observed_by,
evidence: r.evidence,
};
});
if (!layerFilter || layerFilter.includes('marks')) {
console.log('MARKS:');
if (marks.length === 0) {
console.log(' (none)');
} else {
for (const m of marks) {
console.log(` [${m.current_strength}] ${m.subject} — ${m.observation}`);
console.log(` by ${m.observed_by}, evidence: ${JSON.stringify(m.evidence)}`);
}
}
}
}
async function memoryMark(subject: string, observation: string) {
const id = crypto.randomUUID();
const now = new Date();
await sql`
INSERT INTO memory_marks (
id, workspace_id, subject, observation, evidence,
initial_strength, decay_rate, observed_by,
first_observed_at, last_reinforced_at, reinforcement_count,
affinity, substrate_refs, state, created_at, updated_at
) VALUES (
${id}, 'obadiah', ${subject}, ${observation},
${sql.json({ manual: true })},
1.0, 14, 'joshua',
${now}, ${now}, 0,
${sql.json([])}, ${sql.json([])}, 'active', ${now}, ${now}
)
`;
console.log(`Mark created: ${id}`);
console.log(` ${subject} — ${observation}`);
}
async function memorySeeds(stateFilter?: string) {
const rows = stateFilter
? await sql`SELECT id, subject, proposed_change, target_file, state, planted_at
FROM memory_seeds
WHERE workspace_id = 'obadiah' AND state = ${stateFilter}
ORDER BY planted_at DESC`
: await sql`SELECT id, subject, proposed_change, target_file, state, planted_at
FROM memory_seeds
WHERE workspace_id = 'obadiah'
ORDER BY planted_at DESC`;
if (rows.length === 0) {
console.log('(no seeds)');
return;
}
for (const r of rows as any[]) {
console.log(`[${r.state}] ${r.subject} → ${r.target_file}`);
console.log(` ${r.proposed_change}`);
console.log(` planted ${new Date(r.planted_at).toISOString()} id=${r.id}`);
console.log();
}
}
async function memorySubstrate() {
const fs = await import('node:fs/promises');
const path = await import('node:path');
const root = '/Users/joshua/Projects/Obadiah/memory/long-term';
let entries: string[];
try {
entries = await fs.readdir(root);
} catch (err) {
console.log('(no substrate directory at', root, ')');
return;
}
for (const entry of entries.sort()) {
if (!entry.endsWith('.md')) continue;
console.log(`=== long-term/${entry} ===`);
const content = await fs.readFile(path.join(root, entry), 'utf8');
console.log(content);
console.log();
}
}
- [ ] Step 2: Add dispatch in the main command switch
Find the existing switch or if chain that routes commands like context save, agents list. Add a memory branch:
if (command === 'memory') {
const subcommand = process.argv[3];
if (subcommand === 'recall') {
const subject = process.argv[4];
if (!subject) { console.error('usage: memory recall <subject> [--layers=marks,substrate]'); process.exit(1); }
const layersArg = process.argv.find((a) => a.startsWith('--layers='));
const horizonArg = process.argv.find((a) => a.startsWith('--horizon='));
await memoryRecall(subject, {
layers: layersArg?.split('=')[1],
horizon: horizonArg?.split('=')[1],
});
} else if (subcommand === 'mark') {
const subject = process.argv[4];
const observation = process.argv[5];
if (!subject || !observation) {
console.error('usage: memory mark <subject> "<observation>"');
process.exit(1);
}
await memoryMark(subject, observation);
} else if (subcommand === 'seeds') {
const stateArg = process.argv.find((a) => a.startsWith('--state='));
await memorySeeds(stateArg?.split('=')[1]);
} else if (subcommand === 'substrate') {
await memorySubstrate();
} else {
console.error('usage: memory recall|mark|seeds|substrate ...');
process.exit(1);
}
await sql.end();
return;
}
- [ ] Step 3: Smoke test each CLI subcommand
These assume Task 5’s migration has been applied and the dev Postgres is running.
# Insert a manual mark
npx tsx deployments/obadiah/bridge/cambium-bridge.ts memory mark joshua.test.smoke "hello from the CLI"
# Expected: "Mark created: <uuid>"
# Recall it
npx tsx deployments/obadiah/bridge/cambium-bridge.ts memory recall joshua.test
# Expected: a MARKS block listing the mark we just inserted with strength ~1.0
# Seeds (should be empty)
npx tsx deployments/obadiah/bridge/cambium-bridge.ts memory seeds
# Expected: "(no seeds)"
# Substrate (reads real long-term/ files)
npx tsx deployments/obadiah/bridge/cambium-bridge.ts memory substrate
# Expected: printed contents of each long-term/*.md file
- [ ] Step 4: Commit
git add deployments/obadiah/bridge/cambium-bridge.ts
git commit -m "feat(obadiah): add memory recall|mark|seeds|substrate CLI subcommands"
Task 16: Run Full Test Suite, Typecheck, and Build
- [ ] Step 1: Run full test suite
pnpm turbo test
Expected: everything green. No regressions in existing packages (@cambium/rhythms, @cambium/signals, @cambium/db, @cambium/genesis, @cambium/core).
- [ ] Step 2: Run typecheck across the monorepo
pnpm turbo typecheck
Expected: everything green. If typecheck fails in @cambium/memory on something like 'Receptor' has no exported member, check the actual exports in packages/core/src/types/signals.ts (or wherever Receptor lives) — the plan assumes Receptor is exported from @cambium/core. If it’s in @cambium/signals, import from there instead and update the receptor file.
- [ ] Step 3: Run build
pnpm turbo build
Expected: @cambium/memory builds. No downstream breakage.
- [ ] Step 4: If anything fails
Investigate root cause. Fix inline. Do not --force or skip failing tests. Re-run until green, then proceed.
Follow-up Work (Out of Scope for This Plan)
Tracked separately:
- PgMemoryStore — Drizzle-backed
MemoryStoreimplementation using the tables created in Task 5. Tables and indexes are already in place; only the store class is missing. Swap instart-memory.ts. - SEED-002 — Real LLM provider (
AnthropicLLMExtractororOpenAILLMExtractor) replacingMockLLMExtractorinstart-memory.ts. Includes thesummaryfield onRecallResultfor natural-language blending. - Consolidation date payload — the current consolidation rhythm template can’t fill in a date from “yesterday”. A follow-up extends Rhythms with templated payloads (
{{yesterday}}) or wraps the receptor with a small forwarder that re-emits with a computed date. - Compost reader wired to real genesis — replace the stub
CompostSourceinstart-memory.tswith an adapter that callsCompostingProcess.getTemplateCompost()for Obadiah’s registered templates. - File-watcher invalidation for SubstrateReader and DailyReader — currently
invalidate()is manual. A real file watcher (chokidar or fs.watch) triggers automatic invalidation when Joshua edits a substrate file in his own editor. - BL-005 — Loom memory page (visual inspector for marks, seeds, and recall results).
- BL-006 — Vector search over marks for semantic similarity, when the subject namespace outgrows exact-prefix matching.
- Automatic seed digest — weekly signal that surfaces pending seeds for Joshua’s review.
Success Criteria (from spec)
- ✅
pnpm --filter @cambium/memory testpasses — Tasks 3, 4, 6, 7, 8, 9, 10, 11, 12, 13 - ✅ Migration applies cleanly to dev DB — Task 5
- ✅
recall()returns structured multi-layer results — Task 10 - ✅ End-to-end: daily line → consolidation → mark with evidence → recall surfaces it — Task 13 (consolidation) + Task 12 (end-to-end) + Task 15 (manual CLI proof)
- ✅ A consolidated daily file is archived — Task 13
- ✅ Strength decays correctly (
initial 1.0, decay 7, +14 days ≈ 0.25) — Task 4 testquarters after two half-lives - ✅ Seed planted via signal → row created → CLI lists it → substrate file untouched — Task 11 (signal path) + Task 15 (CLI proof). The substrate file is never touched by any code path in this package.