@timurproko/a1 0.1.8-dev.290 → 0.1.8-dev.299

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.
Files changed (62) hide show
  1. package/README.md +9 -0
  2. package/dist/composition/owned-ui.js +12 -1
  3. package/dist/contracts/owned-ui/index.d.ts +1 -0
  4. package/dist/contracts/owned-ui/index.js +1 -0
  5. package/dist/contracts/owned-ui/prompt-history.d.ts +37 -0
  6. package/dist/contracts/owned-ui/prompt-history.js +23 -0
  7. package/dist/features/prompt-history/index.d.ts +2 -0
  8. package/dist/features/prompt-history/index.js +2 -0
  9. package/dist/features/prompt-history/paths.d.ts +4 -0
  10. package/dist/features/prompt-history/paths.js +16 -0
  11. package/dist/features/prompt-history/service.d.ts +24 -0
  12. package/dist/features/prompt-history/service.js +260 -0
  13. package/dist/features/prompt-history/store.d.ts +15 -0
  14. package/dist/features/prompt-history/store.js +166 -0
  15. package/dist/features/prompt-history/worker.d.ts +9 -0
  16. package/dist/features/prompt-history/worker.js +42 -0
  17. package/dist/integrations/pi/components/editor-interaction.d.ts +38 -0
  18. package/dist/integrations/pi/components/editor-interaction.js +1 -0
  19. package/dist/integrations/pi/components/history-editor-loader.d.ts +5 -0
  20. package/dist/integrations/pi/components/history-editor-loader.js +5 -0
  21. package/dist/integrations/pi/components/index.d.ts +1 -0
  22. package/dist/integrations/pi/components/index.js +1 -0
  23. package/dist/integrations/pi/components/owned-editor-ux.d.ts +1 -1
  24. package/dist/integrations/pi/components/owned-editor-ux.js +41 -19
  25. package/dist/integrations/pi/components/shell-editor-autocomplete.js +9 -1
  26. package/dist/integrations/pi/components/shell-shared-facade.d.ts +6 -0
  27. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +13 -9
  28. package/dist/integrations/pi/components/upstream/components/owned-editor.js +137 -133
  29. package/dist/integrations/pi/components/upstream/history/editor-core.d.ts +280 -0
  30. package/dist/integrations/pi/components/upstream/history/editor-core.js +2062 -0
  31. package/dist/integrations/pi/components/upstream/history/kill-ring.d.ts +32 -0
  32. package/dist/integrations/pi/components/upstream/history/kill-ring.js +48 -0
  33. package/dist/integrations/pi/components/upstream/history/printable-key.d.ts +1 -0
  34. package/dist/integrations/pi/components/upstream/history/printable-key.js +40 -0
  35. package/dist/integrations/pi/components/upstream/history/text-helpers.d.ts +14 -0
  36. package/dist/integrations/pi/components/upstream/history/text-helpers.js +28 -0
  37. package/dist/integrations/pi/components/upstream/history/undo-stack.d.ts +22 -0
  38. package/dist/integrations/pi/components/upstream/history/undo-stack.js +36 -0
  39. package/dist/integrations/pi/components/upstream/history/word-navigation.d.ts +24 -0
  40. package/dist/integrations/pi/components/upstream/history/word-navigation.js +100 -0
  41. package/dist/integrations/pi/session-ui/prompt-chips.d.ts +1 -0
  42. package/dist/integrations/pi/session-ui/prompt-chips.js +3 -0
  43. package/dist/integrations/pi/session-ui/prompt-history-controller.d.ts +30 -0
  44. package/dist/integrations/pi/session-ui/prompt-history-controller.js +147 -0
  45. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +8 -0
  46. package/dist/integrations/pi/session-ui/session-shell-root.js +3 -0
  47. package/dist/integrations/pi/session-ui/session-shell.js +40 -5
  48. package/dist/native/darwin-arm64/manifest.json +1 -1
  49. package/dist/native/linux-x64/manifest.json +1 -1
  50. package/dist/native/win32-x64/manifest.json +2 -2
  51. package/dist/native/win32-x64/process-guardian.exe +0 -0
  52. package/dist/product-identity.d.ts +1 -1
  53. package/dist/product-identity.js +1 -1
  54. package/dist/product-identity.json +1 -0
  55. package/dist/ui/settings/declarations.js +18 -0
  56. package/dist/ui/settings/session.js +13 -4
  57. package/docs/architecture/boundaries.md +1 -0
  58. package/docs/architecture/history-editor-provenance.md +96 -0
  59. package/docs/architecture/project-structure.md +1 -0
  60. package/docs/architecture/resource-and-data-policy.md +22 -1
  61. package/docs/features/prompt-history.md +121 -0
  62. package/package.json +1 -1
