@howaboua/pi-notes-compaction 0.0.0-stage → 0.0.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/CHANGELOG.md +9 -0
- package/PCC-INTEGRATION.md +100 -0
- package/README.md +42 -2
- package/changelog.ts +337 -0
- package/package.json +50 -5
- package/src/bridge.ts +156 -0
- package/src/fresh-notes.ts +132 -0
- package/src/history.ts +198 -0
- package/src/index.ts +255 -0
- package/src/lifecycle.ts +538 -0
- package/src/notes.ts +440 -0
- package/src/settings-screen.ts +115 -0
- package/src/settings.ts +48 -0
- package/src/store.ts +240 -0
- package/src/tools.ts +554 -0
- package/src/windows.ts +282 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import type { CachedResponse, LookupQuery } from "./bridge.js";
|
|
6
|
+
import { queryKey } from "./bridge.js";
|
|
7
|
+
|
|
8
|
+
export interface SessionRef {
|
|
9
|
+
id: string;
|
|
10
|
+
file: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface NoteRevision {
|
|
13
|
+
id: string;
|
|
14
|
+
path: string;
|
|
15
|
+
text: string;
|
|
16
|
+
mode: "replace" | "append";
|
|
17
|
+
createdAt: number;
|
|
18
|
+
updatedAt: number;
|
|
19
|
+
}
|
|
20
|
+
interface CacheRow {
|
|
21
|
+
query_json: string;
|
|
22
|
+
bytes: Buffer;
|
|
23
|
+
source: string;
|
|
24
|
+
encoding: string;
|
|
25
|
+
fetched_at: number;
|
|
26
|
+
coverage: CachedResponse["coverage"];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One connection per extension process. Read-only access never creates a database or session. */
|
|
30
|
+
export class NotesStore {
|
|
31
|
+
private db: Database.Database | undefined;
|
|
32
|
+
readonly path: string;
|
|
33
|
+
constructor(path: string) {
|
|
34
|
+
this.path = path;
|
|
35
|
+
}
|
|
36
|
+
private open(write = false): Database.Database | undefined {
|
|
37
|
+
if (this.db) return this.db;
|
|
38
|
+
if (!write && !existsSync(this.path)) return undefined;
|
|
39
|
+
if (write) mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
40
|
+
const db = new Database(this.path);
|
|
41
|
+
try {
|
|
42
|
+
db.pragma("busy_timeout = 5000");
|
|
43
|
+
db.pragma("journal_mode = WAL");
|
|
44
|
+
db.pragma("foreign_keys = ON");
|
|
45
|
+
const version = db.pragma("user_version", { simple: true });
|
|
46
|
+
if (version !== 0 && version !== 1) {
|
|
47
|
+
throw new Error("Notes database version is not supported");
|
|
48
|
+
}
|
|
49
|
+
db.exec(`
|
|
50
|
+
CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, file_path TEXT, updated_at INTEGER NOT NULL);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
52
|
+
id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
53
|
+
path TEXT NOT NULL, text TEXT NOT NULL, mode TEXT NOT NULL,
|
|
54
|
+
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
55
|
+
);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS notes_session ON notes(session_id);
|
|
57
|
+
CREATE TABLE IF NOT EXISTS remote_cache (
|
|
58
|
+
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
59
|
+
query_key TEXT NOT NULL, query_json TEXT NOT NULL, source TEXT NOT NULL,
|
|
60
|
+
encoding TEXT NOT NULL, bytes BLOB NOT NULL, fetched_at INTEGER NOT NULL, coverage TEXT NOT NULL,
|
|
61
|
+
PRIMARY KEY(session_id, query_key, source)
|
|
62
|
+
);
|
|
63
|
+
PRAGMA user_version = 1;
|
|
64
|
+
`);
|
|
65
|
+
this.db = db;
|
|
66
|
+
return db;
|
|
67
|
+
} catch {
|
|
68
|
+
db.close();
|
|
69
|
+
throw new Error(
|
|
70
|
+
"Local notes storage is unavailable. History remains available. Retry when local storage is restored.",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
private record(db: Database.Database, session: SessionRef): void {
|
|
75
|
+
db.prepare(
|
|
76
|
+
"INSERT INTO sessions VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET file_path = COALESCE(excluded.file_path, sessions.file_path), updated_at = excluded.updated_at",
|
|
77
|
+
).run(session.id, session.file ?? null, Date.now());
|
|
78
|
+
}
|
|
79
|
+
/** Persist only after a real mutation. Snapshot revisions remain immutable for branch navigation. */
|
|
80
|
+
write(session: SessionRef, note: Omit<NoteRevision, "id">): NoteRevision {
|
|
81
|
+
const db = this.open(true)!;
|
|
82
|
+
const revision = { ...note, id: randomUUID() };
|
|
83
|
+
db.transaction(() => {
|
|
84
|
+
this.record(db, session);
|
|
85
|
+
db.prepare("INSERT INTO notes VALUES (?, ?, ?, ?, ?, ?, ?)").run(
|
|
86
|
+
revision.id,
|
|
87
|
+
session.id,
|
|
88
|
+
revision.path,
|
|
89
|
+
revision.text,
|
|
90
|
+
revision.mode,
|
|
91
|
+
revision.createdAt,
|
|
92
|
+
revision.updatedAt,
|
|
93
|
+
);
|
|
94
|
+
})();
|
|
95
|
+
return revision;
|
|
96
|
+
}
|
|
97
|
+
read(id: string): NoteRevision | undefined {
|
|
98
|
+
const row = this.open()
|
|
99
|
+
?.prepare(
|
|
100
|
+
"SELECT id, path, text, mode, created_at AS createdAt, updated_at AS updatedAt FROM notes WHERE id = ?",
|
|
101
|
+
)
|
|
102
|
+
.get(id) as NoteRevision | undefined;
|
|
103
|
+
return row;
|
|
104
|
+
}
|
|
105
|
+
updateFile(session: SessionRef): void {
|
|
106
|
+
if (!session.file) return;
|
|
107
|
+
this.open()
|
|
108
|
+
?.prepare("UPDATE sessions SET file_path = ? WHERE id = ?")
|
|
109
|
+
.run(session.file, session.id);
|
|
110
|
+
}
|
|
111
|
+
cache(session: SessionRef, responses: readonly CachedResponse[]): void {
|
|
112
|
+
if (!responses.length) return;
|
|
113
|
+
for (const response of responses) {
|
|
114
|
+
if (
|
|
115
|
+
!(response.bytes instanceof Uint8Array) ||
|
|
116
|
+
!response.bytes.length ||
|
|
117
|
+
!response.source ||
|
|
118
|
+
!response.encoding ||
|
|
119
|
+
!Number.isFinite(response.fetchedAt) ||
|
|
120
|
+
!["complete", "partial", "unknown"].includes(response.coverage)
|
|
121
|
+
)
|
|
122
|
+
throw new Error("Remote lookup returned an invalid cache response");
|
|
123
|
+
}
|
|
124
|
+
const db = this.open(true)!;
|
|
125
|
+
db.transaction(() => {
|
|
126
|
+
this.record(db, session);
|
|
127
|
+
const insert = db.prepare(
|
|
128
|
+
"INSERT INTO remote_cache VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id, query_key, source) DO UPDATE SET query_json = excluded.query_json, encoding = excluded.encoding, bytes = excluded.bytes, fetched_at = excluded.fetched_at, coverage = excluded.coverage",
|
|
129
|
+
);
|
|
130
|
+
for (const response of responses)
|
|
131
|
+
insert.run(
|
|
132
|
+
session.id,
|
|
133
|
+
queryKey(response.query),
|
|
134
|
+
JSON.stringify(response.query),
|
|
135
|
+
response.source,
|
|
136
|
+
response.encoding,
|
|
137
|
+
Buffer.from(response.bytes),
|
|
138
|
+
response.fetchedAt,
|
|
139
|
+
response.coverage,
|
|
140
|
+
);
|
|
141
|
+
})();
|
|
142
|
+
}
|
|
143
|
+
cached(sessionIds: readonly string[], query: LookupQuery): CachedResponse[] {
|
|
144
|
+
const db = this.open();
|
|
145
|
+
if (!db) return [];
|
|
146
|
+
const rows = sessionIds.flatMap(
|
|
147
|
+
(id) =>
|
|
148
|
+
db
|
|
149
|
+
.prepare(
|
|
150
|
+
"SELECT * FROM remote_cache WHERE session_id = ? AND query_key = ?",
|
|
151
|
+
)
|
|
152
|
+
.all(id, queryKey(query)) as CacheRow[],
|
|
153
|
+
);
|
|
154
|
+
const latest = new Map<string, CachedResponse>();
|
|
155
|
+
for (const row of rows)
|
|
156
|
+
if (
|
|
157
|
+
!latest.has(row.source) ||
|
|
158
|
+
latest.get(row.source)!.fetchedAt < row.fetched_at
|
|
159
|
+
)
|
|
160
|
+
latest.set(row.source, {
|
|
161
|
+
query: JSON.parse(row.query_json) as LookupQuery,
|
|
162
|
+
bytes: new Uint8Array(row.bytes),
|
|
163
|
+
source: row.source,
|
|
164
|
+
encoding: row.encoding,
|
|
165
|
+
fetchedAt: row.fetched_at,
|
|
166
|
+
coverage: row.coverage,
|
|
167
|
+
});
|
|
168
|
+
return [...latest.values()];
|
|
169
|
+
}
|
|
170
|
+
cachedPaths(sessionIds: readonly string[]): string[] {
|
|
171
|
+
const db = this.open();
|
|
172
|
+
if (!db) return [];
|
|
173
|
+
const rows = sessionIds
|
|
174
|
+
.flatMap(
|
|
175
|
+
(id) =>
|
|
176
|
+
db
|
|
177
|
+
.prepare(
|
|
178
|
+
"SELECT query_json, fetched_at FROM remote_cache WHERE session_id = ? ORDER BY fetched_at DESC LIMIT 20",
|
|
179
|
+
)
|
|
180
|
+
.all(id) as { query_json: string; fetched_at: number }[],
|
|
181
|
+
)
|
|
182
|
+
.sort((a, b) => b.fetched_at - a.fetched_at);
|
|
183
|
+
return rows.flatMap((row) => {
|
|
184
|
+
const query = JSON.parse(row.query_json) as LookupQuery;
|
|
185
|
+
return query.namespace === "notes" &&
|
|
186
|
+
query.params["action"] === "read_file" &&
|
|
187
|
+
typeof query.params["path"] === "string"
|
|
188
|
+
? [query.params["path"]]
|
|
189
|
+
: [];
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
status(): { sessions: number; revisions: number; cachedResponses: number } {
|
|
193
|
+
const db = this.open();
|
|
194
|
+
const count = (table: string) =>
|
|
195
|
+
(
|
|
196
|
+
db?.prepare(`SELECT count(*) AS n FROM ${table}`).get() as
|
|
197
|
+
| { n: number }
|
|
198
|
+
| undefined
|
|
199
|
+
)?.n ?? 0;
|
|
200
|
+
return {
|
|
201
|
+
sessions: count("sessions"),
|
|
202
|
+
revisions: count("notes"),
|
|
203
|
+
cachedResponses: count("remote_cache"),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
prune(activeSessionId: string): {
|
|
207
|
+
removed: number;
|
|
208
|
+
unknownPaths: number;
|
|
209
|
+
inaccessible: number;
|
|
210
|
+
} {
|
|
211
|
+
const db = this.open();
|
|
212
|
+
const result = { removed: 0, unknownPaths: 0, inaccessible: 0 };
|
|
213
|
+
if (!db) return result;
|
|
214
|
+
const sessions = db.prepare("SELECT id, file_path FROM sessions").all() as {
|
|
215
|
+
id: string;
|
|
216
|
+
file_path: string | null;
|
|
217
|
+
}[];
|
|
218
|
+
for (const session of sessions) {
|
|
219
|
+
if (session.id === activeSessionId) continue;
|
|
220
|
+
if (!session.file_path) {
|
|
221
|
+
result.unknownPaths++;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
statSync(session.file_path);
|
|
226
|
+
} catch (error) {
|
|
227
|
+
// Only missing paths prove deletion. Permission failures and malformed paths do not.
|
|
228
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
229
|
+
db.prepare("DELETE FROM sessions WHERE id = ?").run(session.id);
|
|
230
|
+
result.removed++;
|
|
231
|
+
} else result.inaccessible++;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
close(): void {
|
|
237
|
+
this.db?.close();
|
|
238
|
+
this.db = undefined;
|
|
239
|
+
}
|
|
240
|
+
}
|