mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
877 lines
38 KiB
TypeScript
877 lines
38 KiB
TypeScript
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { DatabaseManager } from '../../src/store/db.js';
|
|
import {
|
|
indexSession,
|
|
indexAllSessions,
|
|
indexChangedSessions,
|
|
getSessionStats,
|
|
countSessionFiles,
|
|
needsBackfill,
|
|
touchBackfillTimestamp,
|
|
LAST_SESSION_BACKFILL_KEY,
|
|
indexCurrentSession,
|
|
indexLiveSession,
|
|
parseSessionManagerSnapshot,
|
|
truncateMessageContent,
|
|
upsertSessionFileMetadata,
|
|
pruneEphemeralReviewSessions,
|
|
pruneOldSessions,
|
|
} from '../../src/store/session-indexer.js';
|
|
import { DEFAULT_MAX_MESSAGE_CONTENT_LENGTH } from '../../src/constants.js';
|
|
import { parseSessionFile, type ParsedSession } from '../../src/store/session-parser.js';
|
|
|
|
describe('session-indexer', () => {
|
|
let tmpDir: string;
|
|
let dbManager: DatabaseManager;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'indexer-test-'));
|
|
dbManager = new DatabaseManager(tmpDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
dbManager.close();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function createTestSession(overrides: Partial<ParsedSession> = {}): ParsedSession {
|
|
const id = overrides.id ?? 'session-1';
|
|
return {
|
|
id,
|
|
project: 'test-project',
|
|
cwd: '/test',
|
|
startedAt: '2026-05-03T00:00:00Z',
|
|
endedAt: null,
|
|
messages: [
|
|
{ id: `${id}-msg-1`, role: 'user', content: 'Hello', timestamp: '2026-05-03T00:01:00Z' },
|
|
{ id: `${id}-msg-2`, role: 'assistant', content: 'Hi there!', timestamp: '2026-05-03T00:01:30Z', toolCalls: ['read'] },
|
|
],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('indexSession', () => {
|
|
it('should index a session and its messages', () => {
|
|
const session = createTestSession();
|
|
const result = indexSession(dbManager, session);
|
|
|
|
assert.strictEqual(result.sessionId, 'session-1');
|
|
assert.strictEqual(result.messagesIndexed, 2);
|
|
assert.strictEqual(result.skipped, false);
|
|
|
|
// Verify in database
|
|
const db = dbManager.getDb();
|
|
const dbSession = db.prepare('SELECT * FROM sessions WHERE id = ?').get('session-1') as Record<string, unknown>;
|
|
assert.strictEqual(dbSession.project, 'test-project');
|
|
assert.strictEqual(dbSession.message_count, 2);
|
|
|
|
const messages = db.prepare('SELECT * FROM messages WHERE session_id = ?').all('session-1') as Record<string, unknown>[];
|
|
assert.strictEqual(messages.length, 2);
|
|
assert.strictEqual(messages[0].role, 'user');
|
|
assert.strictEqual(messages[1].role, 'assistant');
|
|
});
|
|
|
|
it('should store tool_calls as JSON', () => {
|
|
const session = createTestSession();
|
|
indexSession(dbManager, session);
|
|
|
|
const db = dbManager.getDb();
|
|
const msg = db.prepare('SELECT tool_calls FROM messages WHERE id = ?').get('session-1-msg-2') as { tool_calls: string | null };
|
|
assert.ok(msg.tool_calls);
|
|
assert.deepStrictEqual(JSON.parse(msg.tool_calls), ['read']);
|
|
});
|
|
|
|
it('should cap oversized message content while retaining both ends', () => {
|
|
const content = `begin-${'x'.repeat(DEFAULT_MAX_MESSAGE_CONTENT_LENGTH)}-end`;
|
|
const session = createTestSession({
|
|
messages: [{ id: 'oversized-message', role: 'assistant', content, timestamp: '2026-05-03T00:01:00Z' }],
|
|
});
|
|
|
|
indexSession(dbManager, session);
|
|
|
|
const stored = dbManager.getDb().prepare('SELECT content FROM messages WHERE id = ?').get('oversized-message') as { content: string };
|
|
assert.ok(stored.content.length <= DEFAULT_MAX_MESSAGE_CONTENT_LENGTH);
|
|
assert.match(stored.content, /^begin-/);
|
|
assert.match(stored.content, /-end$/);
|
|
assert.match(stored.content, /truncated, \d+ chars total/);
|
|
assert.strictEqual(truncateMessageContent('short content'), 'short content');
|
|
});
|
|
|
|
it('should skip already-indexed sessions with no new messages', () => {
|
|
const session = createTestSession();
|
|
|
|
const result1 = indexSession(dbManager, session);
|
|
assert.strictEqual(result1.skipped, false);
|
|
|
|
const result2 = indexSession(dbManager, session);
|
|
assert.strictEqual(result2.skipped, true);
|
|
assert.strictEqual(result2.messagesIndexed, 0);
|
|
});
|
|
|
|
it('should append missing messages for an already-indexed resumed session', () => {
|
|
const session = createTestSession();
|
|
indexSession(dbManager, session);
|
|
|
|
const resumed = createTestSession({
|
|
messages: [
|
|
...session.messages,
|
|
{ id: 'session-1-msg-3', role: 'user', content: 'Resumed later', timestamp: '2026-05-03T00:02:00Z' },
|
|
],
|
|
});
|
|
const result = indexSession(dbManager, resumed);
|
|
|
|
assert.strictEqual(result.skipped, false);
|
|
assert.strictEqual(result.messagesIndexed, 1);
|
|
assert.strictEqual(dbManager.getStats().sessions, 1);
|
|
assert.strictEqual(dbManager.getStats().messages, 3);
|
|
|
|
const dbSession = dbManager.getDb().prepare('SELECT message_count FROM sessions WHERE id = ?').get('session-1') as { message_count: number };
|
|
assert.strictEqual(dbSession.message_count, 3);
|
|
});
|
|
|
|
it('should handle sessions with no messages', () => {
|
|
const session = createTestSession({ messages: [] });
|
|
const result = indexSession(dbManager, session);
|
|
|
|
assert.strictEqual(result.messagesIndexed, 0);
|
|
assert.strictEqual(result.skipped, false);
|
|
});
|
|
});
|
|
|
|
describe('indexAllSessions', () => {
|
|
it('should index all JSONL files from disk', () => {
|
|
// Create mock session directory structure
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
const projDir = path.join(sessionsDir, 'test-project');
|
|
fs.mkdirSync(projDir, { recursive: true });
|
|
|
|
// Write a valid JSONL file
|
|
const lines = [
|
|
JSON.stringify({ type: 'session', id: 's1', timestamp: '2026-05-03T00:00:00Z', cwd: '/test' }),
|
|
JSON.stringify({
|
|
type: 'message',
|
|
id: 'm1',
|
|
parentId: null,
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() },
|
|
}),
|
|
];
|
|
fs.writeFileSync(path.join(projDir, 'session1.jsonl'), lines.join('\n'));
|
|
|
|
const result = indexAllSessions(dbManager, sessionsDir);
|
|
assert.strictEqual(result.sessionsProcessed, 1);
|
|
assert.strictEqual(result.sessionsIndexed, 1);
|
|
assert.strictEqual(result.messagesIndexed, 1);
|
|
assert.strictEqual(result.errors.length, 0);
|
|
});
|
|
|
|
it('should skip already-indexed sessions on re-run', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
const projDir = path.join(sessionsDir, 'test-project');
|
|
fs.mkdirSync(projDir, { recursive: true });
|
|
|
|
const lines = [
|
|
JSON.stringify({ type: 'session', id: 's1', timestamp: '2026-05-03T00:00:00Z', cwd: '/test' }),
|
|
JSON.stringify({
|
|
type: 'message',
|
|
id: 'm1',
|
|
parentId: null,
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() },
|
|
}),
|
|
];
|
|
fs.writeFileSync(path.join(projDir, 'session1.jsonl'), lines.join('\n'));
|
|
|
|
// First run
|
|
const result1 = indexAllSessions(dbManager, sessionsDir);
|
|
assert.strictEqual(result1.sessionsIndexed, 1);
|
|
|
|
// Second run — should skip
|
|
const result2 = indexAllSessions(dbManager, sessionsDir);
|
|
assert.strictEqual(result2.sessionsSkipped, 1);
|
|
assert.strictEqual(result2.sessionsIndexed, 0);
|
|
});
|
|
|
|
it('should handle invalid JSONL files gracefully', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
const projDir = path.join(sessionsDir, 'test-project');
|
|
fs.mkdirSync(projDir, { recursive: true });
|
|
|
|
// Invalid file (no session entry)
|
|
fs.writeFileSync(path.join(projDir, 'invalid.jsonl'), '{"type":"message","id":"m1"}');
|
|
|
|
const result = indexAllSessions(dbManager, sessionsDir);
|
|
assert.strictEqual(result.sessionsProcessed, 1);
|
|
assert.strictEqual(result.errors.length, 1);
|
|
});
|
|
|
|
it('should handle empty sessions directory', () => {
|
|
const sessionsDir = path.join(tmpDir, 'empty-sessions');
|
|
fs.mkdirSync(sessionsDir);
|
|
|
|
const result = indexAllSessions(dbManager, sessionsDir);
|
|
assert.strictEqual(result.sessionsProcessed, 0);
|
|
assert.strictEqual(result.sessionsIndexed, 0);
|
|
});
|
|
|
|
it('should handle non-existent sessions directory', () => {
|
|
const result = indexAllSessions(dbManager, '/nonexistent/path');
|
|
assert.strictEqual(result.sessionsProcessed, 0);
|
|
});
|
|
|
|
it('skips files outside the retention window and reports expiredSkipped', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
const projDir = path.join(sessionsDir, 'test-project');
|
|
fs.mkdirSync(projDir, { recursive: true });
|
|
|
|
const write = (fileName: string, sessionId: string) => {
|
|
const lines = [
|
|
JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: '/test' }),
|
|
JSON.stringify({
|
|
type: 'message',
|
|
id: `${sessionId}-m1`,
|
|
parentId: null,
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() },
|
|
}),
|
|
];
|
|
fs.writeFileSync(path.join(projDir, fileName), lines.join('\n'));
|
|
};
|
|
write('recent.jsonl', 'recent');
|
|
write('expired.jsonl', 'expired');
|
|
|
|
// Age the second file beyond the retention window.
|
|
const old = new Date('2026-01-01T00:00:00Z');
|
|
fs.utimesSync(path.join(projDir, 'expired.jsonl'), old, old);
|
|
|
|
const cutoff = Date.parse('2026-03-01T00:00:00Z');
|
|
const result = indexAllSessions(dbManager, sessionsDir, undefined, cutoff);
|
|
assert.strictEqual(result.sessionsIndexed, 1);
|
|
assert.strictEqual(result.expiredSkipped, 1);
|
|
assert.strictEqual(dbManager.getStats().sessions, 1);
|
|
|
|
// Without a cutoff the same directory indexes both files.
|
|
const result2 = indexAllSessions(dbManager, sessionsDir);
|
|
assert.strictEqual(result2.sessionsIndexed, 1);
|
|
assert.strictEqual(result2.expiredSkipped, undefined);
|
|
assert.strictEqual(dbManager.getStats().sessions, 2);
|
|
});
|
|
});
|
|
|
|
describe('indexChangedSessions', () => {
|
|
function writeJsonlSession(filePath: string, sessionId: string, messageIds = [`${sessionId}-m1`]): void {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const lines = [
|
|
JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: `/test/${sessionId}` }),
|
|
...messageIds.map((id, index) => JSON.stringify({
|
|
type: 'message',
|
|
id,
|
|
parentId: null,
|
|
timestamp: `2026-05-03T00:0${index + 1}:00Z`,
|
|
message: { role: 'user', content: [{ type: 'text', text: `Hello ${id}` }], timestamp: Date.now() },
|
|
})),
|
|
];
|
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
}
|
|
|
|
it('skips unchanged files using stored size and mtime metadata without parsing them', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl');
|
|
writeJsonlSession(filePath, 's1');
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
|
|
const result = indexChangedSessions(dbManager, sessionsDir);
|
|
|
|
assert.strictEqual(result.sessionsProcessed, 0);
|
|
assert.strictEqual(result.sessionsSkipped, 1);
|
|
assert.strictEqual(result.errors.length, 0);
|
|
});
|
|
|
|
it('indexes changed files and appends newly persisted messages', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl');
|
|
writeJsonlSession(filePath, 's1', ['s1-m1']);
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
|
|
writeJsonlSession(filePath, 's1', ['s1-m1', 's1-m2']);
|
|
const result = indexChangedSessions(dbManager, sessionsDir);
|
|
|
|
assert.strictEqual(result.sessionsProcessed, 1);
|
|
assert.strictEqual(result.sessionsIndexed, 1);
|
|
assert.strictEqual(result.messagesIndexed, 1);
|
|
assert.strictEqual(dbManager.getStats().messages, 2);
|
|
});
|
|
|
|
it('parses existing sessions without file metadata and appends missed messages', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl');
|
|
indexSession(dbManager, createTestSession({
|
|
id: 's1',
|
|
messages: [
|
|
{ id: 's1-m1', role: 'user', content: 'Hello s1-m1', timestamp: '2026-05-03T00:01:00Z' },
|
|
],
|
|
}));
|
|
writeJsonlSession(filePath, 's1', ['s1-m1', 's1-m2']);
|
|
|
|
const result = indexChangedSessions(dbManager, sessionsDir);
|
|
|
|
assert.strictEqual(result.sessionsProcessed, 1);
|
|
assert.strictEqual(result.sessionsIndexed, 1);
|
|
assert.strictEqual(result.messagesIndexed, 1);
|
|
assert.strictEqual(dbManager.getStats().messages, 2);
|
|
});
|
|
|
|
it('caps parsed files during startup incremental backfill', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(path.join(sessionsDir, 'project-a', 's1.jsonl'), 's1');
|
|
writeJsonlSession(path.join(sessionsDir, 'project-a', 's2.jsonl'), 's2');
|
|
|
|
const result = indexChangedSessions(dbManager, sessionsDir, { maxFilesToIndex: 1 });
|
|
|
|
assert.strictEqual(result.sessionsProcessed, 1);
|
|
assert.strictEqual(result.reachedLimit, true);
|
|
assert.strictEqual(dbManager.getStats().sessions, 1);
|
|
});
|
|
|
|
it('processes the most recently modified changed files first when the cap is reached', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
// Write an older file first, then a newer one. With newest-first ordering
|
|
// the newer file must be indexed even when the cap only allows one file.
|
|
const olderPath = path.join(sessionsDir, 'project-a', 'older.jsonl');
|
|
const newerPath = path.join(sessionsDir, 'project-a', 'newer.jsonl');
|
|
writeJsonlSession(olderPath, 'older');
|
|
// Ensure a measurable mtime gap (some filesystems have coarse mtime resolution).
|
|
const past = new Date(Date.now() - 60_000);
|
|
fs.utimesSync(olderPath, past, past);
|
|
writeJsonlSession(newerPath, 'newer');
|
|
|
|
const result = indexChangedSessions(dbManager, sessionsDir, { maxFilesToIndex: 1 });
|
|
|
|
assert.strictEqual(result.sessionsProcessed, 1);
|
|
assert.strictEqual(result.reachedLimit, true);
|
|
assert.strictEqual(dbManager.getStats().sessions, 1);
|
|
// The newer file should be the one indexed.
|
|
const indexed = dbManager.getDb().prepare('SELECT id FROM sessions').all() as { id: string }[];
|
|
assert.deepStrictEqual(indexed.map((r) => r.id), ['newer']);
|
|
});
|
|
});
|
|
|
|
describe('current session indexing helpers', () => {
|
|
function writeSessionFile(filePath: string): void {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const lines = [
|
|
JSON.stringify({ type: 'session', id: 'file-session-1', timestamp: '2026-05-03T00:00:00Z', cwd: '/work/file-project' }),
|
|
JSON.stringify({
|
|
type: 'message',
|
|
id: 'file-entry-1',
|
|
parentId: null,
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text: 'from persisted file' }], timestamp: Date.now() },
|
|
}),
|
|
];
|
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
}
|
|
|
|
function createSessionManagerSnapshot(entries: unknown[] = []) {
|
|
return {
|
|
getHeader: () => ({ id: 'live-session-1', timestamp: '2026-05-03T00:00:00Z', cwd: '/work/live-project' }),
|
|
getEntries: () => entries,
|
|
};
|
|
}
|
|
|
|
it('parseSessionManagerSnapshot converts current session entries into ParsedSession', () => {
|
|
const snapshot = createSessionManagerSnapshot([
|
|
{
|
|
type: 'message',
|
|
id: 'entry-1',
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: 'Hello live session' },
|
|
},
|
|
{
|
|
type: 'message',
|
|
id: 'entry-2',
|
|
timestamp: '2026-05-03T00:02:00Z',
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'Hi' }, { type: 'toolCall', name: 'read' }] },
|
|
},
|
|
{
|
|
type: 'message',
|
|
id: 'entry-3',
|
|
timestamp: '2026-05-03T00:03:00Z',
|
|
message: { role: 'toolResult', content: [{ type: 'text', text: 'tool output is not indexed by current schema' }] },
|
|
},
|
|
]);
|
|
|
|
const parsed = parseSessionManagerSnapshot(snapshot);
|
|
|
|
assert.ok(parsed);
|
|
assert.strictEqual(parsed.id, 'live-session-1');
|
|
assert.strictEqual(parsed.project, 'live-project');
|
|
assert.strictEqual(parsed.messages.length, 2);
|
|
assert.deepStrictEqual(parsed.messages[1].toolCalls, ['read']);
|
|
});
|
|
|
|
it('parseSessionManagerSnapshot skips embedded tool result content', () => {
|
|
const parsed = parseSessionManagerSnapshot(createSessionManagerSnapshot([
|
|
{
|
|
type: 'message',
|
|
id: 'entry-1',
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: {
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: 'Summary of the result.' },
|
|
{ type: 'tool_result', content: [{ type: 'text', text: 'unbounded output' }] },
|
|
],
|
|
},
|
|
},
|
|
]));
|
|
|
|
assert.ok(parsed);
|
|
assert.strictEqual(parsed.messages[0].content, 'Summary of the result.');
|
|
});
|
|
|
|
it('indexLiveSession prefers the persisted JSONL file when available', () => {
|
|
const filePath = path.join(tmpDir, 'sessions', 'project', 'file-session.jsonl');
|
|
writeSessionFile(filePath);
|
|
const snapshot = {
|
|
getHeader: () => ({ id: 'stale-memory-session', timestamp: '2026-05-03T00:00:00Z', cwd: '/work/stale' }),
|
|
getEntries: () => [],
|
|
getSessionFile: () => filePath,
|
|
};
|
|
|
|
const result = indexLiveSession(dbManager, snapshot);
|
|
|
|
assert.ok(result);
|
|
assert.strictEqual(result.sessionId, 'file-session-1');
|
|
assert.strictEqual(result.messagesIndexed, 1);
|
|
const indexed = dbManager.getDb().prepare('SELECT id, cwd FROM sessions WHERE id = ?').get('file-session-1') as { id: string; cwd: string };
|
|
assert.strictEqual(indexed.cwd, '/work/file-project');
|
|
});
|
|
|
|
it('does not index a session without persisted file provenance', () => {
|
|
const result = indexLiveSession(dbManager, {
|
|
getHeader: () => ({ id: 'ephemeral-review', timestamp: '2026-05-03T00:00:00Z', cwd: '/tmp/review' }),
|
|
getEntries: () => [{
|
|
type: 'message',
|
|
id: 'review-message',
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text: 'review prompt' }] },
|
|
}],
|
|
getSessionFile: () => undefined,
|
|
});
|
|
|
|
assert.strictEqual(result, null);
|
|
assert.strictEqual(dbManager.getStats().sessions, 0);
|
|
assert.strictEqual(dbManager.getStats().messages, 0);
|
|
});
|
|
|
|
it('removes existing file-less background review sessions', () => {
|
|
indexSession(dbManager, {
|
|
id: 'old-background-review',
|
|
project: 'review',
|
|
cwd: '/tmp/review',
|
|
startedAt: '2026-05-03T00:00:00Z',
|
|
endedAt: null,
|
|
messages: [{
|
|
id: 'review-message',
|
|
role: 'user',
|
|
content: '<file name="/tmp/pi-hermes-prompt-abc123/prompt.md">\nReview the conversation above.',
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
}],
|
|
});
|
|
|
|
assert.strictEqual(pruneEphemeralReviewSessions(dbManager), 1);
|
|
assert.strictEqual(dbManager.getStats().sessions, 0);
|
|
assert.strictEqual(dbManager.getStats().messages, 0);
|
|
});
|
|
|
|
it('indexCurrentSession indexes missing live messages idempotently', () => {
|
|
const entries = [
|
|
{
|
|
type: 'message',
|
|
id: 'entry-1',
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: 'Hello live session' },
|
|
},
|
|
];
|
|
const snapshot = createSessionManagerSnapshot(entries);
|
|
|
|
const result1 = indexCurrentSession(dbManager, snapshot);
|
|
assert.ok(result1);
|
|
assert.strictEqual(result1.messagesIndexed, 1);
|
|
assert.strictEqual(dbManager.getStats().sessions, 1);
|
|
assert.strictEqual(dbManager.getStats().messages, 1);
|
|
|
|
entries.push({
|
|
type: 'message',
|
|
id: 'entry-2',
|
|
timestamp: '2026-05-03T00:02:00Z',
|
|
message: { role: 'assistant', content: [{ type: 'text', text: 'Hi again' }] },
|
|
});
|
|
const result2 = indexCurrentSession(dbManager, snapshot);
|
|
assert.ok(result2);
|
|
assert.strictEqual(result2.messagesIndexed, 1);
|
|
assert.strictEqual(dbManager.getStats().sessions, 1);
|
|
assert.strictEqual(dbManager.getStats().messages, 2);
|
|
|
|
const result3 = indexCurrentSession(dbManager, snapshot);
|
|
assert.ok(result3);
|
|
assert.strictEqual(result3.skipped, true);
|
|
assert.strictEqual(result3.messagesIndexed, 0);
|
|
});
|
|
});
|
|
|
|
describe('backfill metadata helpers', () => {
|
|
function writeJsonlSession(sessionsDir: string, projectDir: string, sessionId: string): void {
|
|
const projDir = path.join(sessionsDir, projectDir);
|
|
fs.mkdirSync(projDir, { recursive: true });
|
|
const lines = [
|
|
JSON.stringify({ type: 'session', id: sessionId, timestamp: '2026-05-03T00:00:00Z', cwd: `/test/${projectDir}` }),
|
|
JSON.stringify({
|
|
type: 'message',
|
|
id: `${sessionId}-m1`,
|
|
parentId: null,
|
|
timestamp: '2026-05-03T00:01:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text: 'Hello' }], timestamp: Date.now() },
|
|
}),
|
|
];
|
|
fs.writeFileSync(path.join(projDir, `${sessionId}.jsonl`), lines.join('\n'));
|
|
}
|
|
|
|
it('countSessionFiles counts JSONL files in session project directories', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
writeJsonlSession(sessionsDir, 'project-b', 's2');
|
|
fs.writeFileSync(path.join(sessionsDir, 'project-b', 'notes.txt'), 'not a session');
|
|
|
|
assert.strictEqual(countSessionFiles(sessionsDir), 2);
|
|
});
|
|
|
|
it('needsBackfill is true when session file count exceeds indexed sessions', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true);
|
|
});
|
|
|
|
it('needsBackfill is false when counts match and timestamp is recent', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
touchBackfillTimestamp(dbManager, new Date('2026-05-03T00:30:00Z'));
|
|
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), false);
|
|
});
|
|
|
|
it('needsBackfill is true when file metadata changes even with a recent timestamp', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
touchBackfillTimestamp(dbManager, new Date('2026-05-03T00:30:00Z'));
|
|
|
|
fs.appendFileSync(path.join(sessionsDir, 'project-a', 's1.jsonl'), '\n' + JSON.stringify({
|
|
type: 'message',
|
|
id: 's1-m2',
|
|
parentId: null,
|
|
timestamp: '2026-05-03T00:02:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text: 'Hello again' }], timestamp: Date.now() },
|
|
}));
|
|
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true);
|
|
});
|
|
|
|
it('needsBackfill is true for existing sessions without file metadata even with a recent timestamp', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
indexSession(dbManager, createTestSession({ id: 's1', messages: [] }));
|
|
touchBackfillTimestamp(dbManager, new Date('2026-05-03T00:30:00Z'));
|
|
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true);
|
|
});
|
|
|
|
it('needsBackfill is true when timestamp is missing or older than 24 hours', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true);
|
|
|
|
touchBackfillTimestamp(dbManager, new Date('2026-05-01T00:00:00Z'));
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true);
|
|
});
|
|
|
|
it('needsBackfill returns no work when retention is enabled and every file is expired, even without a backfill timestamp', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl');
|
|
|
|
// Age the file beyond the retention window.
|
|
const old = new Date('2026-01-01T00:00:00Z');
|
|
fs.utimesSync(filePath, old, old);
|
|
|
|
const now = new Date('2026-05-03T01:00:00Z');
|
|
const cutoff = Date.parse('2026-03-01T00:00:00Z');
|
|
assert.ok(old.getTime() < cutoff, 'sanity: the file must be outside the window');
|
|
|
|
// Fresh DB: no indexed rows and no backfill timestamp, so the periodic
|
|
// timestamp check alone would demand a backfill. With retention enabled
|
|
// and no retained files, there is no work to do — this must return
|
|
// false instead of scheduling an empty backfill on every startup.
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, now, cutoff), false);
|
|
});
|
|
|
|
it('needsBackfill ignores expired files instead of using a blind file-count shortcut', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
// Two files on disk, zero indexed rows: a file-count-based shortcut
|
|
// would return true here. With retention enabled and every file
|
|
// expired, there is nothing to backfill.
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
writeJsonlSession(sessionsDir, 'project-b', 's2');
|
|
const old = new Date('2026-01-01T00:00:00Z');
|
|
fs.utimesSync(path.join(sessionsDir, 'project-a', 's1.jsonl'), old, old);
|
|
fs.utimesSync(path.join(sessionsDir, 'project-b', 's2.jsonl'), old, old);
|
|
|
|
const cutoff = Date.parse('2026-03-01T00:00:00Z');
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z'), cutoff), false);
|
|
|
|
// A retained file with no stored metadata still demands a backfill,
|
|
// even though the file count alone (2 files, 0 rows) is unchanged.
|
|
const recent = new Date('2026-04-15T00:00:00Z');
|
|
fs.utimesSync(path.join(sessionsDir, 'project-b', 's2.jsonl'), recent, recent);
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z'), cutoff), true);
|
|
});
|
|
|
|
it('needsBackfill still schedules when a retained file exists without a backfill timestamp', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl');
|
|
|
|
// File stays inside the retention window.
|
|
const recent = new Date('2026-04-15T00:00:00Z');
|
|
fs.utimesSync(filePath, recent, recent);
|
|
|
|
const now = new Date('2026-05-03T01:00:00Z');
|
|
const cutoff = Date.parse('2026-03-01T00:00:00Z');
|
|
assert.ok(recent.getTime() >= cutoff, 'sanity: the file must be inside the window');
|
|
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, now, cutoff), true);
|
|
});
|
|
|
|
it('needsBackfill keeps the periodic check active when retention is disabled', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl');
|
|
|
|
// An old file is still eligible when retention is disabled (cutoff 0).
|
|
const old = new Date('2026-01-01T00:00:00Z');
|
|
fs.utimesSync(filePath, old, old);
|
|
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date('2026-05-03T01:00:00Z')), true);
|
|
});
|
|
|
|
it('touchBackfillTimestamp upserts the metadata row', () => {
|
|
touchBackfillTimestamp(dbManager, new Date('2026-05-03T00:00:00Z'));
|
|
touchBackfillTimestamp(dbManager, new Date('2026-05-03T01:00:00Z'));
|
|
|
|
const row = dbManager.getDb().prepare('SELECT value FROM extension_metadata WHERE key = ?').get(LAST_SESSION_BACKFILL_KEY) as { value: string };
|
|
assert.strictEqual(row.value, '2026-05-03T01:00:00.000Z');
|
|
});
|
|
|
|
it('upsertSessionFileMetadata keeps stored metadata in sync after a session file is appended', () => {
|
|
// Mirrors the session_shutdown path: index the session, then upsert the
|
|
// file metadata for the final on-disk state. A subsequent
|
|
// indexChangedSessions pass must skip the file instead of re-parsing it.
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSession(sessionsDir, 'project-a', 's1');
|
|
const filePath = path.join(sessionsDir, 'project-a', 's1.jsonl');
|
|
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
// Simulate Pi appending the closing entry on shutdown.
|
|
fs.appendFileSync(filePath, '\n' + JSON.stringify({
|
|
type: 'message',
|
|
id: 's1-m2',
|
|
parentId: null,
|
|
timestamp: '2026-05-03T00:02:00Z',
|
|
message: { role: 'user', content: [{ type: 'text', text: 'Hello again' }], timestamp: Date.now() },
|
|
}));
|
|
const session = parseSessionFile(filePath);
|
|
indexSession(dbManager, session);
|
|
upsertSessionFileMetadata(dbManager, filePath, session.id);
|
|
|
|
const result = indexChangedSessions(dbManager, sessionsDir);
|
|
|
|
assert.strictEqual(result.sessionsProcessed, 0);
|
|
assert.strictEqual(result.sessionsSkipped, 1);
|
|
assert.strictEqual(result.reachedLimit, undefined);
|
|
});
|
|
});
|
|
|
|
describe('retention and backfill alignment', () => {
|
|
/** Write a session JSONL file and force its mtime to `ms` (ms epoch). */
|
|
function writeJsonlSessionAt(sessionsDir: string, sessionId: string, ms: number): string {
|
|
const filePath = path.join(sessionsDir, 'project-a', `${sessionId}.jsonl`);
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const lines = [
|
|
JSON.stringify({ type: 'session', id: sessionId, timestamp: new Date(ms).toISOString(), cwd: `/test/${sessionId}` }),
|
|
JSON.stringify({ type: 'message', id: `${sessionId}-m1`, parentId: null, timestamp: new Date(ms + 1000).toISOString(), message: { role: 'user', content: [{ type: 'text', text: `Hello ${sessionId}` }], timestamp: Date.now() } }),
|
|
];
|
|
fs.writeFileSync(filePath, lines.join('\n'));
|
|
fs.utimesSync(filePath, new Date(ms), new Date(ms));
|
|
return filePath;
|
|
}
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
const now = () => Date.now();
|
|
const cutoffFor = (days: number) => now() - days * DAY;
|
|
|
|
it('does not re-index a session file outside the retention cutoff after pruning', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
// A file last modified 60 days ago; retention window of 30 days => expired.
|
|
writeJsonlSessionAt(sessionsDir, 's1', cutoffFor(60));
|
|
const cutoff = cutoffFor(30);
|
|
|
|
// Fresh index (no cutoff) mirrors the pre-retention state, then retention
|
|
// pruning removes the session row; the on-disk file stays.
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
const pruned = pruneOldSessions(dbManager, 30);
|
|
assert.strictEqual(pruned.sessionsRemoved, 1);
|
|
assert.strictEqual(dbManager.getStats().sessions, 0);
|
|
|
|
// A later startup backfill, given the same retention cutoff, must NOT
|
|
// re-queue the expired file -- otherwise the pruned session returns.
|
|
const result = indexChangedSessions(dbManager, sessionsDir, { retentionCutoffMs: cutoff });
|
|
assert.strictEqual(result.sessionsProcessed, 0);
|
|
assert.strictEqual(result.sessionsIndexed, 0);
|
|
assert.strictEqual(result.sessionsSkipped, 0);
|
|
assert.strictEqual(dbManager.getStats().sessions, 0);
|
|
});
|
|
|
|
it('does not schedule a startup backfill when only expired files remain', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSessionAt(sessionsDir, 's1', cutoffFor(60));
|
|
const cutoff = cutoffFor(30);
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
pruneOldSessions(dbManager, 30);
|
|
touchBackfillTimestamp(dbManager);
|
|
|
|
// With every file outside the window, needsBackfill must be false so no
|
|
// backfill is scheduled on the next startup (no repeated re-indexing).
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date(), cutoff), false);
|
|
});
|
|
|
|
it('still indexes files within the retention cutoff even when others are expired', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSessionAt(sessionsDir, 'old', cutoffFor(60));
|
|
writeJsonlSessionAt(sessionsDir, 'fresh', now());
|
|
const cutoff = cutoffFor(30);
|
|
|
|
const result = indexChangedSessions(dbManager, sessionsDir, { retentionCutoffMs: cutoff });
|
|
// Only the fresh file is eligible; the expired one is skipped.
|
|
assert.strictEqual(result.sessionsProcessed, 1);
|
|
assert.strictEqual(result.sessionsIndexed, 1);
|
|
const stats = getSessionStats(dbManager);
|
|
assert.deepStrictEqual(stats.projects[0].sessions, 1); // only the retained session
|
|
assert.strictEqual(needsBackfill(dbManager, sessionsDir, new Date(), cutoff), true);
|
|
});
|
|
|
|
it('prunes only sessions whose file mtime is outside the retention window', () => {
|
|
const sessionsDir = path.join(tmpDir, 'sessions');
|
|
writeJsonlSessionAt(sessionsDir, 'old', cutoffFor(60));
|
|
writeJsonlSessionAt(sessionsDir, 'recent', now());
|
|
indexAllSessions(dbManager, sessionsDir);
|
|
|
|
const pruned = pruneOldSessions(dbManager, 30);
|
|
// 'old' has an expired file mtime and is pruned; 'recent' has a fresh
|
|
// file mtime and is kept, even though both were indexed.
|
|
assert.strictEqual(pruned.sessionsRemoved, 1);
|
|
const stats = getSessionStats(dbManager);
|
|
assert.strictEqual(stats.totalSessions, 1);
|
|
const remaining = dbManager.getDb().prepare('SELECT id FROM sessions').all() as { id: string }[];
|
|
assert.deepStrictEqual(remaining.map((r) => r.id), ['recent']);
|
|
});
|
|
});
|
|
|
|
describe('pruneOldSessions', () => {
|
|
it('removes nothing when the database is empty', () => {
|
|
const result = pruneOldSessions(dbManager, 30);
|
|
assert.deepStrictEqual(result, { sessionsRemoved: 0, messagesRemoved: 0 });
|
|
});
|
|
|
|
it('removes sessions older than the retention window and their messages', () => {
|
|
indexSession(dbManager, createTestSession({ id: 'old', startedAt: '2020-01-01T00:00:00Z' }));
|
|
indexSession(dbManager, createTestSession({ id: 'recent', startedAt: new Date().toISOString() }));
|
|
|
|
const result = pruneOldSessions(dbManager, 30);
|
|
assert.strictEqual(result.sessionsRemoved, 1);
|
|
assert.strictEqual(result.messagesRemoved, 2); // old session has 2 messages
|
|
|
|
const stats = getSessionStats(dbManager);
|
|
assert.strictEqual(stats.totalSessions, 1);
|
|
assert.strictEqual(stats.totalMessages, 2);
|
|
const remaining = dbManager.getDb().prepare('SELECT id FROM sessions').all() as { id: string }[];
|
|
assert.deepStrictEqual(remaining.map((r) => r.id), ['recent']);
|
|
});
|
|
|
|
it('leaves sessions exactly at the retention boundary (not strictly older) untouched', () => {
|
|
const now = Date.now();
|
|
const justWithin = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(); // 10 days ago
|
|
indexSession(dbManager, createTestSession({ id: 'within', startedAt: justWithin }));
|
|
|
|
const result = pruneOldSessions(dbManager, 30);
|
|
assert.strictEqual(result.sessionsRemoved, 0);
|
|
assert.strictEqual(dbManager.getStats().sessions, 1);
|
|
});
|
|
|
|
it('is idempotent across repeated calls', () => {
|
|
indexSession(dbManager, createTestSession({ id: 'old', startedAt: '2020-01-01T00:00:00Z' }));
|
|
|
|
pruneOldSessions(dbManager, 30);
|
|
const again = pruneOldSessions(dbManager, 30);
|
|
assert.deepStrictEqual(again, { sessionsRemoved: 0, messagesRemoved: 0 });
|
|
});
|
|
});
|
|
|
|
describe('getSessionStats', () => {
|
|
it('should return zero counts for empty database', () => {
|
|
const stats = getSessionStats(dbManager);
|
|
assert.strictEqual(stats.totalSessions, 0);
|
|
assert.strictEqual(stats.totalMessages, 0);
|
|
assert.deepStrictEqual(stats.projects, []);
|
|
});
|
|
|
|
it('should return correct stats after indexing', () => {
|
|
const session = createTestSession();
|
|
indexSession(dbManager, session);
|
|
|
|
const stats = getSessionStats(dbManager);
|
|
assert.strictEqual(stats.totalSessions, 1);
|
|
assert.strictEqual(stats.totalMessages, 2);
|
|
assert.strictEqual(stats.projects.length, 1);
|
|
assert.strictEqual(stats.projects[0].project, 'test-project');
|
|
assert.strictEqual(stats.projects[0].sessions, 1);
|
|
assert.strictEqual(stats.projects[0].messages, 2);
|
|
});
|
|
|
|
it('should group by project', () => {
|
|
indexSession(dbManager, createTestSession({ id: 's1', project: 'project-a' }));
|
|
indexSession(dbManager, createTestSession({ id: 's2', project: 'project-a' }));
|
|
indexSession(dbManager, createTestSession({ id: 's3', project: 'project-b' }));
|
|
|
|
const stats = getSessionStats(dbManager);
|
|
assert.strictEqual(stats.totalSessions, 3);
|
|
assert.strictEqual(stats.projects.length, 2);
|
|
|
|
const projA = stats.projects.find(p => p.project === 'project-a');
|
|
const projB = stats.projects.find(p => p.project === 'project-b');
|
|
assert.ok(projA);
|
|
assert.ok(projB);
|
|
assert.strictEqual(projA.sessions, 2);
|
|
assert.strictEqual(projB.sessions, 1);
|
|
});
|
|
});
|
|
});
|