Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9c43438
feat(core): Temporal Linkage List — per-entity event chains (read-path)
moralespanitz May 5, 2026
68de141
feat(core): first-mention events — chronological topic-introduction p…
moralespanitz May 5, 2026
6980e87
feat(core): productize first-mention + TLL EO read endpoints
moralespanitz May 5, 2026
dc7b9ff
fix(tll): atomic append with advisory lock + unique position index (r…
moralespanitz May 6, 2026
91676f4
fix(search): drop TLL-augmented rows from similarity gate (review #2)
moralespanitz May 6, 2026
4020135
fix(tll): use memory.observed_at not new Date() for chain ordering (r…
moralespanitz May 6, 2026
d6fa8ed
fix(schema): predecessor_memory_id ON DELETE CASCADE (review #4)
moralespanitz May 6, 2026
63efbea
fix(schemas): cap EventChainsQuerySchema entity_ids at 100 (review #6)
moralespanitz May 6, 2026
ba0db16
fix(husky): unset GIT_INDEX_FILE/GIT_DIR before fallow audit
moralespanitz May 6, 2026
58c3095
chore(llm): drop unused inputTokens/outputTokens from ChatResult (rev…
moralespanitz May 6, 2026
623ac96
chore(tll): name TLL_ENTITY_LOOKUP_SEED_LIMIT constant (review #10)
moralespanitz May 6, 2026
9eacd79
fix(tll): tighten shouldUseTLL regex to reduce false-positives (revie…
moralespanitz May 6, 2026
36701b0
fix(observability): structured logging for TLL/first-mention fail-ope…
moralespanitz May 6, 2026
35dc36a
fix(first-mention): position by post-sorted index for idempotency (re…
moralespanitz May 6, 2026
c46cddf
test(routes): HTTP-level coverage for event-chains + first-mentions e…
moralespanitz May 6, 2026
216abc9
fix(tll, schemas): address PR #18 review v2–v5 follow-ups
ethanj May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
# npx fallow dupes --save-baseline=.fallow/dupes-baseline.json
BASE=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)
BASE=${BASE:-origin/main}
# Git sets GIT_INDEX_FILE/GIT_DIR before invoking pre-commit hooks. Fallow's
# `git worktree add` for base-ref scanning performs a checkout that writes
# to GIT_INDEX_FILE if it is set — which corrupts the main worktree's index
# by replacing it with the base ref's tree (silently deleting files the
# feature branch added that don't exist on the base ref, then committing
# those deletions). Reproduced on this repo's worktrees during the PR #18
# review-response work. Unset both vars so any nested git invocation runs
# against the worktree's own default index. (Same fix lives at
# atomicmemory-benchmarks `.husky/pre-commit` commit `327326a`.)
unset GIT_INDEX_FILE
unset GIT_DIR
npx fallow audit \
--health-baseline=.fallow/health-baseline.json \
--dupes-baseline=.fallow/dupes-baseline.json \
Expand Down
36 changes: 36 additions & 0 deletions src/app/runtime-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import { LinkRepository } from '../db/link-repository.js';
import { MemoryRepository } from '../db/memory-repository.js';
import { EntityRepository } from '../db/repository-entities.js';
import { LessonRepository } from '../db/repository-lessons.js';
import { TllRepository } from '../db/repository-tll.js';
import { FirstMentionRepository } from '../db/repository-first-mentions.js';
import { FirstMentionService } from '../services/first-mention-service.js';
import { llm } from '../services/llm.js';
import type { CoreStores } from '../db/stores.js';
import { PgMemoryStore } from '../db/pg-memory-store.js';
import { PgEpisodeStore } from '../db/pg-episode-store.js';
Expand Down Expand Up @@ -202,6 +206,36 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime {
pool,
};

// Phase 4 TLL — per-entity event chain for EO/MSR/TR queries.
// Append on memory store, traverse on retrieval.
const tllRepository = entities ? new TllRepository(pool) : null;

// First-mention events — chronological topic-introduction list. Caller
// (e.g. an external harness) drives extraction explicitly via the
// POST /v1/memories/first-mentions/extract route, supplying its own
// turn-id-to-memory-id mapping (the ingest pipeline does not retain
// turn structure). The chatFn adapter wraps the configured LLM
// singleton; per-call cost is logged inside `llm.chat`.
const firstMentionRepository = new FirstMentionRepository(pool);
const firstMentionService = new FirstMentionService(
firstMentionRepository,
async (system, user, maxTokens) => {
const text = await llm.chat(
[
{ role: 'system', content: system },
{ role: 'user', content: user },
],
{ maxTokens },
);
// Token usage is intentionally NOT returned here: `LLMProvider.chat`
// emits per-call cost telemetry via `writeCostEvent` internally
// (see `src/services/llm.ts`). Surfacing zeros at this seam invited
// the bug the prior reviewer caught — readers would treat them as
// real counts. Drop the field instead until usage is plumbed.
return { text };
},
);

const service = new MemoryService(
memory,
claims,
Expand All @@ -210,6 +244,8 @@ export function createCoreRuntime(deps: CoreRuntimeDeps): CoreRuntime {
undefined,
runtimeConfig,
stores,
tllRepository ?? undefined,
firstMentionService,
);

return {
Expand Down
120 changes: 120 additions & 0 deletions src/db/__tests__/repository-first-mentions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* Integration tests for FirstMentionRepository.
*
* Validates the storage idempotency contract that pairs with review #7:
* the `(user_id, memory_id)` UNIQUE constraint silently drops duplicate
* inserts, so a re-run of `extractAndStore` for the same conversation
* never produces extra rows. The service layer's post-sorted index
* `positionInConversation` makes the read-back deterministic across
* re-runs even when the LLM's turn_id assignment drifts; this test
* exercises the DB half of that guarantee.
*
* Requires DATABASE_URL in .env.test.
*/

import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { pool } from '../pool.js';
import { FirstMentionRepository, type FirstMentionEvent } from '../repository-first-mentions.js';
import { MemoryRepository } from '../memory-repository.js';
import { setupTestSchema, unitVector } from './test-fixtures.js';

const TEST_USER = 'first-mentions-repo-test-user';

describe('FirstMentionRepository', () => {
const fmRepo = new FirstMentionRepository(pool);
const memoryRepo = new MemoryRepository(pool);

beforeAll(async () => {
await setupTestSchema(pool);
});

beforeEach(async () => {
await pool.query(
'DELETE FROM first_mention_events WHERE user_id = $1',
[TEST_USER],
);
await memoryRepo.deleteAll();
});

afterAll(async () => {
await pool.end();
});

async function makeMemory(content: string, seed: number): Promise<string> {
return memoryRepo.storeMemory({
userId: TEST_USER,
content,
embedding: unitVector(seed),
importance: 0.5,
sourceSite: 'first-mention-test',
});
}

async function countRows(): Promise<number> {
const r = await pool.query<{ c: string }>(
`SELECT COUNT(*)::text AS c FROM first_mention_events WHERE user_id = $1`,
[TEST_USER],
);
return Number.parseInt(r.rows[0].c, 10);
}

it('store() is idempotent on (user_id, memory_id) across re-runs (review #7)', async () => {
const memA = await makeMemory('A topic memory', 1);
const memB = await makeMemory('B topic memory', 2);

// Run 1: turn_ids 5/12, position 0/1.
const run1: FirstMentionEvent[] = [
{ topic: 'A', turnId: 5, memoryId: memA, anchorDate: null, positionInConversation: 0 },
{ topic: 'B', turnId: 12, memoryId: memB, anchorDate: null, positionInConversation: 1 },
];
await fmRepo.store(TEST_USER, 'beam', run1);

// Run 2: same logical topics but the LLM drifted A's turn_id 5 -> 6.
// The post-sorted index keeps positionInConversation = 0/1 so the
// INSERT shape is identical to the row already on disk; ON CONFLICT
// (user_id, memory_id) DO NOTHING drops the duplicate cleanly.
const run2: FirstMentionEvent[] = [
{ topic: 'A', turnId: 6, memoryId: memA, anchorDate: null, positionInConversation: 0 },
{ topic: 'B', turnId: 12, memoryId: memB, anchorDate: null, positionInConversation: 1 },
];
await fmRepo.store(TEST_USER, 'beam', run2);

expect(await countRows()).toBe(2);
const list = await fmRepo.list(TEST_USER);
expect(list).toHaveLength(2);
expect(list.map((e) => e.topic)).toEqual(['A', 'B']);
expect(list.map((e) => e.positionInConversation)).toEqual([0, 1]);
// The first-write wins per ON CONFLICT DO NOTHING — original turn_id
// for A (5) is what survives, not the drifted 6.
const a = list.find((e) => e.topic === 'A');
expect(a?.turnId).toBe(5);
});

it('store() does no work when the events array is empty', async () => {
await fmRepo.store(TEST_USER, 'beam', []);
expect(await countRows()).toBe(0);
});

it('list() returns events ordered by position_in_conversation ASC', async () => {
const m1 = await makeMemory('m1', 11);
const m2 = await makeMemory('m2', 12);
const m3 = await makeMemory('m3', 13);

// Insert deliberately out of position order.
await fmRepo.store(TEST_USER, 'beam', [
{ topic: 'middle', turnId: 5, memoryId: m2, anchorDate: null, positionInConversation: 1 },
{ topic: 'late', turnId: 9, memoryId: m3, anchorDate: null, positionInConversation: 2 },
{ topic: 'early', turnId: 2, memoryId: m1, anchorDate: null, positionInConversation: 0 },
]);

const list = await fmRepo.list(TEST_USER);
expect(list.map((e) => e.topic)).toEqual(['early', 'middle', 'late']);
expect(list.map((e) => e.positionInConversation)).toEqual([0, 1, 2]);
});

it('getByMemoryId() returns null when no event exists for the memory', async () => {
const memA = await makeMemory('A', 21);
const result = await fmRepo.getByMemoryId(TEST_USER, memA);
expect(result).toBeNull();
});
});
Loading
Loading