package/README.md CHANGED
@@ -45,6 +45,15 @@ a1 pi # vanilla Pi oracle: ~/.pi/agent
45
45
  Unsupported commands exit quietly without launching anything. The removed
46
46
  `update:<preview>` forms are not aliases; use `update --develop`.
47
47
 
48
+ ## Prompt history
49
+
50
+ Bare A1 recalls recent unique prompts across sessions with Up/Down. History is
51
+ stored in a separate profile-local SQLite database under A1's application-data
52
+ root, not `~/.a1/agent` or disposable caches. `/settings` → History controls
53
+ persistence and the 10-100-entry limit (default 100), effective on next start.
54
+ History is unencrypted user text; images are not reattached. See
55
+ [prompt history](docs/features/prompt-history.md) for privacy, storage, and removal.
56
+
48
57
  ## Extensions
49
58
 
50
59
  Pi extension packages install into A1's own profile (`~/.a1/agent`), so bare `a1`
@@ -1,5 +1,6 @@
1
+ import { PromptHistoryService } from "../features/prompt-history/index.js";
1
2
  import { resolveProductPaths } from "../foundation/lifecycle/index.js";
2
- import { applyConfiguredPiTheme, getAvailablePiThemes } from "../integrations/pi/components/index.js";
3
+ import { applyConfiguredPiTheme, getAvailablePiThemes, loadHistoryEditor } from "../integrations/pi/components/index.js";
3
4
  import { createPiEngineAdapter, } from "../integrations/pi/engine/index.js";
4
5
  import { OwnedUiSessionShell } from "../integrations/pi/session-ui/index.js";
5
6
  import { OwnedUiSettingsSession, OwnedUiSettingsStore } from "../ui/settings/index.js";
@@ -42,6 +43,15 @@ export async function composeOwnedUi(options = {}) {
42
43
  enabled: () => settings.value("promptSuggestions") !== false,
43
44
  onChange: (listener) => settings.onChange(() => listener(settings.value("promptSuggestions") !== false)),
44
45
  };
