pi-hermes-memory 0.7.8 → 0.7.10
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/README.md +21 -17
- package/package.json +1 -1
- package/src/config.ts +14 -1
- package/src/extension-root-migration.ts +101 -0
- package/src/handlers/index-sessions.ts +1 -1
- package/src/handlers/skills-command.ts +544 -75
- package/src/index.ts +37 -11
- package/src/project-memory-migration.ts +1 -1
- package/src/store/memory-store.ts +1 -1
- package/src/store/session-anchor-search.ts +472 -0
- package/src/store/skill-store.ts +142 -43
- package/src/store/skill-utils.ts +23 -6
- package/src/tools/session-search-tool.ts +106 -1
- package/src/types.ts +10 -5
package/src/index.ts
CHANGED
|
@@ -51,18 +51,20 @@ import { loadConfig } from "./config.js";
|
|
|
51
51
|
import { detectProject, detectProjectSkills } from "./project.js";
|
|
52
52
|
import { buildPromptContext } from "./prompt-context.js";
|
|
53
53
|
import { migrateLegacyProjectMemoryDirs } from "./project-memory-migration.js";
|
|
54
|
+
import { migrateExtensionRoot } from "./extension-root-migration.js";
|
|
54
55
|
|
|
55
56
|
export function resolveProjectSkillDiscovery(
|
|
56
57
|
skillStore: SkillStore,
|
|
57
58
|
projectsMemoryDir: string | undefined,
|
|
58
59
|
cwd?: string,
|
|
59
|
-
): { skillPaths: string[] }
|
|
60
|
+
): { skillPaths: string[] } {
|
|
60
61
|
const detected = detectProjectSkills(projectsMemoryDir, cwd);
|
|
61
62
|
skillStore.setProjectContext(detected.name, detected.skillsDir);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
const skillPaths = [skillStore.getGlobalSkillsDir()];
|
|
65
|
+
if (detected.skillsDir) skillPaths.push(detected.skillsDir);
|
|
66
|
+
|
|
67
|
+
return { skillPaths };
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export function registerProjectSkillDiscoveryHandler(
|
|
@@ -78,17 +80,32 @@ export function registerProjectSkillDiscoveryHandler(
|
|
|
78
80
|
export default function (pi: ExtensionAPI) {
|
|
79
81
|
const config = loadConfig();
|
|
80
82
|
|
|
81
|
-
const globalDir = config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
|
|
82
83
|
const agentRoot = path.join(os.homedir(), ".pi", "agent");
|
|
83
|
-
const
|
|
84
|
+
const legacyGlobalDir = path.join(agentRoot, "memory");
|
|
85
|
+
const defaultGlobalDir = path.join(agentRoot, "pi-hermes-memory");
|
|
86
|
+
|
|
87
|
+
const configuredMemoryDir = config.memoryDir?.trim();
|
|
88
|
+
const pointsToLegacyMemoryDir = configuredMemoryDir
|
|
89
|
+
? path.resolve(configuredMemoryDir) === path.resolve(legacyGlobalDir)
|
|
90
|
+
: false;
|
|
91
|
+
|
|
92
|
+
const globalDir = !configuredMemoryDir || pointsToLegacyMemoryDir
|
|
93
|
+
? defaultGlobalDir
|
|
94
|
+
: configuredMemoryDir;
|
|
95
|
+
|
|
96
|
+
const shouldMigrateExtensionRoot = !configuredMemoryDir || pointsToLegacyMemoryDir;
|
|
97
|
+
let extensionRootMigrated = false;
|
|
98
|
+
|
|
99
|
+
const store = new MemoryStore({ ...config, memoryDir: globalDir });
|
|
84
100
|
const project = detectProject(config.projectsMemoryDir);
|
|
85
101
|
const projectName = project.name ?? "";
|
|
86
102
|
const skillStore = new SkillStore({
|
|
87
|
-
globalSkillsDir: path.join(
|
|
103
|
+
globalSkillsDir: path.join(globalDir, "skills"),
|
|
88
104
|
projectSkillsDir: project.memoryDir ? path.join(project.memoryDir, "skills") : null,
|
|
89
105
|
projectName: project.name,
|
|
90
|
-
legacySkillsDir: path.join(
|
|
91
|
-
|
|
106
|
+
legacySkillsDir: path.join(legacyGlobalDir, "skills"),
|
|
107
|
+
legacyPiGlobalSkillsDir: path.join(agentRoot, "skills"),
|
|
108
|
+
migrationSentinelPath: path.join(globalDir, ".skills-migrated-to-extension-storage"),
|
|
92
109
|
});
|
|
93
110
|
const dbManager = new DatabaseManager(globalDir);
|
|
94
111
|
|
|
@@ -120,6 +137,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
137
|
|
|
121
138
|
// ── 1. Load memory from disk on session start ──
|
|
122
139
|
pi.on("session_start", async (event, _ctx) => {
|
|
140
|
+
if (shouldMigrateExtensionRoot && !extensionRootMigrated) {
|
|
141
|
+
try {
|
|
142
|
+
await migrateExtensionRoot(legacyGlobalDir, globalDir);
|
|
143
|
+
} catch {
|
|
144
|
+
// best effort migration only
|
|
145
|
+
}
|
|
146
|
+
extensionRootMigrated = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
123
149
|
refreshSkillProjectContext((event as { cwd?: string }).cwd);
|
|
124
150
|
await skillStore.migrateLegacySkills();
|
|
125
151
|
await skillStore.ensureDiscoveredRoots();
|
|
@@ -174,7 +200,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
174
200
|
registerPreviewContextCommand(pi, store, projectStore, projectName, config);
|
|
175
201
|
|
|
176
202
|
// ── 11. SQLite session search + extended memory ──
|
|
177
|
-
registerSessionSearchTool(pi, dbManager);
|
|
203
|
+
registerSessionSearchTool(pi, dbManager, config.sessionSearch ?? { variant: "legacy" });
|
|
178
204
|
registerMemorySearchTool(pi, dbManager);
|
|
179
205
|
registerIndexSessionsCommand(pi);
|
|
180
206
|
|
|
@@ -23,7 +23,7 @@ function writeEntries(filePath: string, entries: string[]): void {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
function isLegacyProjectDir(agentRoot: string, projectsMemoryDir: string, name: string): boolean {
|
|
26
|
-
if (name === "memory" || name === "skills" || name === projectsMemoryDir) return false;
|
|
26
|
+
if (name === "memory" || name === "pi-hermes-memory" || name === "skills" || name === projectsMemoryDir) return false;
|
|
27
27
|
if (name.startsWith(".")) return false;
|
|
28
28
|
|
|
29
29
|
const dir = path.join(agentRoot, name);
|
|
@@ -46,7 +46,7 @@ export class MemoryStore {
|
|
|
46
46
|
// ─── Path helpers ───
|
|
47
47
|
|
|
48
48
|
private get memoryDir(): string {
|
|
49
|
-
return this.config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "memory");
|
|
49
|
+
return this.config.memoryDir ?? path.join(os.homedir(), ".pi", "agent", "pi-hermes-memory");
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
private pathFor(target: "memory" | "user" | "failure"): string {
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_LIMIT = 50;
|
|
5
|
+
const MAX_LIMIT = 100;
|
|
6
|
+
const DEFAULT_MAX_FILES = 5000;
|
|
7
|
+
const DEFAULT_MAX_LINES = 500000;
|
|
8
|
+
const LIST_FIELDS = new Set(["all", "any", "exclude"]);
|
|
9
|
+
const VALUE_FIELDS = new Set(["from", "to", "cwd", "limit"]);
|
|
10
|
+
|
|
11
|
+
export interface SessionAnchorRange {
|
|
12
|
+
path: string;
|
|
13
|
+
startLine: number;
|
|
14
|
+
endLine: number;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
cwd?: string;
|
|
17
|
+
startTime?: string;
|
|
18
|
+
endTime?: string;
|
|
19
|
+
score?: number;
|
|
20
|
+
reason: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SessionAnchorSearchResult {
|
|
24
|
+
success: boolean;
|
|
25
|
+
ranges: SessionAnchorRange[];
|
|
26
|
+
message?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SessionAnchorSearchOptions {
|
|
30
|
+
sessionsDir?: string;
|
|
31
|
+
maxFiles?: number;
|
|
32
|
+
maxLines?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ParsedAnchorRequest {
|
|
36
|
+
from?: Date;
|
|
37
|
+
to?: Date;
|
|
38
|
+
cwd?: string;
|
|
39
|
+
limit: number;
|
|
40
|
+
all: string[];
|
|
41
|
+
any: string[];
|
|
42
|
+
exclude: string[];
|
|
43
|
+
hasTimeConstraint: boolean;
|
|
44
|
+
hasTextConstraint: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface LineHit {
|
|
48
|
+
path: string;
|
|
49
|
+
lineNumber: number;
|
|
50
|
+
sessionId?: string;
|
|
51
|
+
cwd?: string;
|
|
52
|
+
timestamp?: string;
|
|
53
|
+
timestampMs?: number;
|
|
54
|
+
text: string;
|
|
55
|
+
score: number;
|
|
56
|
+
reason: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface PendingRange {
|
|
60
|
+
path: string;
|
|
61
|
+
startLine: number;
|
|
62
|
+
endLine: number;
|
|
63
|
+
sessionId?: string;
|
|
64
|
+
cwd?: string;
|
|
65
|
+
startTime?: string;
|
|
66
|
+
endTime?: string;
|
|
67
|
+
score: number;
|
|
68
|
+
reason: string;
|
|
69
|
+
text: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function searchSessionAnchors(
|
|
73
|
+
markdown: string,
|
|
74
|
+
options: SessionAnchorSearchOptions = {},
|
|
75
|
+
): SessionAnchorSearchResult {
|
|
76
|
+
const parsed = parseMarkdownRequest(markdown);
|
|
77
|
+
if (!parsed.success) {
|
|
78
|
+
return { success: false, ranges: [], message: parsed.message };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!options.sessionsDir) {
|
|
82
|
+
return { success: false, ranges: [], message: "sessionsDir is required" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!fs.existsSync(options.sessionsDir)) {
|
|
86
|
+
return { success: false, ranges: [], message: `sessionsDir does not exist: ${options.sessionsDir}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const files = findJsonlFiles(options.sessionsDir).sort();
|
|
90
|
+
const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
|
|
91
|
+
const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
|
|
92
|
+
if (files.length > maxFiles) {
|
|
93
|
+
return {
|
|
94
|
+
success: false,
|
|
95
|
+
ranges: [],
|
|
96
|
+
message: `Request too broad: ${files.length} session files exceed the configured scan cap of ${maxFiles}. Add from/to, cwd, all, or any constraints.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ranges: PendingRange[] = [];
|
|
101
|
+
let scannedLines = 0;
|
|
102
|
+
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const remainingLines = maxLines - scannedLines;
|
|
105
|
+
const fileResult = searchJsonlFile(file, parsed.request, remainingLines, scannedLines, maxLines);
|
|
106
|
+
if (!fileResult.success) {
|
|
107
|
+
return { success: false, ranges: [], message: fileResult.message };
|
|
108
|
+
}
|
|
109
|
+
scannedLines += fileResult.scannedLines;
|
|
110
|
+
ranges.push(...fileResult.ranges);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const filtered = ranges.filter((range) => !containsAny(range.text, parsed.request.exclude));
|
|
114
|
+
const sorted = sortRanges(filtered, parsed.request.hasTextConstraint);
|
|
115
|
+
const limited = sorted.slice(0, parsed.request.limit).map(({ text: _text, ...range }) => range);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
success: true,
|
|
119
|
+
ranges: limited,
|
|
120
|
+
message: limited.length === 0 ? "No matching session anchors found." : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseMarkdownRequest(markdown: string): { success: true; request: ParsedAnchorRequest } | { success: false; message: string } {
|
|
125
|
+
if (!markdown || markdown.trim().length === 0) {
|
|
126
|
+
return { success: false, message: "markdown is required" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const fields = new Map<string, string>();
|
|
130
|
+
const lists: Record<"all" | "any" | "exclude", string[]> = { all: [], any: [], exclude: [] };
|
|
131
|
+
const seen = new Set<string>();
|
|
132
|
+
let currentList: "all" | "any" | "exclude" | null = null;
|
|
133
|
+
|
|
134
|
+
const lines = markdown.split(/\r?\n/);
|
|
135
|
+
for (const line of lines) {
|
|
136
|
+
const trimmed = line.trim();
|
|
137
|
+
if (trimmed.length === 0) continue;
|
|
138
|
+
|
|
139
|
+
const fieldMatch = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(trimmed);
|
|
140
|
+
if (fieldMatch) {
|
|
141
|
+
const field = fieldMatch[1];
|
|
142
|
+
const value = fieldMatch[2];
|
|
143
|
+
|
|
144
|
+
if (!VALUE_FIELDS.has(field) && !LIST_FIELDS.has(field)) {
|
|
145
|
+
return {
|
|
146
|
+
success: false,
|
|
147
|
+
message: `Invalid field '${field}'. Supported fields: from, to, cwd, limit, all, any, exclude.`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (seen.has(field)) {
|
|
151
|
+
return { success: false, message: `Duplicate field '${field}'. Keep one value.` };
|
|
152
|
+
}
|
|
153
|
+
seen.add(field);
|
|
154
|
+
|
|
155
|
+
if (LIST_FIELDS.has(field)) {
|
|
156
|
+
if (value.trim().length > 0) {
|
|
157
|
+
return { success: false, message: `Invalid list section '${field}'. Use '${field}:' followed by '- item' lines.` };
|
|
158
|
+
}
|
|
159
|
+
currentList = field as "all" | "any" | "exclude";
|
|
160
|
+
} else {
|
|
161
|
+
fields.set(field, value.trim());
|
|
162
|
+
currentList = null;
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const listMatch = /^-\s+(.*)$/.exec(trimmed);
|
|
168
|
+
if (listMatch && currentList) {
|
|
169
|
+
const term = listMatch[1].trim();
|
|
170
|
+
if (term.length === 0) {
|
|
171
|
+
return { success: false, message: `Empty term in '${currentList}'. Remove it or provide text.` };
|
|
172
|
+
}
|
|
173
|
+
lists[currentList].push(term);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (listMatch && !currentList) {
|
|
178
|
+
return { success: false, message: "List item found outside all, any, or exclude section." };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { success: false, message: `Invalid markdown line: ${trimmed}` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const limitValue = fields.get("limit");
|
|
185
|
+
let limit = DEFAULT_LIMIT;
|
|
186
|
+
if (limitValue !== undefined) {
|
|
187
|
+
if (!/^\d+$/.test(limitValue)) {
|
|
188
|
+
return { success: false, message: "Invalid limit. Use a positive integer." };
|
|
189
|
+
}
|
|
190
|
+
const parsedLimit = Number(limitValue);
|
|
191
|
+
if (!Number.isSafeInteger(parsedLimit) || parsedLimit <= 0) {
|
|
192
|
+
return { success: false, message: "Invalid limit. Use a positive integer." };
|
|
193
|
+
}
|
|
194
|
+
limit = Math.min(parsedLimit, MAX_LIMIT);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const fromValue = fields.get("from");
|
|
198
|
+
const toValue = fields.get("to");
|
|
199
|
+
const from = fromValue === undefined ? undefined : parseDateTime(fromValue, "from");
|
|
200
|
+
if (from === null) return { success: false, message: "Invalid from. Use YYYY-MM-DD or an ISO timestamp." };
|
|
201
|
+
const to = toValue === undefined ? undefined : parseDateTime(toValue, "to");
|
|
202
|
+
if (to === null) return { success: false, message: "Invalid to. Use YYYY-MM-DD or an ISO timestamp." };
|
|
203
|
+
if (from && to && from.getTime() > to.getTime()) {
|
|
204
|
+
return { success: false, message: "Invalid time window. 'from' must be before or equal to 'to'." };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const cwd = fields.get("cwd");
|
|
208
|
+
if (fields.has("cwd") && (!cwd || cwd.trim().length === 0)) {
|
|
209
|
+
return { success: false, message: "Invalid cwd. Provide a non-empty path." };
|
|
210
|
+
}
|
|
211
|
+
const all = lists.all;
|
|
212
|
+
const any = lists.any;
|
|
213
|
+
const exclude = lists.exclude;
|
|
214
|
+
const hasTimeConstraint = Boolean(from || to);
|
|
215
|
+
const hasCwdConstraint = Boolean(cwd);
|
|
216
|
+
const hasTextConstraint = all.length > 0 || any.length > 0;
|
|
217
|
+
|
|
218
|
+
if (!hasTimeConstraint && !hasCwdConstraint && !hasTextConstraint) {
|
|
219
|
+
return {
|
|
220
|
+
success: false,
|
|
221
|
+
message: "Request needs at least one constraint: provide from/to, cwd, all, or any.",
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
success: true,
|
|
226
|
+
request: { from, to, cwd, limit, all, any, exclude, hasTimeConstraint, hasTextConstraint },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseDateTime(value: string, boundary: "from" | "to"): Date | null {
|
|
231
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
232
|
+
if (dateOnly) {
|
|
233
|
+
const year = Number(dateOnly[1]);
|
|
234
|
+
const month = Number(dateOnly[2]);
|
|
235
|
+
const day = Number(dateOnly[3]);
|
|
236
|
+
const date = boundary === "from"
|
|
237
|
+
? new Date(year, month - 1, day, 0, 0, 0, 0)
|
|
238
|
+
: new Date(year, month - 1, day, 23, 59, 59, 999);
|
|
239
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
return date;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const date = new Date(value);
|
|
246
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function findJsonlFiles(dir: string): string[] {
|
|
250
|
+
const files: string[] = [];
|
|
251
|
+
|
|
252
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
253
|
+
const fullPath = path.join(dir, entry.name);
|
|
254
|
+
if (entry.isDirectory()) {
|
|
255
|
+
files.push(...findJsonlFiles(fullPath));
|
|
256
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
257
|
+
files.push(fullPath);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return files;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function searchJsonlFile(
|
|
265
|
+
filePath: string,
|
|
266
|
+
request: ParsedAnchorRequest,
|
|
267
|
+
maxLines: number,
|
|
268
|
+
scannedBefore: number,
|
|
269
|
+
scanCap: number,
|
|
270
|
+
): { success: true; ranges: PendingRange[]; scannedLines: number } | { success: false; message: string } {
|
|
271
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
272
|
+
const lines = content.split(/\r?\n/);
|
|
273
|
+
const hits: LineHit[] = [];
|
|
274
|
+
let currentSessionId: string | undefined;
|
|
275
|
+
let currentCwd: string | undefined;
|
|
276
|
+
|
|
277
|
+
let scannedLines = 0;
|
|
278
|
+
|
|
279
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
280
|
+
const line = lines[index];
|
|
281
|
+
if (line.trim().length === 0) continue;
|
|
282
|
+
|
|
283
|
+
scannedLines += 1;
|
|
284
|
+
if (scannedLines > maxLines) {
|
|
285
|
+
return {
|
|
286
|
+
success: false,
|
|
287
|
+
message: `Request too broad: scanned ${scannedBefore + scannedLines} session lines, exceeding the configured scan cap of ${scanCap}. Add from/to, cwd, all, or any constraints.`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let event: unknown;
|
|
292
|
+
try {
|
|
293
|
+
event = JSON.parse(line);
|
|
294
|
+
} catch {
|
|
295
|
+
return { success: false, message: `Invalid JSON in ${filePath}:${index + 1}` };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const sessionId = getSessionId(event) ?? currentSessionId;
|
|
299
|
+
if (sessionId) currentSessionId = sessionId;
|
|
300
|
+
|
|
301
|
+
const cwd = getCwd(event) ?? currentCwd;
|
|
302
|
+
if (cwd) currentCwd = cwd;
|
|
303
|
+
|
|
304
|
+
if (request.cwd && cwd !== request.cwd) continue;
|
|
305
|
+
|
|
306
|
+
const timestamp = getTimestamp(event);
|
|
307
|
+
const timestampMs = timestamp ? Date.parse(timestamp) : undefined;
|
|
308
|
+
const hasValidTimestamp = timestampMs !== undefined && !Number.isNaN(timestampMs);
|
|
309
|
+
if (request.hasTimeConstraint) {
|
|
310
|
+
if (!hasValidTimestamp) continue;
|
|
311
|
+
if (request.from && timestampMs < request.from.getTime()) continue;
|
|
312
|
+
if (request.to && timestampMs > request.to.getTime()) continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const text = textualizeEvent(event);
|
|
316
|
+
const termScore = scoreTerms(text, request);
|
|
317
|
+
const matchesTerms = request.hasTextConstraint ? termScore > 0 : true;
|
|
318
|
+
if (!matchesTerms) continue;
|
|
319
|
+
|
|
320
|
+
if (!request.hasTextConstraint && !hasValidTimestamp) continue;
|
|
321
|
+
|
|
322
|
+
hits.push({
|
|
323
|
+
path: filePath,
|
|
324
|
+
lineNumber: index + 1,
|
|
325
|
+
sessionId,
|
|
326
|
+
cwd,
|
|
327
|
+
timestamp: hasValidTimestamp ? timestamp : undefined,
|
|
328
|
+
timestampMs: hasValidTimestamp ? timestampMs : undefined,
|
|
329
|
+
text,
|
|
330
|
+
score: request.hasTextConstraint ? termScore : 1,
|
|
331
|
+
reason: buildReason(request, text),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return { success: true, ranges: mergeAdjacentHits(hits), scannedLines };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function mergeAdjacentHits(hits: LineHit[]): PendingRange[] {
|
|
339
|
+
const ranges: PendingRange[] = [];
|
|
340
|
+
|
|
341
|
+
for (const hit of hits) {
|
|
342
|
+
const last = ranges.at(-1);
|
|
343
|
+
if (last && last.path === hit.path && last.endLine + 1 === hit.lineNumber && last.reason === hit.reason) {
|
|
344
|
+
last.endLine = hit.lineNumber;
|
|
345
|
+
last.score += hit.score;
|
|
346
|
+
last.text += "\n" + hit.text;
|
|
347
|
+
last.sessionId ??= hit.sessionId;
|
|
348
|
+
last.cwd ??= hit.cwd;
|
|
349
|
+
if (!last.startTime && hit.timestamp) last.startTime = hit.timestamp;
|
|
350
|
+
if (hit.timestamp) last.endTime = hit.timestamp;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
ranges.push({
|
|
355
|
+
path: hit.path,
|
|
356
|
+
startLine: hit.lineNumber,
|
|
357
|
+
endLine: hit.lineNumber,
|
|
358
|
+
sessionId: hit.sessionId,
|
|
359
|
+
cwd: hit.cwd,
|
|
360
|
+
startTime: hit.timestamp,
|
|
361
|
+
endTime: hit.timestamp,
|
|
362
|
+
score: hit.score,
|
|
363
|
+
reason: hit.reason,
|
|
364
|
+
text: hit.text,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return ranges;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function sortRanges(ranges: PendingRange[], textConstrained: boolean): PendingRange[] {
|
|
372
|
+
return [...ranges].sort((a, b) => {
|
|
373
|
+
if (textConstrained && b.score !== a.score) return b.score - a.score;
|
|
374
|
+
const timeCompare = Date.parse(a.startTime ?? "") - Date.parse(b.startTime ?? "");
|
|
375
|
+
if (!Number.isNaN(timeCompare) && timeCompare !== 0) return timeCompare;
|
|
376
|
+
const pathCompare = a.path.localeCompare(b.path);
|
|
377
|
+
if (pathCompare !== 0) return pathCompare;
|
|
378
|
+
return a.startLine - b.startLine;
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function scoreTerms(text: string, request: ParsedAnchorRequest): number {
|
|
383
|
+
const lower = text.toLocaleLowerCase();
|
|
384
|
+
const matchedAll = request.all.filter((term) => lower.includes(term.toLocaleLowerCase()));
|
|
385
|
+
const matchedAny = request.any.filter((term) => lower.includes(term.toLocaleLowerCase()));
|
|
386
|
+
|
|
387
|
+
if (request.all.length > 0 && matchedAll.length !== request.all.length) return 0;
|
|
388
|
+
if (request.any.length > 0 && matchedAny.length === 0) return 0;
|
|
389
|
+
|
|
390
|
+
if (request.all.length === 0 && request.any.length === 0) return 1;
|
|
391
|
+
return matchedAll.length * 2 + matchedAny.length;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function buildReason(request: ParsedAnchorRequest, text: string): string {
|
|
395
|
+
if (!request.hasTextConstraint) {
|
|
396
|
+
if (request.hasTimeConstraint && request.cwd) return "cwd+time window";
|
|
397
|
+
if (request.hasTimeConstraint) return "time window";
|
|
398
|
+
return "cwd";
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const lower = text.toLocaleLowerCase();
|
|
402
|
+
const parts: string[] = [];
|
|
403
|
+
if (request.all.length > 0) parts.push(`matched all: ${request.all.join(", ")}`);
|
|
404
|
+
const matchedAny = request.any.filter((term) => lower.includes(term.toLocaleLowerCase()));
|
|
405
|
+
if (matchedAny.length > 0) parts.push(`matched any: ${matchedAny.join(", ")}`);
|
|
406
|
+
return parts.join("; ");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function containsAny(text: string, terms: string[]): boolean {
|
|
410
|
+
const lower = text.toLocaleLowerCase();
|
|
411
|
+
return terms.some((term) => lower.includes(term.toLocaleLowerCase()));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function getTimestamp(event: unknown): string | undefined {
|
|
415
|
+
if (!isRecord(event)) return undefined;
|
|
416
|
+
if (typeof event.timestamp === "string") return event.timestamp;
|
|
417
|
+
if (isRecord(event.message) && typeof event.message.timestamp === "string") return event.message.timestamp;
|
|
418
|
+
return undefined;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function getSessionId(event: unknown): string | undefined {
|
|
422
|
+
if (!isRecord(event)) return undefined;
|
|
423
|
+
if (typeof event.sessionId === "string") return event.sessionId;
|
|
424
|
+
if (typeof event.session_id === "string") return event.session_id;
|
|
425
|
+
if (event.type === "session" && typeof event.id === "string") return event.id;
|
|
426
|
+
if (isRecord(event.session) && typeof event.session.id === "string") return event.session.id;
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function getCwd(event: unknown): string | undefined {
|
|
431
|
+
if (!isRecord(event)) return undefined;
|
|
432
|
+
if (typeof event.cwd === "string") return event.cwd;
|
|
433
|
+
if (isRecord(event.session) && typeof event.session.cwd === "string") return event.session.cwd;
|
|
434
|
+
return undefined;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function textualizeEvent(event: unknown): string {
|
|
438
|
+
const parts: string[] = [];
|
|
439
|
+
collectStrings(event, parts);
|
|
440
|
+
return parts.join("\n");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const METADATA_TEXT_KEYS = new Set([
|
|
444
|
+
"type",
|
|
445
|
+
"id",
|
|
446
|
+
"parentId",
|
|
447
|
+
"sessionId",
|
|
448
|
+
"session_id",
|
|
449
|
+
"timestamp",
|
|
450
|
+
"cwd",
|
|
451
|
+
"role",
|
|
452
|
+
"customType",
|
|
453
|
+
]);
|
|
454
|
+
|
|
455
|
+
function collectStrings(value: unknown, parts: string[], key?: string): void {
|
|
456
|
+
if (typeof value === "string") {
|
|
457
|
+
if (!key || !METADATA_TEXT_KEYS.has(key)) parts.push(value);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (Array.isArray(value)) {
|
|
462
|
+
for (const item of value) collectStrings(item, parts, key);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (!isRecord(value)) return;
|
|
467
|
+
for (const [childKey, item] of Object.entries(value)) collectStrings(item, parts, childKey);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
471
|
+
return typeof value === "object" && value !== null;
|
|
472
|
+
}
|