cambium ▸ docs/superpowers/plans/2026-04-11-cambium-memory.md
updated 2026-04-12

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 *.md children become substrate) or a single .md file (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. See docs/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, call stat() — if isDirectory(), read its *.md children; if isFile(), read the file itself - SubstrateEntry.file should become the absolute path, not the relative long-term/identity.md form. This disambiguates files with the same basename across different source directories (e.g. memory/long-term/identity.md vs personal-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 fake SOUL.md written to the tmpdir root). Verify the reader walks both uniformly and returns entries from both. - Test assertions that previously used expect(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 SubstrateReader with an array: new SubstrateReader([dir]) instead of new SubstrateReader(dir). Substrate result assertions should match against absolute paths or basenames, not the old long-term/<name>.md relative 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.substrate for 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 substrate subcommand 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_refs field on MemoryMark (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:

  1. No real LLM in v1. Task 14 (consolidation receptor) uses a LLMExtractor interface with a MockLLMExtractor implementation that returns deterministic extractions from fixture text. Real provider binding (Anthropic/OpenAI) is deferred to SEED-002.
  2. In-memory store at the edge. Tasks 6-14 test against InMemoryMemoryStore. The Drizzle schema and migration land in Task 5, but a PgMemoryStore implementation is a follow-up (same pattern as Rhythms v1: schema ready, store implementation deferred). The Obadiah wiring (Task 15) uses the in-memory store.
  3. Compost subject matching is text-based, not structured. Exploration of @cambium/genesis revealed that CompostRecord has no subject, affinity, or tags — only template_id, failure_patterns[].pattern, successful_strategies[], and template_improvements[] (all free text) plus routing_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

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"
  }
}

Create packages/memory/tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "exclude": ["src/**/*.test.ts", "test"]
}

Create packages/memory/vitest.config.ts:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.test.ts'],
  },
});

Create packages/memory/src/index.ts:

// @cambium/memory — four-layer memory substrate (substrate, marks, daily, compost)
// Exports added as implementation lands.
export {};

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.

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

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
}

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';
pnpm --filter @cambium/memory typecheck

Expected: exits 0.

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

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);
  });
});
pnpm --filter @cambium/memory test

Expected: tests fail on module-not-found.

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}.`);
}
pnpm --filter @cambium/memory test

Expected: all subject tests pass.

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

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);
  });
});
pnpm --filter @cambium/memory test

Expected: failures on module-not-found.

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;
}
pnpm --filter @cambium/memory test

Expected: all strength tests pass.

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

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).

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.

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.

pnpm --filter @cambium/db typecheck

Expected: exits 0.

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

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');
  });
});
pnpm --filter @cambium/memory test

Expected: failures on module-not-found.

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() });
  }
}
pnpm --filter @cambium/memory test

Expected: all memory-store tests pass.

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

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.

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 });
    }
  });
});
pnpm --filter @cambium/memory test

Expected: failures on module-not-found.

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();
}
pnpm --filter @cambium/memory test

Expected: all substrate-reader tests pass.

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

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');
  });
});
pnpm --filter @cambium/memory test

Expected: failures on module-not-found.

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;
  }
}
pnpm --filter @cambium/memory test

Expected: all daily-reader tests pass.

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:

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

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);
  });
});
pnpm --filter @cambium/memory test

Expected: failures on module-not-found.

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));
}
pnpm --filter @cambium/memory test

Expected: all compost-reader tests pass.

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

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']);
  });
});
pnpm --filter @cambium/memory test

Expected: failures on module-not-found.

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;
}
pnpm --filter @cambium/memory test

Expected: all recall tests pass.

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

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');
  });
});
pnpm --filter @cambium/memory test

Expected: failures on module-not-found.

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;
}
pnpm --filter @cambium/memory test

Expected: all receptor tests pass.

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

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);
  });
});
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.

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)

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 };

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([]);
  });
});
pnpm --filter @cambium/memory test

Expected: llm-extractor tests fail on module-not-found; consolidation tests fail because createConsolidationReceptor doesn’t exist yet.

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);
    },
  };
}
pnpm --filter @cambium/memory test

Expected: all llm-extractor and consolidation tests pass.

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

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.

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);
});
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.

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

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();
  }
}

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;
}

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
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

pnpm turbo test

Expected: everything green. No regressions in existing packages (@cambium/rhythms, @cambium/signals, @cambium/db, @cambium/genesis, @cambium/core).

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.

pnpm turbo build

Expected: @cambium/memory builds. No downstream breakage.

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:


Success Criteria (from spec)

  1. pnpm --filter @cambium/memory test passes — Tasks 3, 4, 6, 7, 8, 9, 10, 11, 12, 13
  2. ✅ Migration applies cleanly to dev DB — Task 5
  3. recall() returns structured multi-layer results — Task 10
  4. ✅ 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)
  5. ✅ A consolidated daily file is archived — Task 13
  6. ✅ Strength decays correctly (initial 1.0, decay 7, +14 days ≈ 0.25) — Task 4 test quarters after two half-lives
  7. ✅ 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.