feat(core): add first-class Pi session indexing (#23)

Pi cannot be read as another linear JSONL stream. Its history is a tree with
a durable leaf, orphan roots, branch summaries, and two compaction forms, so
the active context is something the format states rather than something line
order implies. The adapter keeps those semantics inside itself and projects
the result into the existing canonical tables.

Sessions are keyed by (normalized header cwd, header id) rather than by path,
because Pi's --session-id lookup is project-local: two projects may reuse an
id, while a move or an identical copy is still one session. Discovery covers
both layouts Pi writes and fingerprints each file by mtime, ctime, size and
inode, so a rewrite that preserves mtime is not read as unchanged.

Abandoned branches are preserved rather than dropped. Visibility becomes
three-state -- visible, inactive, hidden -- and helpers return only visible
rows until includeInactive asks for the superseded path, labeling every row
so a caller knows which it holds. Usage counts all three, because an
abandoned call still spent tokens; message_count reports only the visible
transcript.

A committed MIT-licensed oracle transcribed from Pi 0.83.0 pins the context
algorithms, and a fixed-seed differential runs 512 generated sessions against
it on every test run. Schema changes are additive.
This commit is contained in:
SaladDay
2026-08-04 23:33:01 +08:00
committed by GitHub
parent 1941e64572
commit 2589384e68
63 changed files with 7796 additions and 374 deletions
+50
View File
@@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises';
import { DatabaseSync } from 'node:sqlite';
import { extractContentType, extractMessageIsMeta } from '../packages/core/src/db.ts';
import { migrateCoreSchemaColumns } from '../packages/core/src/schema-migrations.ts';
async function readExecutableSchema() {
return readFile(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
@@ -50,6 +51,42 @@ test('messages schema stores the raw content block type', async () => {
assert.match(source, /CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages/);
});
test('summaries preserve usage from provider-owned summary model calls', async () => {
const source = await readExecutableSchema();
assert.match(source, /summaries \([\s\S]*visibility TEXT DEFAULT 'visible'[\s\S]*input_tokens INTEGER, output_tokens INTEGER/);
assert.match(source, /index_state \([\s\S]*cursor TEXT/);
});
test('additive migrations preserve old index state and summary rows while adding canonical fields', () => {
const db = new DatabaseSync(':memory:');
try {
db.exec(`
CREATE TABLE index_state (
jsonl_path TEXT PRIMARY KEY, mtime REAL, lines_processed INTEGER
);
CREATE TABLE summaries (
id TEXT PRIMARY KEY, session_id TEXT, timestamp TEXT,
source TEXT, content TEXT
);
INSERT INTO index_state VALUES ('unit', 12, 3);
INSERT INTO summaries VALUES ('summary', 'session', NULL, 'legacy', 'kept');
`);
migrateCoreSchemaColumns(db);
assert.equal(
db.prepare("SELECT cursor FROM index_state WHERE jsonl_path='unit'").get().cursor,
null,
);
assert.deepEqual(
{ ...db.prepare("SELECT content,visibility FROM summaries WHERE id='summary'").get() },
{ content: 'kept', visibility: 'visible' },
);
} finally {
db.close();
}
});
test('tool results schema indexes live session patch lookups', async () => {
const db = new DatabaseSync(':memory:');
try {
@@ -125,10 +162,18 @@ test('schema reference stays focused on raw SQL structure', async () => {
assert.ok(ref.split('\n').length < 420, 'schema.md should remain a quick SQL reference');
assert.match(ref, /Raw SQL Quick Reference/i);
assert.match(ref, /Claude Code, Codex, Kimi Code, and Pi/);
assert.equal(
ref.match(/Provider ID: `claude`, `codex`, `kimi`, or `pi`/g)?.length,
2,
'session and message source fields should document every provider',
);
assert.match(ref, /references\/api-reference\.md/);
assert.match(ref, /sessions\.id\s+<--\s+messages\.session_id/);
assert.match(ref, /tool_calls.*does not have timestamps/i);
assert.match(ref, /COALESCE\(m\.is_meta, 0\) = 0/);
assert.match(ref, /provider-attested superseded history/);
assert.match(ref, /Exact opaque provider cursor/);
assert.doesNotMatch(ref, /#### `summaries\(opts\?\)`/);
assert.doesNotMatch(ref, /#### `raw\(uuid, opts\?\)`/);
});
@@ -137,8 +182,11 @@ test('api reference documents query helpers and current return fields', async ()
const ref = await readApiReference();
assert.match(ref, /## Query API Reference/);
assert.match(ref, /'claude' \| 'codex' \| 'kimi' \| 'pi'/);
assert.doesNotMatch(ref, /"claude", "codex", or omitted/);
assert.match(ref, /#### `summaries\(opts\?\)`/);
assert.match(ref, /summary rows/i);
assert.match(ref, /Inactive summaries describe work that was tried and[\s\S]*then superseded/);
assert.match(ref, /session_title/);
assert.match(ref, /opts\.branch/);
assert.match(ref, /#### `raw\(uuid, opts\?\)`/);
@@ -154,6 +202,8 @@ test('api reference documents query helpers and current return fields', async ()
test('skill routes agents to the right reference document', async () => {
const skill = await readSkill();
assert.match(skill, /Claude Code, Codex, Kimi Code, and Pi/);
assert.match(skill, /'claude'.*'codex'.*'kimi'.*'pi'/s);
assert.match(skill, /Reference Map/);
assert.match(skill, /references\/schema\.md.*raw SQL/i);
assert.match(skill, /references\/api-reference\.md.*helper/i);