@nklisch/pi-enhanced 0.4.1 → 0.4.2
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/node_modules/@nklisch/pi-astral-pocket/README.md +108 -52
- package/node_modules/@nklisch/pi-astral-pocket/package.json +3 -1
- package/node_modules/@nklisch/pi-astral-pocket/src/config.ts +40 -23
- package/node_modules/@nklisch/pi-astral-pocket/src/controller.ts +78 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/distiller.ts +182 -73
- package/node_modules/@nklisch/pi-astral-pocket/src/guidance.ts +12 -5
- package/node_modules/@nklisch/pi-astral-pocket/src/index.ts +172 -77
- package/node_modules/@nklisch/pi-astral-pocket/src/provider.ts +128 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/scope.ts +33 -0
- package/node_modules/@nklisch/pi-astral-pocket/src/sessions.ts +111 -99
- package/node_modules/@nklisch/pi-astral-pocket/src/store.ts +319 -119
- package/node_modules/@nklisch/pi-astral-pocket/src/tools.ts +43 -13
- package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
|
@@ -3,20 +3,18 @@ import { join } from "node:path";
|
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
|
|
5
5
|
import { ASTRA_MODEL_ID, ASTRA_PROVIDER } from "./activation.js";
|
|
6
|
+
import { resolveProjectIdentity } from "./scope.js";
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
export interface SessionRevision {
|
|
9
|
+
mtimeMs: number;
|
|
10
|
+
size: number;
|
|
11
|
+
key: string;
|
|
12
|
+
}
|
|
11
13
|
|
|
12
|
-
export interface SessionFileInfo {
|
|
14
|
+
export interface SessionFileInfo extends SessionRevision {
|
|
13
15
|
path: string;
|
|
14
16
|
id: string;
|
|
15
17
|
cwd: string;
|
|
16
|
-
mtimeMs: number;
|
|
17
|
-
/** True when any assistant message in the file was produced by astra, or a
|
|
18
|
-
* model_change switched to it. Assistant messages carry provider/model
|
|
19
|
-
* directly, so sessions that were astra from the start are still caught. */
|
|
20
18
|
astra: boolean;
|
|
21
19
|
}
|
|
22
20
|
|
|
@@ -30,18 +28,29 @@ export interface SessionSearchHit {
|
|
|
30
28
|
|
|
31
29
|
const HITS_PER_FILE_CAP = 50;
|
|
32
30
|
const SUMMARIZED_EXCERPT = 200;
|
|
33
|
-
const FULL_EXCERPT =
|
|
31
|
+
const FULL_EXCERPT = 2_000;
|
|
34
32
|
|
|
35
33
|
async function* iterLines(path: string): AsyncGenerator<string> {
|
|
36
34
|
const rl = createInterface({ input: createReadStream(path, "utf8"), crlfDelay: Infinity });
|
|
37
35
|
for await (const line of rl) yield line;
|
|
38
36
|
}
|
|
39
37
|
|
|
38
|
+
export function revisionFor(path: string): SessionRevision | undefined {
|
|
39
|
+
try {
|
|
40
|
+
const stat = statSync(path);
|
|
41
|
+
const mtimeMs = stat.mtimeMs;
|
|
42
|
+
const size = stat.size;
|
|
43
|
+
return { mtimeMs, size, key: `${mtimeMs}:${size}` };
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
/** Enumerate top-level session files across all project dirs, newest first. */
|
|
41
|
-
export function listSessionFiles(sessionsDir: string, maxAgeDays?: number): { path: string
|
|
50
|
+
export function listSessionFiles(sessionsDir: string, maxAgeDays?: number, nowMs = Date.now()): Array<{ path: string } & SessionRevision> {
|
|
42
51
|
if (!existsSync(sessionsDir)) return [];
|
|
43
|
-
const cutoff = maxAgeDays === undefined ? null :
|
|
44
|
-
const files: { path: string
|
|
52
|
+
const cutoff = maxAgeDays === undefined ? null : nowMs - maxAgeDays * 86_400_000;
|
|
53
|
+
const files: Array<{ path: string } & SessionRevision> = [];
|
|
45
54
|
for (const dir of readdirSync(sessionsDir)) {
|
|
46
55
|
const dirPath = join(sessionsDir, dir);
|
|
47
56
|
try {
|
|
@@ -49,12 +58,12 @@ export function listSessionFiles(sessionsDir: string, maxAgeDays?: number): { pa
|
|
|
49
58
|
for (const file of readdirSync(dirPath)) {
|
|
50
59
|
if (!file.endsWith(".jsonl")) continue;
|
|
51
60
|
const path = join(dirPath, file);
|
|
52
|
-
const
|
|
53
|
-
if (cutoff !== null && mtimeMs < cutoff) continue;
|
|
54
|
-
files.push({ path,
|
|
61
|
+
const revision = revisionFor(path);
|
|
62
|
+
if (!revision || (cutoff !== null && revision.mtimeMs < cutoff)) continue;
|
|
63
|
+
files.push({ path, ...revision });
|
|
55
64
|
}
|
|
56
65
|
} catch {
|
|
57
|
-
|
|
66
|
+
// Recall remains available when one session directory is unreadable.
|
|
58
67
|
}
|
|
59
68
|
}
|
|
60
69
|
return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
@@ -69,38 +78,30 @@ function isAstraMarker(entry: Record<string, unknown>): boolean {
|
|
|
69
78
|
return false;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
export async function identifySession(path: string, revisionOrMtime: SessionRevision | number): Promise<SessionFileInfo> {
|
|
82
|
+
const revision = typeof revisionOrMtime === "number"
|
|
83
|
+
? (revisionFor(path) ?? { mtimeMs: revisionOrMtime, size: 0, key: `${revisionOrMtime}:0` })
|
|
84
|
+
: revisionOrMtime;
|
|
75
85
|
let id = "";
|
|
76
86
|
let cwd = "";
|
|
77
87
|
let astra = false;
|
|
78
88
|
for await (const line of iterLines(path)) {
|
|
79
89
|
if (id && cwd && astra) break;
|
|
80
90
|
let entry: Record<string, unknown>;
|
|
81
|
-
try {
|
|
82
|
-
entry = JSON.parse(line) as Record<string, unknown>;
|
|
83
|
-
} catch {
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
91
|
+
try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
86
92
|
if (entry.type === "session") {
|
|
87
93
|
id = String(entry.id ?? "");
|
|
88
94
|
cwd = String(entry.cwd ?? "");
|
|
89
|
-
} else if (isAstraMarker(entry))
|
|
90
|
-
astra = true;
|
|
91
|
-
}
|
|
95
|
+
} else if (isAstraMarker(entry)) astra = true;
|
|
92
96
|
}
|
|
93
|
-
return { path, id, cwd,
|
|
97
|
+
return { path, id, cwd, astra, ...revision };
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
function textOf(content: unknown): string {
|
|
97
101
|
if (typeof content === "string") return content;
|
|
98
102
|
if (!Array.isArray(content)) return "";
|
|
99
|
-
return content
|
|
100
|
-
.
|
|
101
|
-
.map((b) => (typeof b.text === "string" ? b.text : ""))
|
|
102
|
-
.filter(Boolean)
|
|
103
|
-
.join(" ");
|
|
103
|
+
return content.filter((block): block is Record<string, unknown> => typeof block === "object" && block !== null)
|
|
104
|
+
.map((block) => typeof block.text === "string" ? block.text : "").filter(Boolean).join(" ");
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
function truncate(text: string, cap: number): string {
|
|
@@ -108,71 +109,68 @@ function truncate(text: string, cap: number): string {
|
|
|
108
109
|
return clean.length <= cap ? clean : `${clean.slice(0, cap)}…`;
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
/** Search astra sessions for a query. Summarized by default: tool hits return
|
|
112
|
-
* name + truncated args/results, message hits return short excerpts. `full`
|
|
113
|
-
* raises the excerpt cap. Only astra sessions are searched — other models'
|
|
114
|
-
* sessions are noise for this tool and may carry unrelated secrets. */
|
|
115
112
|
export async function searchAstraSessions(
|
|
116
113
|
sessionsDir: string,
|
|
117
114
|
query: string,
|
|
118
|
-
options: { full?: boolean; limit?: number; maxAgeDays?: number } = {},
|
|
115
|
+
options: { full?: boolean; limit?: number; maxAgeDays?: number; projectId?: string; recallScope?: "current" | "all" } = {},
|
|
119
116
|
): Promise<SessionSearchHit[]> {
|
|
120
117
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
121
118
|
if (terms.length === 0) return [];
|
|
122
|
-
const limit = options.limit ?? 10;
|
|
119
|
+
const limit = Math.max(1, options.limit ?? 10);
|
|
123
120
|
const cap = options.full ? FULL_EXCERPT : SUMMARIZED_EXCERPT;
|
|
124
121
|
const hits: SessionSearchHit[] = [];
|
|
125
|
-
|
|
126
|
-
for (const
|
|
127
|
-
|
|
128
|
-
const info = await identifySession(path, mtimeMs);
|
|
122
|
+
const sessions: SessionFileInfo[] = [];
|
|
123
|
+
for (const file of listSessionFiles(sessionsDir, options.maxAgeDays)) {
|
|
124
|
+
const info = await identifySession(file.path, file);
|
|
129
125
|
if (!info.astra) continue;
|
|
126
|
+
if ((options.recallScope ?? "current") !== "all" &&
|
|
127
|
+
(!info.cwd || options.projectId === undefined || resolveProjectIdentity(info.cwd) !== options.projectId)) continue;
|
|
128
|
+
sessions.push(info);
|
|
129
|
+
}
|
|
130
|
+
sessions.sort((a, b) => {
|
|
131
|
+
const projectRank = Number(Boolean(options.projectId && b.cwd) && resolveProjectIdentity(b.cwd) === options.projectId) -
|
|
132
|
+
Number(Boolean(options.projectId && a.cwd) && resolveProjectIdentity(a.cwd) === options.projectId);
|
|
133
|
+
return projectRank || b.mtimeMs - a.mtimeMs;
|
|
134
|
+
});
|
|
130
135
|
|
|
136
|
+
for (const info of sessions) {
|
|
137
|
+
if (hits.length >= limit) break;
|
|
131
138
|
const fileHits: SessionSearchHit[] = [];
|
|
132
|
-
for await (const line of iterLines(path)) {
|
|
139
|
+
for await (const line of iterLines(info.path)) {
|
|
133
140
|
if (fileHits.length >= HITS_PER_FILE_CAP) break;
|
|
134
|
-
if (!terms.every((
|
|
141
|
+
if (!terms.every((term) => line.toLowerCase().includes(term))) continue;
|
|
135
142
|
let entry: Record<string, unknown>;
|
|
136
|
-
try {
|
|
137
|
-
entry = JSON.parse(line) as Record<string, unknown>;
|
|
138
|
-
} catch {
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
143
|
+
try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
141
144
|
if (entry.type !== "message") continue;
|
|
142
145
|
const msg = entry.message as Record<string, unknown>;
|
|
143
146
|
const role = msg.role as string;
|
|
144
147
|
const timestamp = String(entry.timestamp ?? "");
|
|
145
148
|
const base = { sessionId: info.id, project: info.cwd, timestamp };
|
|
146
149
|
if (role === "user" || role === "assistant") {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
for (const block of content as Record<string, unknown>[]) {
|
|
150
|
+
if (Array.isArray(msg.content)) {
|
|
151
|
+
for (const block of msg.content as Record<string, unknown>[]) {
|
|
150
152
|
if (block.type === "toolCall") {
|
|
151
153
|
const args = truncate(JSON.stringify(block.arguments ?? {}), cap);
|
|
152
|
-
if (terms.every((
|
|
154
|
+
if (terms.every((term) => `${String(block.name)} ${args}`.toLowerCase().includes(term))) {
|
|
153
155
|
fileHits.push({ ...base, kind: "toolCall", excerpt: `${String(block.name)}(${args})` });
|
|
154
156
|
}
|
|
155
157
|
} else if (block.type === "text" && typeof block.text === "string") {
|
|
156
|
-
const text = block.text
|
|
157
|
-
if (terms.every((
|
|
158
|
+
const text = block.text;
|
|
159
|
+
if (terms.every((term) => text.toLowerCase().includes(term))) {
|
|
158
160
|
fileHits.push({ ...base, kind: role as "user" | "assistant", excerpt: truncate(text, cap) });
|
|
159
161
|
}
|
|
160
162
|
}
|
|
161
163
|
}
|
|
162
164
|
} else {
|
|
163
|
-
const text = textOf(content);
|
|
164
|
-
if (terms.every((
|
|
165
|
+
const text = textOf(msg.content);
|
|
166
|
+
if (terms.every((term) => text.toLowerCase().includes(term))) {
|
|
165
167
|
fileHits.push({ ...base, kind: role as "user" | "assistant", excerpt: truncate(text, cap) });
|
|
166
168
|
}
|
|
167
169
|
}
|
|
168
170
|
} else if (role === "toolResult") {
|
|
169
171
|
const text = textOf(msg.content);
|
|
170
|
-
if (terms.every((
|
|
171
|
-
fileHits.push({
|
|
172
|
-
...base,
|
|
173
|
-
kind: "toolResult",
|
|
174
|
-
excerpt: `${String(msg.toolName ?? "tool")} → ${truncate(text, cap)}`,
|
|
175
|
-
});
|
|
172
|
+
if (terms.every((term) => `${String(msg.toolName ?? "")} ${text}`.toLowerCase().includes(term))) {
|
|
173
|
+
fileHits.push({ ...base, kind: "toolResult", excerpt: `${String(msg.toolName ?? "tool")} → ${truncate(text, cap)}` });
|
|
176
174
|
}
|
|
177
175
|
}
|
|
178
176
|
}
|
|
@@ -181,45 +179,59 @@ export async function searchAstraSessions(
|
|
|
181
179
|
return hits.slice(0, limit);
|
|
182
180
|
}
|
|
183
181
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
182
|
+
function compactEntry(entry: Record<string, unknown>): string {
|
|
183
|
+
if (entry.type !== "message") return "";
|
|
184
|
+
const msg = entry.message as Record<string, unknown>;
|
|
185
|
+
if (msg.role === "user") return `USER: ${truncate(textOf(msg.content), 1_500)}`;
|
|
186
|
+
if (msg.role === "assistant") {
|
|
187
|
+
const blocks = Array.isArray(msg.content) ? msg.content as Record<string, unknown>[] : [];
|
|
188
|
+
const content = blocks.map((block) => {
|
|
189
|
+
if (block.type === "text") return truncate(String(block.text ?? ""), 1_500);
|
|
190
|
+
if (block.type === "toolCall") return `[tool: ${String(block.name)} ${truncate(JSON.stringify(block.arguments ?? {}), 200)}]`;
|
|
191
|
+
return "";
|
|
192
|
+
}).filter(Boolean).join(" ");
|
|
193
|
+
return content ? `ASSISTANT: ${content}` : "";
|
|
194
|
+
}
|
|
195
|
+
if (msg.role === "toolResult") {
|
|
196
|
+
return `[result: ${String(msg.toolName ?? "tool")}${msg.isError ? " (error)" : ""}] ${truncate(textOf(msg.content), 200)}`;
|
|
197
|
+
}
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Keep a small opening for problem context and devote the rest of the budget
|
|
203
|
+
* to the latest conversation, where final decisions and corrections live.
|
|
204
|
+
*/
|
|
188
205
|
export async function readSessionDigest(path: string, capBytes = 60_000): Promise<string> {
|
|
189
|
-
const
|
|
190
|
-
|
|
206
|
+
const openingCap = Math.max(1_000, Math.floor(capBytes * 0.2));
|
|
207
|
+
const tailCap = Math.max(1_000, capBytes - openingCap - 80);
|
|
208
|
+
const opening: string[] = [];
|
|
209
|
+
const tail: string[] = [];
|
|
210
|
+
let openingSize = 0;
|
|
211
|
+
let tailSize = 0;
|
|
212
|
+
let openingClosed = false;
|
|
213
|
+
let omitted = false;
|
|
214
|
+
|
|
191
215
|
for await (const line of iterLines(path)) {
|
|
192
|
-
if (size >= capBytes) {
|
|
193
|
-
parts.push("… [transcript truncated]");
|
|
194
|
-
break;
|
|
195
|
-
}
|
|
196
216
|
let entry: Record<string, unknown>;
|
|
197
|
-
try {
|
|
198
|
-
|
|
199
|
-
|
|
217
|
+
try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
218
|
+
const chunk = compactEntry(entry);
|
|
219
|
+
if (!chunk) continue;
|
|
220
|
+
if (!openingClosed && openingSize + chunk.length + 1 <= openingCap) {
|
|
221
|
+
opening.push(chunk);
|
|
222
|
+
openingSize += chunk.length + 1;
|
|
200
223
|
continue;
|
|
201
224
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (b.type === "text") return truncate(String(b.text ?? ""), 1500);
|
|
211
|
-
if (b.type === "toolCall") return `[tool: ${String(b.name)} ${truncate(JSON.stringify(b.arguments ?? {}), 200)}]`;
|
|
212
|
-
return "";
|
|
213
|
-
})
|
|
214
|
-
.filter(Boolean)
|
|
215
|
-
.join(" ");
|
|
216
|
-
chunk = texts ? `ASSISTANT: ${texts}` : "";
|
|
217
|
-
} else if (msg.role === "toolResult") {
|
|
218
|
-
chunk = `[result: ${String(msg.toolName ?? "tool")}${msg.isError ? " (error)" : ""}] ${truncate(textOf(msg.content), 200)}`;
|
|
225
|
+
// Once a chunk overflows the opening budget, all later entries belong to
|
|
226
|
+
// the tail so their chronology cannot jump ahead of an older tail entry.
|
|
227
|
+
openingClosed = true;
|
|
228
|
+
tail.push(chunk);
|
|
229
|
+
tailSize += chunk.length + 1;
|
|
230
|
+
while (tailSize > tailCap && tail.length > 1) {
|
|
231
|
+
tailSize -= (tail.shift()?.length ?? 0) + 1;
|
|
232
|
+
omitted = true;
|
|
219
233
|
}
|
|
220
|
-
if (!chunk) continue;
|
|
221
|
-
size += chunk.length;
|
|
222
|
-
parts.push(chunk);
|
|
223
234
|
}
|
|
224
|
-
return
|
|
235
|
+
if (tail.length === 0) return opening.join("\n");
|
|
236
|
+
return [...opening, ...(omitted ? ["… [earlier transcript omitted; latest decisions retained] …"] : []), ...tail].join("\n");
|
|
225
237
|
}
|