pi-memory-stone 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/package.json +23 -0
- package/src/commands/index.ts +234 -0
- package/src/config/index.ts +108 -0
- package/src/db/index.ts +620 -0
- package/src/db/schema.ts +161 -0
- package/src/index.ts +197 -0
- package/src/indexing/index.ts +207 -0
- package/src/indexing/parser.ts +374 -0
- package/src/privacy/index.ts +167 -0
- package/src/retrieval/index.ts +219 -0
- package/src/tools/index.ts +257 -0
- package/test/indexing.test.ts +97 -0
- package/test/parser.test.ts +261 -0
- package/test/privacy.test.ts +120 -0
- package/test/ranking.test.ts +403 -0
- package/tsconfig.json +13 -0
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Database schema for pi-memory-stone.
|
|
3
|
+
* Versioned migrations applied sequentially.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SCHEMA_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export interface Migration {
|
|
9
|
+
version: number;
|
|
10
|
+
name: string;
|
|
11
|
+
sql: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const MIGRATIONS: Migration[] = [
|
|
15
|
+
{
|
|
16
|
+
version: 1,
|
|
17
|
+
name: "initial-schema",
|
|
18
|
+
sql: `
|
|
19
|
+
-- Track indexed sessions
|
|
20
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
21
|
+
id TEXT PRIMARY KEY,
|
|
22
|
+
session_file TEXT UNIQUE,
|
|
23
|
+
cwd TEXT,
|
|
24
|
+
repo_root TEXT,
|
|
25
|
+
project_id TEXT,
|
|
26
|
+
session_name TEXT,
|
|
27
|
+
created_at INTEGER NOT NULL,
|
|
28
|
+
updated_at INTEGER NOT NULL,
|
|
29
|
+
source_status TEXT NOT NULL DEFAULT 'active',
|
|
30
|
+
file_mtime INTEGER,
|
|
31
|
+
file_size INTEGER,
|
|
32
|
+
schema_version INTEGER NOT NULL DEFAULT 1
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
-- Structured memory records
|
|
36
|
+
CREATE TABLE IF NOT EXISTS records (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
kind TEXT NOT NULL,
|
|
39
|
+
scope TEXT NOT NULL DEFAULT 'project',
|
|
40
|
+
project_id TEXT,
|
|
41
|
+
session_id TEXT,
|
|
42
|
+
session_file TEXT,
|
|
43
|
+
branch_leaf_id TEXT,
|
|
44
|
+
entry_id_start TEXT,
|
|
45
|
+
entry_id_end TEXT,
|
|
46
|
+
text TEXT NOT NULL,
|
|
47
|
+
tags TEXT,
|
|
48
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
49
|
+
confidence REAL NOT NULL DEFAULT 1.0,
|
|
50
|
+
importance REAL NOT NULL DEFAULT 0.5,
|
|
51
|
+
created_at INTEGER NOT NULL,
|
|
52
|
+
updated_at INTEGER NOT NULL,
|
|
53
|
+
superseded_by TEXT,
|
|
54
|
+
derived_from_memory_refs TEXT
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
-- Full-text search index (contentless — manually synced)
|
|
58
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS record_fts USING fts5(
|
|
59
|
+
text,
|
|
60
|
+
tags
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
-- File activity tracking
|
|
64
|
+
CREATE TABLE IF NOT EXISTS file_activity (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
record_id TEXT,
|
|
67
|
+
project_id TEXT,
|
|
68
|
+
path TEXT NOT NULL,
|
|
69
|
+
action TEXT NOT NULL,
|
|
70
|
+
entry_id TEXT
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
-- Injection audit log
|
|
74
|
+
CREATE TABLE IF NOT EXISTS injections (
|
|
75
|
+
id TEXT PRIMARY KEY,
|
|
76
|
+
session_id TEXT,
|
|
77
|
+
turn_entry_id TEXT,
|
|
78
|
+
prompt_hash TEXT,
|
|
79
|
+
injected_refs TEXT,
|
|
80
|
+
packet TEXT,
|
|
81
|
+
reasons TEXT,
|
|
82
|
+
created_at INTEGER NOT NULL
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
-- Per-session-file indexing progress
|
|
86
|
+
CREATE TABLE IF NOT EXISTS index_state (
|
|
87
|
+
session_file TEXT PRIMARY KEY,
|
|
88
|
+
session_id TEXT,
|
|
89
|
+
last_indexed_entry_id TEXT,
|
|
90
|
+
last_indexed_entry_timestamp TEXT,
|
|
91
|
+
file_mtime INTEGER,
|
|
92
|
+
file_size INTEGER,
|
|
93
|
+
branch_leaf_id TEXT,
|
|
94
|
+
schema_version INTEGER NOT NULL DEFAULT 1
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
-- Background job queue
|
|
98
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
99
|
+
id TEXT PRIMARY KEY,
|
|
100
|
+
type TEXT NOT NULL,
|
|
101
|
+
payload TEXT,
|
|
102
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
103
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
last_error TEXT,
|
|
105
|
+
created_at INTEGER NOT NULL,
|
|
106
|
+
updated_at INTEGER NOT NULL
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
-- Schema migration tracking
|
|
110
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
111
|
+
version INTEGER PRIMARY KEY,
|
|
112
|
+
applied_at INTEGER NOT NULL,
|
|
113
|
+
name TEXT NOT NULL
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
-- Indexes
|
|
117
|
+
CREATE INDEX IF NOT EXISTS idx_records_project ON records(project_id);
|
|
118
|
+
CREATE INDEX IF NOT EXISTS idx_records_kind ON records(kind);
|
|
119
|
+
CREATE INDEX IF NOT EXISTS idx_records_scope ON records(scope);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_records_session ON records(session_id);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_records_status ON records(status);
|
|
122
|
+
CREATE INDEX IF NOT EXISTS idx_file_activity_record ON file_activity(record_id);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_file_activity_path ON file_activity(path);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_file_activity_project ON file_activity(project_id);
|
|
125
|
+
CREATE INDEX IF NOT EXISTS idx_injections_session ON injections(session_id);
|
|
126
|
+
`,
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
/** Record kinds */
|
|
131
|
+
export const RECORD_KINDS = [
|
|
132
|
+
"session_summary",
|
|
133
|
+
"turn_summary",
|
|
134
|
+
"decision",
|
|
135
|
+
"preference",
|
|
136
|
+
"task",
|
|
137
|
+
"error_resolution",
|
|
138
|
+
"file_activity",
|
|
139
|
+
] as const;
|
|
140
|
+
|
|
141
|
+
export type RecordKind = (typeof RECORD_KINDS)[number];
|
|
142
|
+
|
|
143
|
+
/** Record scopes */
|
|
144
|
+
export const RECORD_SCOPES = ["project", "global"] as const;
|
|
145
|
+
export type RecordScope = (typeof RECORD_SCOPES)[number];
|
|
146
|
+
|
|
147
|
+
/** Record statuses */
|
|
148
|
+
export const RECORD_STATUSES = ["active", "soft_forgotten", "hard_forgotten", "superseded"] as const;
|
|
149
|
+
export type RecordStatus = (typeof RECORD_STATUSES)[number];
|
|
150
|
+
|
|
151
|
+
/** Source statuses */
|
|
152
|
+
export const SOURCE_STATUSES = ["active", "missing", "archived"] as const;
|
|
153
|
+
export type SourceStatus = (typeof SOURCE_STATUSES)[number];
|
|
154
|
+
|
|
155
|
+
/** Job statuses */
|
|
156
|
+
export const JOB_STATUSES = ["pending", "running", "done", "failed"] as const;
|
|
157
|
+
export type JobStatus = (typeof JOB_STATUSES)[number];
|
|
158
|
+
|
|
159
|
+
/** File action types */
|
|
160
|
+
export const FILE_ACTIONS = ["read", "write", "edit", "bash", "delete"] as const;
|
|
161
|
+
export type FileAction = (typeof FILE_ACTIONS)[number];
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-memory-stone — Global pi extension for session memory.
|
|
3
|
+
*
|
|
4
|
+
* Preserves and retrieves useful memory across pi sessions.
|
|
5
|
+
* Builds a searchable SQLite+FTS5 index with backreferences to session entries.
|
|
6
|
+
*
|
|
7
|
+
* Vertical slice MVP:
|
|
8
|
+
* - SQLite schema + migrations
|
|
9
|
+
* - Deterministic turn_summary and file_activity capture on agent_end
|
|
10
|
+
* - FTS5 search
|
|
11
|
+
* - /memory-status, /memory-search, /memory-last commands
|
|
12
|
+
* - memory_search, memory_open, memory_remember, memory_forget tools
|
|
13
|
+
* - Conservative same-project before_agent_start injection
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { registerCommands } from "./commands/index.js";
|
|
18
|
+
import { registerTools } from "./tools/index.js";
|
|
19
|
+
import { indexSessionOnAgentEnd } from "./indexing/index.js";
|
|
20
|
+
import { retrieve, buildInjectionPacket, formatInjectionForLlm } from "./retrieval/index.js";
|
|
21
|
+
import { getProjectId, getConfig, clearProjectCache } from "./config/index.js";
|
|
22
|
+
import { closeDb } from "./db/index.js";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
|
|
25
|
+
// ─── Session-scoped state ───────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Track injected refs per turn to prevent feedback loops */
|
|
28
|
+
const injectedRefsThisSession: Set<string> = new Set();
|
|
29
|
+
|
|
30
|
+
/** Whether memory injection is temporarily disabled for this session */
|
|
31
|
+
let sessionEnabled = true;
|
|
32
|
+
|
|
33
|
+
// ─── Extension entry point ─────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export default function (pi: ExtensionAPI) {
|
|
36
|
+
// ── Register commands ──────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
registerCommands(pi);
|
|
39
|
+
|
|
40
|
+
// ── Register tools ─────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
registerTools(pi);
|
|
43
|
+
|
|
44
|
+
// ── agent_end: index session turn ──────────────────────────────
|
|
45
|
+
|
|
46
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
47
|
+
try {
|
|
48
|
+
const { recordsCreated, errors } = await indexSessionOnAgentEnd(ctx, event);
|
|
49
|
+
|
|
50
|
+
if (errors.length > 0 && ctx.hasUI) {
|
|
51
|
+
// Log errors but don't flood UI
|
|
52
|
+
for (const err of errors.slice(0, 2)) {
|
|
53
|
+
console.error("[pi-memory-stone] indexing error:", err);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Optionally notify on first indexing
|
|
58
|
+
if (recordsCreated > 0 && ctx.hasUI) {
|
|
59
|
+
// Silent by default; uncomment for verbose mode:
|
|
60
|
+
// ctx.ui.setStatus("memory-stone", `Indexed ${recordsCreated} records`);
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error("[pi-memory-stone] agent_end handler error:", err);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ── before_agent_start: inject relevant memories ───────────────
|
|
68
|
+
|
|
69
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
70
|
+
try {
|
|
71
|
+
// Check if memory is enabled
|
|
72
|
+
const config = getConfig(ctx.cwd);
|
|
73
|
+
if (!config.enabled) return;
|
|
74
|
+
|
|
75
|
+
// Check for session toggle entries before honoring the cached toggle so
|
|
76
|
+
// /memory-on can re-enable injection after /memory-off in the same session.
|
|
77
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
78
|
+
if (
|
|
79
|
+
entry.type === "custom" &&
|
|
80
|
+
entry.customType === "memory-stone:session-toggle"
|
|
81
|
+
) {
|
|
82
|
+
const data = entry.data as { enabled?: boolean } | undefined;
|
|
83
|
+
if (typeof data?.enabled === "boolean") {
|
|
84
|
+
sessionEnabled = data.enabled;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!sessionEnabled) return;
|
|
90
|
+
|
|
91
|
+
const prompt = event.prompt || "";
|
|
92
|
+
if (!prompt.trim()) return;
|
|
93
|
+
|
|
94
|
+
// Hash prompt to detect repeated injections
|
|
95
|
+
const promptHash = createHash("sha256").update(prompt).digest("hex").slice(0, 12);
|
|
96
|
+
|
|
97
|
+
const projectId = getProjectId(ctx.cwd);
|
|
98
|
+
|
|
99
|
+
// Build search query
|
|
100
|
+
const results = retrieve(prompt, projectId, [], {
|
|
101
|
+
limit: config.maxInjectedRecords,
|
|
102
|
+
crossProjectEnabled: config.crossProjectEnabled,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Filter: skip already-injected refs
|
|
106
|
+
const newResults = results.filter((r) => !injectedRefsThisSession.has(r.record.id));
|
|
107
|
+
|
|
108
|
+
// Score threshold
|
|
109
|
+
const thresholdResults = newResults.filter((r) => r.score >= config.scoreThreshold);
|
|
110
|
+
|
|
111
|
+
if (thresholdResults.length === 0) return;
|
|
112
|
+
|
|
113
|
+
// Build and format injection packet
|
|
114
|
+
const packet = buildInjectionPacket(thresholdResults);
|
|
115
|
+
const formatted = formatInjectionForLlm(packet, config.maxInjectedTokens);
|
|
116
|
+
|
|
117
|
+
// Track injected refs (prevent feedback loop)
|
|
118
|
+
for (const r of thresholdResults) {
|
|
119
|
+
injectedRefsThisSession.add(r.record.id);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Log injection to DB
|
|
123
|
+
const { insertInjection } = await import("./db/index.js");
|
|
124
|
+
insertInjection({
|
|
125
|
+
session_id: ctx.sessionManager.getSessionId(),
|
|
126
|
+
turn_entry_id: ctx.sessionManager.getLeafId() ?? undefined,
|
|
127
|
+
prompt_hash: promptHash,
|
|
128
|
+
injected_refs: thresholdResults.map((r) => r.record.id).join(","),
|
|
129
|
+
packet: formatted,
|
|
130
|
+
reasons: thresholdResults.map((r) => r.reasons.join(";")).join(" | "),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Inject as a non-context audit custom entry (separate from LLM context)
|
|
134
|
+
// but also as a system prompt addition for the LLM
|
|
135
|
+
const systemPromptAddition = [
|
|
136
|
+
"",
|
|
137
|
+
"--- Memory Stone Context ---",
|
|
138
|
+
formatted,
|
|
139
|
+
"--- End Memory Stone Context ---",
|
|
140
|
+
].join("\n");
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
systemPrompt: (event.systemPrompt || "") + systemPromptAddition,
|
|
144
|
+
};
|
|
145
|
+
} catch (err) {
|
|
146
|
+
console.error("[pi-memory-stone] before_agent_start handler error:", err);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// ── session_start: restore state ───────────────────────────────
|
|
151
|
+
|
|
152
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
153
|
+
try {
|
|
154
|
+
// Clear session-scoped state
|
|
155
|
+
injectedRefsThisSession.clear();
|
|
156
|
+
sessionEnabled = true;
|
|
157
|
+
|
|
158
|
+
// Restore session toggle from branch
|
|
159
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
160
|
+
if (
|
|
161
|
+
entry.type === "custom" &&
|
|
162
|
+
entry.customType === "memory-stone:session-toggle"
|
|
163
|
+
) {
|
|
164
|
+
const data = entry.data as { enabled?: boolean } | undefined;
|
|
165
|
+
if (typeof data?.enabled === "boolean") {
|
|
166
|
+
sessionEnabled = data.enabled;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Clear project ID cache on session change
|
|
172
|
+
clearProjectCache();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error("[pi-memory-stone] session_start handler error:", err);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── session_shutdown: cleanup ──────────────────────────────────
|
|
179
|
+
|
|
180
|
+
pi.on("session_shutdown", async (_event, _ctx) => {
|
|
181
|
+
try {
|
|
182
|
+
// Best-effort flush — DB is WAL mode so writes are already durable.
|
|
183
|
+
// Cleanup any pending state.
|
|
184
|
+
injectedRefsThisSession.clear();
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error("[pi-memory-stone] session_shutdown handler error:", err);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// ── Cleanup when extension is unloaded ─────────────────────────
|
|
191
|
+
|
|
192
|
+
// Note: pi doesn't have an explicit unload hook, but session_shutdown
|
|
193
|
+
// fires before reload. For process exit, Node handles file descriptor cleanup.
|
|
194
|
+
process.on("exit", () => {
|
|
195
|
+
closeDb();
|
|
196
|
+
});
|
|
197
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Indexing engine: processes session entries on agent_end and stores structured records.
|
|
3
|
+
* Deterministic-only for MVP; LLM extraction deferred.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { statSync } from "node:fs";
|
|
7
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
upsertSession,
|
|
10
|
+
upsertRecord,
|
|
11
|
+
insertFileActivity,
|
|
12
|
+
getIndexState,
|
|
13
|
+
upsertIndexState,
|
|
14
|
+
contentHash,
|
|
15
|
+
} from "../db/index.js";
|
|
16
|
+
import { parseEntries, turnsToRecords, type ParsedFileActivity } from "./parser.js";
|
|
17
|
+
import { getProjectId } from "../config/index.js";
|
|
18
|
+
|
|
19
|
+
// ─── Types for agent_end data ───────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
type AgentEndEvent = unknown;
|
|
22
|
+
|
|
23
|
+
// ─── Index a session from agent_end ─────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export async function indexSessionOnAgentEnd(
|
|
26
|
+
ctx: ExtensionContext,
|
|
27
|
+
_event: AgentEndEvent,
|
|
28
|
+
): Promise<{ recordsCreated: number; errors: string[] }> {
|
|
29
|
+
const errors: string[] = [];
|
|
30
|
+
let recordsCreated = 0;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const sessionManager = ctx.sessionManager;
|
|
34
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
35
|
+
|
|
36
|
+
// Non-persisted sessions: skip indexing (plan: do not index ephemeral)
|
|
37
|
+
if (!sessionFile) {
|
|
38
|
+
return { recordsCreated: 0, errors: [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sessionId = sessionManager.getSessionId();
|
|
42
|
+
const projectId = getProjectId(ctx.cwd);
|
|
43
|
+
const cwd = ctx.cwd;
|
|
44
|
+
const branch = sessionManager.getBranch() as unknown as Parameters<typeof parseEntries>[0];
|
|
45
|
+
const leafId = sessionManager.getLeafId();
|
|
46
|
+
|
|
47
|
+
// Get file info
|
|
48
|
+
let fileMtime: number | null = null;
|
|
49
|
+
let fileSize: number | null = null;
|
|
50
|
+
try {
|
|
51
|
+
const stat = statSync(sessionFile);
|
|
52
|
+
fileMtime = stat.mtimeMs;
|
|
53
|
+
fileSize = stat.size;
|
|
54
|
+
} catch {
|
|
55
|
+
// File may not exist (in-memory session), that's fine
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Check index state
|
|
59
|
+
const prevState = getIndexState(sessionFile);
|
|
60
|
+
const lastIndexedId = prevState?.last_indexed_entry_id ?? null;
|
|
61
|
+
|
|
62
|
+
// Find new entries since last index. Prefer timestamps so this stays correct
|
|
63
|
+
// even if SessionManager branch ordering changes; fall back to root-to-leaf slicing.
|
|
64
|
+
const lastIndexedTimestamp = prevState?.last_indexed_entry_timestamp
|
|
65
|
+
? Date.parse(prevState.last_indexed_entry_timestamp)
|
|
66
|
+
: NaN;
|
|
67
|
+
const lastIndexedIndex = lastIndexedId
|
|
68
|
+
? branch.findIndex((entry) => entry.id === lastIndexedId)
|
|
69
|
+
: -1;
|
|
70
|
+
let newEntries = branch;
|
|
71
|
+
if (lastIndexedId) {
|
|
72
|
+
if (Number.isFinite(lastIndexedTimestamp)) {
|
|
73
|
+
const timestampEntries = branch.filter((entry) => {
|
|
74
|
+
const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
75
|
+
return Number.isFinite(ts) && ts > lastIndexedTimestamp;
|
|
76
|
+
});
|
|
77
|
+
newEntries = timestampEntries.length > 0 || lastIndexedIndex < 0
|
|
78
|
+
? timestampEntries
|
|
79
|
+
: branch.slice(lastIndexedIndex + 1);
|
|
80
|
+
} else {
|
|
81
|
+
newEntries = lastIndexedIndex >= 0 ? branch.slice(lastIndexedIndex + 1) : branch;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (newEntries.length === 0) {
|
|
86
|
+
// Update session metadata even if no new entries
|
|
87
|
+
upsertSession({
|
|
88
|
+
id: sessionId,
|
|
89
|
+
session_file: sessionFile,
|
|
90
|
+
cwd,
|
|
91
|
+
project_id: projectId,
|
|
92
|
+
file_mtime: fileMtime,
|
|
93
|
+
file_size: fileSize,
|
|
94
|
+
});
|
|
95
|
+
upsertIndexState({
|
|
96
|
+
session_file: sessionFile,
|
|
97
|
+
session_id: sessionId,
|
|
98
|
+
last_indexed_entry_id: prevState?.last_indexed_entry_id ?? undefined,
|
|
99
|
+
last_indexed_entry_timestamp: new Date().toISOString(),
|
|
100
|
+
file_mtime: fileMtime,
|
|
101
|
+
file_size: fileSize,
|
|
102
|
+
branch_leaf_id: leafId ?? undefined,
|
|
103
|
+
});
|
|
104
|
+
return { recordsCreated: 0, errors: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Parse new entries
|
|
108
|
+
const { turns, fileActivities } = parseEntries(newEntries);
|
|
109
|
+
|
|
110
|
+
// Convert to records
|
|
111
|
+
const recordPayloads = turnsToRecords(turns, projectId, sessionId, sessionFile);
|
|
112
|
+
|
|
113
|
+
// Upsert session
|
|
114
|
+
upsertSession({
|
|
115
|
+
id: sessionId,
|
|
116
|
+
session_file: sessionFile,
|
|
117
|
+
cwd,
|
|
118
|
+
project_id: projectId,
|
|
119
|
+
file_mtime: fileMtime,
|
|
120
|
+
file_size: fileSize,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Store records
|
|
124
|
+
for (const payload of recordPayloads) {
|
|
125
|
+
try {
|
|
126
|
+
const recordId = upsertRecord({
|
|
127
|
+
kind: payload.kind,
|
|
128
|
+
scope: payload.scope,
|
|
129
|
+
project_id: projectId,
|
|
130
|
+
session_id: sessionId,
|
|
131
|
+
session_file: sessionFile,
|
|
132
|
+
branch_leaf_id: leafId ?? undefined,
|
|
133
|
+
entry_id_start: payload.entryIdStart,
|
|
134
|
+
entry_id_end: payload.entryIdEnd,
|
|
135
|
+
text: payload.text,
|
|
136
|
+
tags: payload.tags,
|
|
137
|
+
});
|
|
138
|
+
recordsCreated++;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
errors.push(`Failed to store record: ${err instanceof Error ? err.message : String(err)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Store file activity
|
|
145
|
+
for (const fa of fileActivities) {
|
|
146
|
+
try {
|
|
147
|
+
insertFileActivity({
|
|
148
|
+
record_id: contentHash(
|
|
149
|
+
`file:${fa.path}:${fa.action}`,
|
|
150
|
+
"file_activity",
|
|
151
|
+
),
|
|
152
|
+
project_id: projectId,
|
|
153
|
+
path: fa.path,
|
|
154
|
+
action: fa.action,
|
|
155
|
+
entry_id: fa.entryId,
|
|
156
|
+
});
|
|
157
|
+
} catch (err) {
|
|
158
|
+
// Non-critical: file activity insertion failure is logged but not fatal
|
|
159
|
+
errors.push(`Failed to store file activity: ${err instanceof Error ? err.message : String(err)}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Update index state
|
|
164
|
+
const lastEntry = newEntries.reduce((latest, entry) => {
|
|
165
|
+
const latestTs = latest?.timestamp ? Date.parse(latest.timestamp) : NaN;
|
|
166
|
+
const entryTs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
167
|
+
if (!Number.isFinite(latestTs) && !Number.isFinite(entryTs)) return entry;
|
|
168
|
+
if (!Number.isFinite(latestTs)) return entry;
|
|
169
|
+
if (!Number.isFinite(entryTs)) return latest;
|
|
170
|
+
return entryTs > latestTs ? entry : latest;
|
|
171
|
+
}, newEntries[0]);
|
|
172
|
+
upsertIndexState({
|
|
173
|
+
session_file: sessionFile,
|
|
174
|
+
session_id: sessionId,
|
|
175
|
+
last_indexed_entry_id: lastEntry?.id ?? prevState?.last_indexed_entry_id ?? undefined,
|
|
176
|
+
last_indexed_entry_timestamp: lastEntry?.timestamp ?? new Date().toISOString(),
|
|
177
|
+
file_mtime: fileMtime,
|
|
178
|
+
file_size: fileSize,
|
|
179
|
+
branch_leaf_id: leafId ?? undefined,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
} catch (err) {
|
|
183
|
+
errors.push(`Indexing failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { recordsCreated, errors };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Backfill (for /memory-backfill command) ────────────────────────
|
|
190
|
+
|
|
191
|
+
export async function backfillSession(
|
|
192
|
+
sessionFile: string,
|
|
193
|
+
ctx: ExtensionContext,
|
|
194
|
+
): Promise<{ recordsCreated: number; errors: string[] }> {
|
|
195
|
+
const errors: string[] = [];
|
|
196
|
+
let recordsCreated = 0;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
// For backfill we need raw JSONL access. We use SessionManager.open for that.
|
|
200
|
+
// But in the MVP, we process via agent_end. Backfill is deferred.
|
|
201
|
+
// This is a placeholder for future implementation.
|
|
202
|
+
} catch (err) {
|
|
203
|
+
errors.push(`Backfill failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { recordsCreated, errors };
|
|
207
|
+
}
|