46
+ const historyLimit = settings?.value("promptHistoryMaxItems");
47
+ const promptHistory = settings === null || !ownedSurfaces || settings.value("promptHistoryEnabled") === false ? null : {
48
+ limit: typeof historyLimit === "number" ? historyLimit : 100,
49
+ store: new PromptHistoryService({
50
+ dataDir: resolveProductPaths().dataDir,
51
+ profileRoot: adapter.agentDir,
52
+ limit: typeof historyLimit === "number" ? historyLimit : 100,
53
+ }),
54
+ };
45
55
  const shell = new OwnedUiSessionShell({
46
56
  backend: adapter,
47
57
  cwd: adapter.cwd,
@@ -50,6 +60,7 @@ export async function composeOwnedUi(options = {}) {
50
60
  ...(ownedSurfaces ? { sessionLayout: "custom-viewport" } : {}),
51
61
  ...(viewportSettings === null ? {} : { viewportSettings }),
52
62
  ...(promptSuggestions === null ? {} : { promptSuggestions }),
63
+ ...(promptHistory === null ? {} : { promptHistory: { ...promptHistory, editor: await loadHistoryEditor() } }),
53
64
  });
54
65
  const application = {
55
66
  get disposed() { return adapter.disposed; },
@@ -2,4 +2,5 @@ export * from "./extension-ui.js";
2
2
  export * from "./model.js";
3
3
  export * from "./image-attachments.js";
4
4
  export * from "./prompt-suggestions.js";
5
+ export * from "./prompt-history.js";
5
6
  export * from "./validation.js";
@@ -2,4 +2,5 @@ export * from "./extension-ui.js";
2
2
  export * from "./model.js";
3
3
  export * from "./image-attachments.js";
4
4
  export * from "./prompt-suggestions.js";
5
+ export * from "./prompt-history.js";
5
6
  export * from "./validation.js";
@@ -0,0 +1,37 @@
1
+ export declare const PROMPT_HISTORY_EDITOR_REPLACEMENT: Readonly<{
2
+ readonly id: "persistent-prompt-history";
3
+ readonly kind: "replacement";
4
+ readonly slot: "editor";
5
+ readonly replaces: readonly string[];
6
+ }>;
7
+ export type PromptHistoryKind = "prompt" | "steer" | "follow-up" | "bash" | "slash";
8
+ export interface PromptHistorySubmission {
9
+ readonly id: string;
10
+ readonly text: string;
11
+ readonly timestamp: number;
12
+ readonly kind: PromptHistoryKind;
13
+ readonly cwd?: string;
14
+ readonly sessionId?: string;
15
+ }
16
+ export interface PromptHistorySnapshot {
17
+ readonly revision: number;
18
+ readonly limit: number;
19
+ readonly entries: readonly {
20
+ readonly text: string;
21
+ readonly submissionId: string;
22
+ }[];
23
+ }
24
+ export type PromptHistoryFailure = "unavailable" | "busy" | "capacity" | "oversized" | "schema" | "corrupt" | "shutdown";
25
+ export type PromptHistoryResult = "committed" | "skipped";
26
+ export interface PromptHistoryPort {
27
+ start(): void;
28
+ record(submission: PromptHistorySubmission): Promise<PromptHistoryResult>;
29
+ refresh(): void;
30
+ onSnapshot(listener: (snapshot: PromptHistorySnapshot) => void): () => void;
31
+ onFailure(listener: (code: PromptHistoryFailure) => void): () => void;
32
+ close(): Promise<void>;
33
+ }
34
+ export declare const PROMPT_HISTORY_MAX_ENTRY_BYTES: number;
35
+ export declare const PROMPT_HISTORY_MAX_TEXT_BYTES: number;
36
+ export declare const PROMPT_HISTORY_MAX_PENDING = 32;
37
+ export declare function assertPromptHistorySubmission(value: unknown): asserts value is PromptHistorySubmission;
@@ -0,0 +1,23 @@
1
+ export const PROMPT_HISTORY_EDITOR_REPLACEMENT = Object.freeze({
2
+ id: "persistent-prompt-history",
3
+ kind: "replacement",
4
+ slot: "editor",
5
+ replaces: Object.freeze(["current-session-history-source", "adjacent-duplicate-policy", "recall-caret-placement", "history-border"]),
6
+ });
7
+ export const PROMPT_HISTORY_MAX_ENTRY_BYTES = 1024 * 1024;
8
+ export const PROMPT_HISTORY_MAX_TEXT_BYTES = 8 * 1024 * 1024;
9
+ export const PROMPT_HISTORY_MAX_PENDING = 32;
10
+ export function assertPromptHistorySubmission(value) {
11
+ if (typeof value !== "object" || value === null || Array.isArray(value))
12
+ throw new TypeError("Invalid history submission");
13
+ const item = value;
14
+ const allowed = new Set(["id", "text", "timestamp", "kind", "cwd", "sessionId"]);
15
+ if (Object.keys(item).some(key => !allowed.has(key))
16
+ || typeof item.id !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(item.id)
17
+ || typeof item.text !== "string" || !item.text.trim() || item.text !== item.text.trim()
18
+ || typeof item.timestamp !== "number" || !Number.isSafeInteger(item.timestamp) || item.timestamp < 0
19
+ || typeof item.kind !== "string" || !["prompt", "steer", "follow-up", "bash", "slash"].includes(item.kind)
20
+ || item.cwd !== undefined && typeof item.cwd !== "string"
21
+ || item.sessionId !== undefined && typeof item.sessionId !== "string")
22
+ throw new TypeError("Invalid history submission");
23
+ }
@@ -0,0 +1,2 @@
1
+ export { PromptHistoryService } from "./service.js";
2
+ export { resolvePromptHistoryPath } from "./paths.js";
@@ -0,0 +1,2 @@
1
+ export { PromptHistoryService } from "./service.js";
2
+ export { resolvePromptHistoryPath } from "./paths.js";
@@ -0,0 +1,4 @@
1
+ export declare function resolvePromptHistoryPath(dataDir: string, profileRoot: string, platform?: NodeJS.Platform): {
2
+ path: string;
3
+ profileId: string;
4
+ };
@@ -0,0 +1,16 @@
1
+ import { createHash } from "node:crypto";
2
+ import { posix, win32 } from "node:path";
3
+ import { PRODUCT_IDENTITY } from "../../product-identity.js";
4
+ export function resolvePromptHistoryPath(dataDir, profileRoot, platform = process.platform) {
5
+ const paths = platform === "win32" ? win32 : posix;
6
+ if (!paths.isAbsolute(dataDir) || !paths.isAbsolute(profileRoot) || dataDir.includes("\0") || profileRoot.includes("\0"))
7
+ throw new TypeError("History requires absolute roots");
8
+ let normalized = paths.normalize(profileRoot);
9
+ if (normalized.length > paths.parse(normalized).root.length)
10
+ normalized = normalized.replace(/[\\/]+$/, "");
11
+ if (platform === "win32")
12
+ normalized = normalized.replaceAll("\\", "/").toLowerCase();
13
+ const digest = createHash("sha256").update(JSON.stringify([1, PRODUCT_IDENTITY.commandName, normalized])).digest("hex");
14
+ const profileId = `${PRODUCT_IDENTITY.filesystem.temporaryPrefix}${digest}`;
15
+ return { path: paths.join(dataDir, "history", `${profileId}.sqlite3`), profileId };
16
+ }
@@ -0,0 +1,24 @@
1
+ import type { Worker } from "node:worker_threads";
2
+ import { type PromptHistoryFailure, type PromptHistoryPort, type PromptHistoryResult, type PromptHistorySnapshot, type PromptHistorySubmission } from "../../contracts/owned-ui/index.js";
3
+ /** Owns a finite asynchronous history queue; storage faults never throw into prompt dispatch. */
4
+ export declare class PromptHistoryService implements PromptHistoryPort {
5
+ #private;
6
+ readonly options: {
7
+ dataDir: string;
8
+ profileRoot: string;
9
+ limit: number;
10
+ createWorker?: () => Worker;
11
+ };
12
+ constructor(options: {
13
+ dataDir: string;
14
+ profileRoot: string;
15
+ limit: number;
16
+ createWorker?: () => Worker;
17
+ });
18
+ start(): void;
19
+ record(submission: PromptHistorySubmission): Promise<PromptHistoryResult>;
20
+ refresh(): void;
21
+ onSnapshot(listener: (snapshot: PromptHistorySnapshot) => void): () => void;
22
+ onFailure(listener: (code: PromptHistoryFailure) => void): () => void;
23
+ close(): Promise<void>;
24
+ }
@@ -0,0 +1,260 @@
1
+ import { assertPromptHistorySubmission, PROMPT_HISTORY_MAX_ENTRY_BYTES, PROMPT_HISTORY_MAX_PENDING, PROMPT_HISTORY_MAX_TEXT_BYTES } from "../../contracts/owned-ui/index.js";
2
+ import { resolvePromptHistoryPath } from "./paths.js";
3
+ /** Owns a finite asynchronous history queue; storage faults never throw into prompt dispatch. */
4
+ export class PromptHistoryService {
5
+ options;
6
+ #snapshots = new Set();
7
+ #failures = new Set();
8
+ #reported = new Set();
9
+ #queue = [];
10
+ #worker;
11
+ #active;
12
+ #timer;
13
+ #poll;
14
+ #sequence = 0;
15
+ #bytes = 0;
16
+ #writes = 0;
17
+ #started = false;
18
+ #ready = false;
19
+ #failed = false;
20
+ #closing = false;
21
+ #reading = false;
22
+ #refreshPending = false;
23
+ #lastRevision = -1;
24
+ #closePromise;
25
+ constructor(options) {
26
+ this.options = options;
27
+ }
28
+ start() {
29
+ if (this.#started || this.#closing)
30
+ return;
31
+ this.#started = true;
32
+ setImmediate(() => { void this.#open().catch(() => this.#fail("unavailable")); });
33
+ }
34
+ record(submission) {
35
+ if (this.#failed || this.#closing)
36
+ return Promise.resolve("skipped");
37
+ try {
38
+ assertPromptHistorySubmission(submission);
39
+ }
40
+ catch {
41
+ this.#report("unavailable");
42
+ return Promise.resolve("skipped");
43
+ }
44
+ const bytes = Buffer.byteLength(submission.text);
45
+ if (bytes > PROMPT_HISTORY_MAX_ENTRY_BYTES) {
46
+ this.#report("oversized");
47
+ return Promise.resolve("skipped");
48
+ }
49
+ if (this.#writes >= PROMPT_HISTORY_MAX_PENDING || this.#bytes + bytes > PROMPT_HISTORY_MAX_TEXT_BYTES) {
50
+ this.#report("capacity");
51
+ return Promise.resolve("skipped");
52
+ }
53
+ const candidate = {
54
+ id: submission.id, text: submission.text, timestamp: submission.timestamp, kind: submission.kind,
55
+ ...(submission.cwd !== undefined && Buffer.byteLength(submission.cwd) <= 8192 ? { cwd: submission.cwd } : {}),
56
+ ...(submission.sessionId !== undefined && Buffer.byteLength(submission.sessionId) <= 256 ? { sessionId: submission.sessionId } : {}),
57
+ };
58
+ this.start();
59
+ this.#writes++;
60
+ this.#bytes += bytes;
61
+ return new Promise(resolve => {
62
+ this.#queue.push({ request: { id: ++this.#sequence, kind: "record", submission: candidate }, done: (_value, ok) => {
63
+ this.#writes--;
64
+ this.#bytes -= bytes;
65
+ resolve(ok ? "committed" : "skipped");
66
+ if (ok)
67
+ this.refresh();
68
+ } });
69
+ this.#drain();
70
+ });
71
+ }
72
+ refresh() {
73
+ if (this.#closing || this.#failed)
74
+ return;
75
+ this.start();
76
+ if (this.#reading) {
77
+ this.#refreshPending = true;
78
+ return;
79
+ }
80
+ this.#reading = true;
81
+ const observers = [...this.#snapshots];
82
+ this.#queue.push({ request: { id: ++this.#sequence, kind: "read" }, done: (value, ok) => {
83
+ this.#reading = false;
84
+ if (ok) {
85
+ if (!isSnapshot(value)) {
86
+ this.#fail("corrupt");
87
+ return;
88
+ }
89
+ if (value.revision !== this.#lastRevision && observers.some(listener => this.#snapshots.has(listener))) {
90
+ this.#lastRevision = value.revision;
91
+ for (const listener of observers) {
92
+ if (!this.#snapshots.has(listener))
93
+ continue;
94
+ try {
95
+ listener(value);
96
+ }
97
+ catch { /* Security: observer errors must not escape the worker event handler. */ }
98
+ }
99
+ }
100
+ }
101
+ if (this.#refreshPending) {
102
+ this.#refreshPending = false;
103
+ this.refresh();
104
+ }
105
+ } });
106
+ this.#drain();
107
+ }
108
+ onSnapshot(listener) {
109
+ this.#snapshots.add(listener);
110
+ this.#lastRevision = -1;
111
+ return () => this.#snapshots.delete(listener);
112
+ }
113
+ onFailure(listener) {
114
+ this.#failures.add(listener);
115
+ return () => this.#failures.delete(listener);
116
+ }
117
+ close() {
118
+ if (this.#closePromise !== undefined)
119
+ return this.#closePromise;
120
+ this.#closing = true;
121
+ if (this.#poll !== undefined)
122
+ clearInterval(this.#poll);
123
+ this.#closePromise = new Promise(resolve => {
124
+ if (!this.#started || this.#failed) {
125
+ resolve();
126
+ return;
127
+ }
128
+ const deadline = setTimeout(() => { this.#report("shutdown"); this.#fail("shutdown"); resolve(); }, 2000);
129
+ this.#queue.push({ request: { id: ++this.#sequence, kind: "close" }, done: (_value, ok) => {
130
+ if (!ok)
131
+ this.#report("shutdown");
132
+ const stopped = this.#worker?.terminate() ?? Promise.resolve();
133
+ void stopped.catch(() => { }).finally(() => { clearTimeout(deadline); resolve(); });
134
+ } });
135
+ this.#drain();
136
+ });
137
+ return this.#closePromise;
138
+ }
139
+ async #open() {
140
+ if (this.#failed)
141
+ return;
142
+ const { Worker } = await import("node:worker_threads");
143
+ if (this.#failed)
144
+ return;
145
+ const location = resolvePromptHistoryPath(this.options.dataDir, this.options.profileRoot);
146
+ const source = import.meta.url.endsWith(".ts");
147
+ const entry = new URL(source ? "./worker.ts" : "./worker.js", import.meta.url);
148
+ const options = { workerData: { ...location, limit: this.options.limit }, stdout: true, stderr: true };
149
+ this.#worker = this.options.createWorker?.() ?? (source
150
+ ? new Worker(`import('tsx/esm/api').then(({ tsImport }) => tsImport(${JSON.stringify(entry.href)}, ${JSON.stringify(import.meta.url)}))`, { ...options, eval: true })
151
+ : new Worker(entry, options));
152
+ this.#worker.stdout.resume();
153
+ this.#worker.stderr.resume();
154
+ this.#timer = setTimeout(() => this.#fail("unavailable"), 5000);
155
+ this.#worker.on("message", (message) => {
156
+ if (this.#failed)
157
+ return;
158
+ clearTimeout(this.#timer);
159
+ if (message === null || typeof message !== "object" || !Number.isSafeInteger(message.id) || typeof message.ok !== "boolean") {
160
+ this.#fail("unavailable");
161
+ return;
162
+ }
163
+ if (!message.ok) {
164
+ this.#fail(failureCode(message.code));
165
+ return;
166
+ }
167
+ if (message.id === 0) {
168
+ this.#ready = true;
169
+ if (!this.#closing) {
170
+ this.refresh();
171
+ this.#poll = setInterval(() => this.refresh(), 1000);
172
+ this.#poll.unref();
173
+ }
174
+ }
175
+ else {
176
+ const item = this.#active;
177
+ if (item?.request.id !== message.id) {
178
+ this.#fail("unavailable");
179
+ return;
180
+ }
181
+ this.#active = undefined;
182
+ item.done(message.value, true);
183
+ }
184
+ this.#drain();
185
+ });
186
+ this.#worker.on("error", () => this.#fail("unavailable"));
187
+ this.#worker.on("exit", () => { if (!this.#closing || this.#active !== undefined || this.#queue.length > 0)
188
+ this.#fail("unavailable"); });
189
+ }
190
+ #drain() {
191
+ if (!this.#ready || this.#failed || this.#active !== undefined)
192
+ return;
193
+ const next = this.#queue.shift();
194
+ if (next === undefined)
195
+ return;
196
+ this.#active = next;
197
+ this.#timer = setTimeout(() => this.#fail("busy"), 1500);
198
+ try {
199
+ this.#worker.postMessage(next.request);
200
+ }
201
+ catch {
202
+ this.#fail("unavailable");
203
+ }
204
+ }
205
+ #report(code) {
206
+ if (this.#reported.has(code))
207
+ return;
208
+ this.#reported.add(code);
209
+ for (const listener of this.#failures) {
210
+ try {
211
+ listener(code);
212
+ }
213
+ catch { /* Security: caller exceptions are not persistence diagnostics. */ }
214
+ }
215
+ }
216
+ #fail(code) {
217
+ if (this.#failed)
218
+ return;
219
+ this.#failed = true;
220
+ clearTimeout(this.#timer);
221
+ clearInterval(this.#poll);
222
+ this.#report(code);
223
+ const active = this.#active;
224
+ this.#active = undefined;
225
+ active?.done(undefined, false);
226
+ for (const item of this.#queue.splice(0))
227
+ item.done(undefined, false);
228
+ this.#worker?.unref();
229
+ void this.#worker?.terminate().catch(() => { });
230
+ }
231
+ }
232
+ function failureCode(value) {
233
+ switch (value) {
234
+ case "busy":
235
+ case "capacity":
236
+ case "oversized":
237
+ case "schema":
238
+ case "corrupt":
239
+ case "shutdown": return value;
240
+ default: return "unavailable";
241
+ }
242
+ }
243
+ function isSnapshot(value) {
244
+ if (typeof value !== "object" || value === null)
245
+ return false;
246
+ const candidate = value;
247
+ if (!Number.isSafeInteger(candidate.revision) || candidate.revision < 0 || !Number.isInteger(candidate.limit)
248
+ || candidate.limit < 10 || candidate.limit > 100 || candidate.limit % 10 !== 0
249
+ || !Array.isArray(candidate.entries) || candidate.entries.length > candidate.limit)
250
+ return false;
251
+ let bytes = 0;
252
+ return candidate.entries.every(item => {
253
+ if (typeof item?.text !== "string" || item.text !== item.text.trim()
254
+ || typeof item.submissionId !== "string" || item.submissionId.length > 128)
255
+ return false;
256
+ const size = Buffer.byteLength(item.text);
257
+ bytes += size;
258
+ return size > 0 && size <= PROMPT_HISTORY_MAX_ENTRY_BYTES && bytes <= PROMPT_HISTORY_MAX_TEXT_BYTES;
259
+ });
260
+ }
@@ -0,0 +1,15 @@
1
+ import { type PromptHistoryFailure, type PromptHistorySnapshot, type PromptHistorySubmission } from "../../contracts/owned-ui/index.js";
2
+ export declare class HistoryStorageError extends Error {
3
+ readonly code: PromptHistoryFailure;
4
+ constructor(code: PromptHistoryFailure);
5
+ }
6
+ export declare class PromptHistoryStore {
7
+ #private;
8
+ readonly path: string;
9
+ readonly profileId: string;
10
+ constructor(path: string, profileId: string, limit: number);
11
+ record(input: PromptHistorySubmission): void;
12
+ snapshot(): PromptHistorySnapshot;
13
+ close(): void;
14
+ }
15
+ export declare function classifyHistoryError(error: unknown): PromptHistoryFailure;
@@ -0,0 +1,166 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, statSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { PRODUCT_IDENTITY } from "../../product-identity.js";
5
+ import { assertPromptHistorySubmission, PROMPT_HISTORY_MAX_ENTRY_BYTES, PROMPT_HISTORY_MAX_TEXT_BYTES } from "../../contracts/owned-ui/index.js";
6
+ export class HistoryStorageError extends Error {
7
+ code;
8
+ constructor(code) {
9
+ super(`Prompt history ${code}`);
10
+ this.code = code;
11
+ }
12
+ }
13
+ const MAX_STORAGE_BYTES = 64 * 1024 * 1024;
14
+ export class PromptHistoryStore {
15
+ path;
16
+ profileId;
17
+ #database;
18
+ constructor(path, profileId, limit) {
19
+ this.path = path;
20
+ this.profileId = profileId;
21
+ if (!Number.isInteger(limit) || limit < 10 || limit > 100 || limit % 10 !== 0)
22
+ throw new HistoryStorageError("schema");
23
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
24
+ if (lstatSync(dirname(path)).isSymbolicLink())
25
+ throw new HistoryStorageError("unavailable");
26
+ if (process.platform !== "win32")
27
+ chmodSync(dirname(path), 0o700);
28
+ try {
29
+ closeSync(openSync(path, "wx", 0o600));
30
+ }
31
+ catch (error) {
32
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
33
+ throw error;
34
+ }
35
+ if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isFile())
36
+ throw new HistoryStorageError("unavailable");
37
+ if (process.platform !== "win32")
38
+ chmodSync(path, 0o600);
39
+ this.#checkSize();
40
+ this.#database = new DatabaseSync(path);
41
+ try {
42
+ this.#database.exec("PRAGMA busy_timeout = 25");
43
+ this.#transaction(() => {
44
+ const version = Number(this.#database.prepare("PRAGMA user_version").get()?.user_version);
45
+ if (version === 0) {
46
+ const tables = this.#database.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
47
+ if (tables.length !== 0)
48
+ throw new HistoryStorageError("schema");
49
+ this.#database.exec(`CREATE TABLE history_meta (id INTEGER PRIMARY KEY CHECK(id=1), schema TEXT NOT NULL, profile TEXT NOT NULL, revision INTEGER NOT NULL, limit_count INTEGER NOT NULL);
50
+ CREATE TABLE prompts (text TEXT PRIMARY KEY COLLATE BINARY, submission_id TEXT NOT NULL, sequence INTEGER NOT NULL UNIQUE, timestamp INTEGER NOT NULL, kind TEXT NOT NULL, cwd TEXT, session_id TEXT, bytes INTEGER NOT NULL CHECK(bytes>0 AND bytes<=1048576));
51
+ PRAGMA user_version=1;`);
52
+ this.#database.prepare("INSERT INTO history_meta VALUES (1,?,?,0,?)").run(PRODUCT_IDENTITY.protocol.promptHistorySchema, profileId, limit);
53
+ }
54
+ else if (version !== 1)
55
+ throw new HistoryStorageError("schema");
56
+ const meta = this.#database.prepare("SELECT schema,profile FROM history_meta WHERE id=1").get();
57
+ if (meta?.schema !== PRODUCT_IDENTITY.protocol.promptHistorySchema || meta?.profile !== profileId)
58
+ throw new HistoryStorageError("schema");
59
+ this.#database.prepare("UPDATE history_meta SET limit_count=?,revision=revision+1 WHERE id=1").run(limit);
60
+ this.#prune(limit);
61
+ });
62
+ this.#database.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA wal_autocheckpoint=128; PRAGMA journal_size_limit=8388608; PRAGMA max_page_count=8192;");
63
+ }
64
+ catch (error) {
65
+ this.#database.close();
66
+ throw error;
67
+ }
68
+ }
69
+ record(input) {
70
+ assertPromptHistorySubmission(input);
71
+ const bytes = Buffer.byteLength(input.text);
72
+ if (bytes > PROMPT_HISTORY_MAX_ENTRY_BYTES)
73
+ throw new HistoryStorageError("oversized");
74
+ this.#checkSize(true);
75
+ this.#transaction(() => {
76
+ const duplicate = this.#database.prepare("SELECT submission_id FROM prompts WHERE text=?").get(input.text);
77
+ if (duplicate?.submission_id === input.id)
78
+ return;
79
+ this.#database.exec("UPDATE history_meta SET revision=revision+1 WHERE id=1");
80
+ const meta = this.#database.prepare("SELECT revision,limit_count FROM history_meta WHERE id=1").get();
81
+ this.#database.prepare(`INSERT INTO prompts VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(text) DO UPDATE SET submission_id=excluded.submission_id,sequence=excluded.sequence,timestamp=excluded.timestamp,kind=excluded.kind,cwd=excluded.cwd,session_id=excluded.session_id,bytes=excluded.bytes`)
82
+ .run(input.text, input.id, meta.revision, input.timestamp, input.kind, bounded(input.cwd, 8192), bounded(input.sessionId, 256), bytes);
83
+ this.#prune(Number(meta.limit_count));
84
+ });
85
+ }
86
+ snapshot() {
87
+ this.#database.exec("BEGIN");
88
+ try {
89
+ const meta = this.#database.prepare("SELECT revision,limit_count FROM history_meta WHERE id=1").get();
90
+ const entries = this.#database.prepare("SELECT text,submission_id FROM prompts ORDER BY sequence DESC LIMIT 100").all();
91
+ let bytes = 0;
92
+ const result = entries.map(row => {
93
+ if (typeof row.text !== "string" || typeof row.submission_id !== "string")
94
+ throw new HistoryStorageError("corrupt");
95
+ bytes += Buffer.byteLength(row.text);
96
+ if (bytes > PROMPT_HISTORY_MAX_TEXT_BYTES)
97
+ throw new HistoryStorageError("corrupt");
98
+ return { text: row.text, submissionId: row.submission_id };
99
+ });
100
+ this.#database.exec("COMMIT");
101
+ return { revision: Number(meta.revision), limit: Number(meta.limit_count), entries: result };
102
+ }
103
+ catch (error) {
104
+ this.#database.exec("ROLLBACK");
105
+ throw error;
106
+ }
107
+ }
108
+ close() { this.#database.close(); }
109
+ #transaction(work) {
110
+ this.#database.exec("BEGIN IMMEDIATE");
111
+ try {
112
+ work();
113
+ this.#database.exec("COMMIT");
114
+ }
115
+ catch (error) {
116
+ this.#database.exec("ROLLBACK");
117
+ throw error;
118
+ }
119
+ }
120
+ #prune(limit) {
121
+ const rows = this.#database.prepare("SELECT sequence,bytes FROM prompts ORDER BY sequence DESC").all();
122
+ let bytes = 0;
123
+ for (const [index, row] of rows.entries()) {
124
+ bytes += Number(row.bytes);
125
+ if (index >= limit || bytes > PROMPT_HISTORY_MAX_TEXT_BYTES)
126
+ this.#database.prepare("DELETE FROM prompts WHERE sequence=?").run(row.sequence);
127
+ }
128
+ }
129
+ #checkSize(maintain = false) {
130
+ let size = 0;
131
+ for (const suffix of ["", "-wal", "-shm"]) {
132
+ try {
133
+ if (lstatSync(this.path + suffix).isSymbolicLink())
134
+ throw new HistoryStorageError("unavailable");
135
+ size += statSync(this.path + suffix).size;
136
+ }
137
+ catch (error) {
138
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
139
+ throw error;
140
+ }
141
+ }
142
+ if (size > MAX_STORAGE_BYTES && maintain) {
143
+ this.#database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
144
+ this.#checkSize();
145
+ }
146
+ else if (size > MAX_STORAGE_BYTES)
147
+ throw new HistoryStorageError("capacity");
148
+ }
149
+ }
150
+ function bounded(value, bytes) {
151
+ return value !== undefined && Buffer.byteLength(value) <= bytes ? value : null;
152
+ }
153
+ export function classifyHistoryError(error) {
154
+ if (error instanceof HistoryStorageError)
155
+ return error.code;
156
+ if (error instanceof Error && "errcode" in error) {
157
+ const code = Number(error.errcode) & 0xff;
158
+ if (code === 5 || code === 6)
159
+ return "busy";
160
+ if (code === 11 || code === 26)
161
+ return "corrupt";
162
+ if (code === 13)
163
+ return "capacity";
164
+ }
165
+ return "unavailable";
166
+ }
@@ -0,0 +1,9 @@
1
+ import type { PromptHistorySubmission } from "../../contracts/owned-ui/index.js";
2
+ export type HistoryWorkerRequest = {
3
+ id: number;
4
+ kind: "read" | "close";
5
+ } | {
6
+ id: number;
7
+ kind: "record";
8
+ submission: PromptHistorySubmission;
9
+ };