-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstore.ts
More file actions
235 lines (222 loc) · 8.3 KB
/
Copy pathstore.ts
File metadata and controls
235 lines (222 loc) · 8.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
import fs from "node:fs/promises";
import path from "node:path";
import crypto from "node:crypto";
import { sessionsDir } from "../paths.js";
import { appendLine, ensureDir } from "../fs-utils.js";
import { resolveSandboxMode, type SandboxMode } from "../sandbox/mode.js";
import type { SessionEntry, SessionHeader, StoredMessage } from "../types.js";
import { jsonlLines, tailLines } from "./jsonl.js";
/** Content hash of a system prompt — the identity of a `prompt` entry. */
export function promptFingerprint(text: string): string {
return crypto.createHash("sha256").update(text, "utf8").digest("hex").slice(0, 16);
}
export class SessionStore {
readonly id: string;
readonly path: string;
readonly header: SessionHeader;
/**
* Fingerprint of the last system prompt written to this file, so an
* unchanged prompt isn't re-serialized on every turn (and a resumed session
* doesn't duplicate the prompt it is still running with). Recovered on
* open() from the file we already read.
*/
private lastPromptFingerprint?: string;
private constructor(
id: string,
file: string,
header: SessionHeader,
lastPromptFingerprint?: string,
) {
this.id = id;
this.path = file;
this.header = header;
this.lastPromptFingerprint = lastPromptFingerprint;
}
/**
* Open an existing session. Streamed (T-5): only the header and the newest
* prompt fingerprint are wanted, so a long conversation must not be
* materialized as one string plus an array of every line to get them.
*/
static async open(id: string): Promise<SessionStore> {
const file = path.join(sessionsDir(), `${id}.jsonl`);
let header: SessionHeader | null = null;
let fingerprint: string | undefined;
for await (const line of jsonlLines(file)) {
if (!header) {
header = JSON.parse(line) as SessionHeader;
continue;
}
try {
const entry = JSON.parse(line) as Partial<SessionEntry>;
// Keep the LAST one seen — same answer as the old backwards scan.
if (entry.type === "prompt" && "fingerprint" in entry) {
fingerprint = entry.fingerprint;
}
} catch {
// Skip a torn line rather than failing the whole open.
}
}
if (!header) throw new Error(`session ${id} is empty`);
return new SessionStore(id, file, header, fingerprint);
}
static async create(opts: {
cwd: string;
model: string;
/** Overrides the environment-resolved mode (H2). */
sandboxMode?: SandboxMode;
}): Promise<SessionStore> {
// Session logs now carry the full system prompt — soul, USER.md, MEMORY.md,
// KB — the same sensitive user context the rest of ~/.lisa keeps private, so
// hold them to the same 0600-in-0700 discipline as config.env / devices /
// mail. append's mode only applies on create, so chmod after to tighten a
// dir or file that predates this hardening.
await ensureDir(sessionsDir());
await fs.chmod(sessionsDir(), 0o700).catch(() => {});
const id = `${stamp()}-${crypto.randomBytes(3).toString("hex")}`;
const file = path.join(sessionsDir(), `${id}.jsonl`);
const header: SessionHeader = {
type: "session",
id,
version: 2,
startedAt: new Date().toISOString(),
cwd: opts.cwd,
model: opts.model,
// Resolved once, here. A session carries the posture it was created
// under, so editing a setting cannot widen what a task already running
// under the old one is permitted to do.
sandboxMode: resolveSandboxMode(opts.sandboxMode),
};
await appendLine(file, JSON.stringify(header));
await fs.chmod(file, 0o600).catch(() => {});
return new SessionStore(id, file, header);
}
/**
* Record the system prompt the model is about to see (H3). No-op when the
* text is byte-identical to the last one written — "the prompt in effect at
* entry N" is therefore the nearest preceding prompt entry, and a long chat
* that never self-modifies costs exactly one entry.
*
* Returns whether an entry was actually appended (tests and telemetry care;
* callers generally don't).
*/
async appendPrompt(text: string, reason: "initial" | "rebuilt"): Promise<boolean> {
const fingerprint = promptFingerprint(text);
if (fingerprint === this.lastPromptFingerprint) return false;
const entry: SessionEntry = {
type: "prompt",
ts: new Date().toISOString(),
fingerprint,
text,
reason,
};
await appendLine(this.path, JSON.stringify(entry));
this.lastPromptFingerprint = fingerprint;
return true;
}
async appendMessage(message: StoredMessage): Promise<void> {
const entry: SessionEntry = {
type: "message",
ts: new Date().toISOString(),
message,
};
await appendLine(this.path, JSON.stringify(entry));
}
async appendReflection(summary: string): Promise<void> {
const entry: SessionEntry = {
type: "reflection",
ts: new Date().toISOString(),
summary,
};
await appendLine(this.path, JSON.stringify(entry));
}
/**
* The newest durable reflection. Reflections are appended, so the answer is
* almost always inside the last few KB — read a bounded tail first and only
* fall back to a streamed full scan when the tail didn't cover the file and
* held no reflection (T-5).
*/
async readLatestReflection(): Promise<string | undefined> {
const summaryOf = (line: string): string | undefined => {
try {
const entry = JSON.parse(line) as Partial<SessionEntry>;
if (
entry.type === "reflection" &&
"summary" in entry &&
typeof entry.summary === "string"
) {
return entry.summary;
}
} catch {
// Skip a corrupt line and keep searching older durable reflections.
}
return undefined;
};
let tail: { lines: string[]; complete: boolean };
try {
tail = await tailLines(this.path);
} catch {
return undefined;
}
for (let i = tail.lines.length - 1; i >= 0; i--) {
// The header is line 0 only when the tail covers the whole file; it can
// never parse as a reflection, so no special-casing is needed.
const found = summaryOf(tail.lines[i]!);
if (found !== undefined) return found;
}
if (tail.complete) return undefined;
let latest: string | undefined;
for await (const line of jsonlLines(this.path)) {
const found = summaryOf(line);
if (found !== undefined) latest = found;
}
return latest;
}
/**
* Read a page of message entries (newest-first within the page).
* page=0 = latest PAGE_SIZE messages, page=1 = older ones, etc.
*/
async readMessagePage(
page: number,
pageSize = 20,
): Promise<{ messages: StoredMessage[]; hasMore: boolean }> {
// Streamed with a bounded ring (T-5). The page is taken from the END, so
// only the newest (page+1)*pageSize message lines can ever be needed:
// keep exactly that many and drop the rest as we go, instead of building
// an array of every line in the file and slicing it.
const keep = Math.max(0, (page + 1) * pageSize);
if (keep === 0) return { messages: [], hasMore: false };
const ring: StoredMessage[] = [];
let total = 0;
let first = true;
for await (const line of jsonlLines(this.path)) {
if (first) {
first = false;
continue; // header
}
let entry: { type?: string; message?: StoredMessage };
try {
entry = JSON.parse(line) as { type?: string; message?: StoredMessage };
} catch {
continue;
}
if (entry.type !== "message" || !entry.message) continue;
total++;
ring.push(entry.message);
if (ring.length > keep) ring.shift();
}
const end = total - page * pageSize;
if (end <= 0) return { messages: [], hasMore: false };
const start = Math.max(0, end - pageSize);
// `ring` holds the last `keep` messages, i.e. indices [total-ring.length, total).
const base = total - ring.length;
return { messages: ring.slice(start - base, end - base), hasMore: start > 0 };
}
}
function stamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-` +
`${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
);
}