@huseynsnmz/pi-suite 0.1.0 → 0.2.0

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 (93) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -1
  3. package/package.json +6 -7
  4. package/packages/board/package.json +0 -2
  5. package/packages/board/src/core/rows.mjs +21 -5
  6. package/packages/board/src/ui/dashboard.ts +36 -20
  7. package/packages/commands/LICENSE +21 -0
  8. package/packages/commands/README.md +19 -0
  9. package/packages/commands/package.json +22 -0
  10. package/packages/commands/src/index.ts +43 -0
  11. package/packages/commands/src/security-review.ts +109 -0
  12. package/packages/frame/README.md +1 -1
  13. package/packages/frame/package.json +1 -4
  14. package/packages/frame/src/data/github.ts +1 -1
  15. package/packages/frame/src/data/mode.ts +28 -2
  16. package/packages/frame/src/data/snapshot.ts +1 -1
  17. package/packages/frame/src/data/start-notes.ts +1 -1
  18. package/packages/frame/src/data/totals.ts +2 -2
  19. package/packages/frame/src/data/visits.ts +1 -1
  20. package/packages/frame/src/index.ts +22 -2
  21. package/packages/frame/src/kit/LICENSE +21 -0
  22. package/packages/frame/src/kit/app/attribution.ts +30 -0
  23. package/packages/frame/src/kit/app/builder.ts +186 -0
  24. package/packages/frame/src/kit/app/claim.ts +27 -0
  25. package/packages/frame/src/kit/app/feature.ts +91 -0
  26. package/packages/frame/src/kit/app/host.ts +214 -0
  27. package/packages/frame/src/kit/app/renderers.ts +62 -0
  28. package/packages/frame/src/kit/app/scope.ts +125 -0
  29. package/packages/frame/src/kit/board/model.ts +337 -0
  30. package/packages/frame/src/kit/contract.ts +79 -0
  31. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  32. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  33. package/packages/frame/src/kit/contracts/board.ts +6 -0
  34. package/packages/frame/src/kit/contracts/by.ts +22 -0
  35. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  36. package/packages/frame/src/kit/contracts/events.ts +89 -0
  37. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  38. package/packages/frame/src/kit/contracts/names.ts +12 -0
  39. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  40. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  41. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  42. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  43. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  44. package/packages/frame/src/kit/contracts/views.ts +42 -0
  45. package/packages/frame/src/kit/control.ts +59 -0
  46. package/packages/frame/src/kit/dark-theme.ts +41 -0
  47. package/packages/frame/src/kit/decode.ts +210 -0
  48. package/packages/frame/src/kit/events/log.ts +313 -0
  49. package/packages/frame/src/kit/format.ts +25 -0
  50. package/packages/frame/src/kit/host.ts +4 -0
  51. package/packages/frame/src/kit/index.ts +25 -0
  52. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  53. package/packages/frame/src/kit/overlay-images.ts +36 -0
  54. package/packages/frame/src/kit/paths.ts +16 -0
  55. package/packages/frame/src/kit/screen/client.ts +74 -0
  56. package/packages/frame/src/kit/screen/feature.ts +64 -0
  57. package/packages/frame/src/kit/screen/model.ts +182 -0
  58. package/packages/frame/src/kit/screen/view.ts +838 -0
  59. package/packages/frame/src/kit/settings/files.ts +90 -0
  60. package/packages/frame/src/kit/settings/setting.ts +57 -0
  61. package/packages/frame/src/kit/settings/store.ts +192 -0
  62. package/packages/frame/src/kit/sources/file.ts +129 -0
  63. package/packages/frame/src/kit/sources/follow.ts +155 -0
  64. package/packages/frame/src/kit/testing.ts +260 -0
  65. package/packages/frame/src/kit/totals.ts +72 -0
  66. package/packages/frame/src/kit/ui/entry.ts +75 -0
  67. package/packages/frame/src/kit/ui/frame.ts +212 -0
  68. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  69. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  70. package/packages/frame/src/render/diff.ts +55 -2
  71. package/packages/frame/src/render/fit.ts +1 -1
  72. package/packages/frame/src/render/format.ts +1 -1
  73. package/packages/frame/src/render/segments.ts +1 -1
  74. package/packages/frame/src/render/streaming.ts +1 -1
  75. package/packages/frame/src/render/tools.ts +1 -1
  76. package/packages/frame/src/settings.ts +1 -1
  77. package/packages/frame/src/ui/editor.ts +1 -1
  78. package/packages/frame/src/ui/effort.ts +1 -1
  79. package/packages/frame/src/ui/header.ts +1 -1
  80. package/packages/frame/src/ui/outputs.ts +1 -1
  81. package/packages/frame/src/ui/pi-entries.ts +28 -6
  82. package/packages/frame/src/ui/previews.ts +1 -1
  83. package/packages/frame/src/ui/queue.ts +1 -1
  84. package/packages/frame/src/ui/todos.ts +1 -1
  85. package/packages/frame/src/ui/tool-frame.ts +1 -1
  86. package/packages/frame/src/view/state.ts +1 -1
  87. package/packages/frame/src/view/transcript.ts +1 -1
  88. package/packages/frame/src/view/view.ts +1 -1
  89. package/packages/web/package.json +1 -3
  90. package/packages/web/src/extract.ts +2 -2
  91. package/packages/web/src/markdown.ts +122 -0
  92. package/src/index.ts +1 -1
  93. package/src/settings.ts +1 -0
@@ -0,0 +1,90 @@
1
+ // Every app shares the settings files, so a write keeps every key it does not know.
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { isObject } from "../decode.ts";
8
+
9
+ export type SettingsData = Record<string, unknown>;
10
+
11
+ export interface SettingsFile {
12
+ data: SettingsData;
13
+ warnings: string[];
14
+ }
15
+
16
+ export function globalSettingsPath(): string {
17
+ return join(getAgentDir(), "extensions", "pi-harness", "settings.json");
18
+ }
19
+
20
+ export function projectSettingsPath(cwd: string): string {
21
+ return join(cwd, CONFIG_DIR_NAME, "extensions", "pi-harness", "settings.json");
22
+ }
23
+
24
+ export function readSettingsFile(path: string): SettingsFile {
25
+ try {
26
+ const data: unknown = JSON.parse(readFileSync(path, "utf8"));
27
+ if (isObject(data)) return { data, warnings: [] };
28
+ return { data: {}, warnings: [`${path}: must contain a JSON object; using the defaults`] };
29
+ } catch (error) {
30
+ // A missing file is a fresh start, not a warning.
31
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { data: {}, warnings: [] };
32
+ const reason = error instanceof Error ? error.message : String(error);
33
+ return { data: {}, warnings: [`${path}: could not be read (${reason}); using the defaults`] };
34
+ }
35
+ }
36
+
37
+ export function writeSettingsFile(
38
+ path: string,
39
+ data: SettingsData,
40
+ options: { mode?: number } = {},
41
+ ): void {
42
+ mkdirSync(dirname(path), { recursive: true });
43
+ const temporary = `${path}.${process.pid}.tmp`;
44
+ writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`, {
45
+ encoding: "utf8",
46
+ mode: options.mode,
47
+ });
48
+ renameSync(temporary, path);
49
+ }
50
+
51
+ export function lookup(data: SettingsData, id: string): unknown {
52
+ let node: unknown = data;
53
+ for (const key of id.split(".")) {
54
+ if (!isObject(node)) return undefined;
55
+ node = node[key];
56
+ }
57
+ return node;
58
+ }
59
+
60
+ export function remove(data: SettingsData, id: string): void {
61
+ const [key, ...rest] = id.split(".");
62
+ if (key === undefined || !(key in data)) return;
63
+ if (rest.length === 0) {
64
+ delete data[key];
65
+ return;
66
+ }
67
+ const child = data[key];
68
+ if (!isObject(child)) return;
69
+ remove(child, rest.join("."));
70
+ if (Object.keys(child).length === 0) delete data[key];
71
+ }
72
+
73
+ export function assign(data: SettingsData, id: string, value: unknown): void {
74
+ const keys = id.split(".");
75
+ const last = keys.pop();
76
+ if (last === undefined) return;
77
+
78
+ let node = data;
79
+ for (const key of keys) {
80
+ const next = node[key];
81
+ if (isObject(next)) {
82
+ node = next;
83
+ continue;
84
+ }
85
+ const created: SettingsData = {};
86
+ node[key] = created;
87
+ node = created;
88
+ }
89
+ node[last] = value;
90
+ }
@@ -0,0 +1,57 @@
1
+ // Declared next to the feature that reads it; one handle works against any app.
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ import type { Preview } from "../contracts/screen.ts";
5
+ import type { Control } from "../control.ts";
6
+ import type { Decoder } from "../decode.ts";
7
+ import type { SettingsStore } from "./store.ts";
8
+
9
+ export interface SettingUi {
10
+ section: string;
11
+ label: string;
12
+ description: string;
13
+ /** Overrides the decoder's. A function runs each time the screen opens. */
14
+ control?: Control | ((ctx: ExtensionContext) => Control);
15
+ /** Applies only after `/reload`. */
16
+ restart?: boolean;
17
+ /** What a value looks like, drawn under the list while the setting or a value is selected. */
18
+ preview?: Preview;
19
+ }
20
+
21
+ export interface SettingDefinition<T> {
22
+ /** Dotted path, like `subscription.claudeCodeVersion`. */
23
+ id: string;
24
+ default: T;
25
+ decoder: Decoder<T>;
26
+ /** Once pi trusts the project. */
27
+ project?: boolean;
28
+ ui?: SettingUi;
29
+ }
30
+
31
+ export interface SettingsScope {
32
+ readonly settings: SettingsStore;
33
+ }
34
+
35
+ export interface Setting<T> extends SettingDefinition<T> {
36
+ get(scope: SettingsScope): T;
37
+ listen(scope: SettingsScope, listener: (value: T) => void): () => void;
38
+ }
39
+
40
+ export function setting<T>(definition: SettingDefinition<T>): Setting<T> {
41
+ const handle: Setting<T> = {
42
+ ...definition,
43
+ get: (scope) => scope.settings.get(handle),
44
+ listen: (scope, listener) => scope.settings.listen(handle, listener),
45
+ };
46
+ return handle;
47
+ }
48
+
49
+ export function controlOf(
50
+ entry: Setting<unknown>,
51
+ ctx: ExtensionContext | undefined,
52
+ ): Control | undefined {
53
+ const declared = entry.ui?.control;
54
+ if (typeof declared === "function")
55
+ return ctx === undefined ? entry.decoder.control : declared(ctx);
56
+ return declared ?? entry.decoder.control;
57
+ }
@@ -0,0 +1,192 @@
1
+ // A value comes from the first layer that has a valid one: project (when allowed and trusted), global, default.
2
+ import { formatProblems } from "../decode.ts";
3
+ import {
4
+ assign,
5
+ lookup,
6
+ readSettingsFile,
7
+ remove,
8
+ type SettingsData,
9
+ writeSettingsFile,
10
+ } from "./files.ts";
11
+ import type { Setting } from "./setting.ts";
12
+
13
+ export type Layer = "default" | "global" | "project";
14
+
15
+ type Found<T> = { found: true; value: T } | { found: false };
16
+
17
+ interface Loaded {
18
+ value: unknown;
19
+ layer: Layer;
20
+ global?: unknown;
21
+ }
22
+
23
+ export class SettingsStore {
24
+ readonly globalPath: string;
25
+ #projectPath: string | undefined;
26
+ #known = new Map<string, Setting<unknown>>();
27
+ #loaded = new Map<string, Loaded>();
28
+ #listeners = new Map<string, Set<(value: unknown) => void>>();
29
+ #writeListeners = new Set<() => void>();
30
+ #fresh = false;
31
+
32
+ constructor(globalPath: string) {
33
+ this.globalPath = globalPath;
34
+ }
35
+
36
+ get projectPath(): string | undefined {
37
+ return this.#projectPath;
38
+ }
39
+
40
+ register(settings: readonly Setting<unknown>[]): void {
41
+ for (const entry of settings) {
42
+ const known = this.#known.get(entry.id);
43
+ if (known !== undefined && known !== entry)
44
+ throw new Error(`two settings are named "${entry.id}"`);
45
+ this.#known.set(entry.id, entry);
46
+ }
47
+ this.#fresh = false;
48
+ }
49
+
50
+ known(): Setting<unknown>[] {
51
+ return [...this.#known.values()];
52
+ }
53
+
54
+ /** Throws for an undeclared setting, which would otherwise read its default forever. */
55
+ get<T>(entry: Setting<T>): T {
56
+ // `load` fills #loaded only with values this setting's decoder produced.
57
+ return this.#read(entry).value as T;
58
+ }
59
+
60
+ layer(entry: Setting<unknown>): Layer {
61
+ return this.#read(entry).layer;
62
+ }
63
+
64
+ hidden(entry: Setting<unknown>): unknown {
65
+ return this.#read(entry).global;
66
+ }
67
+
68
+ listen<T>(entry: Setting<T>, listener: (value: T) => void): () => void {
69
+ const listeners = this.#listeners.get(entry.id) ?? new Set();
70
+ this.#listeners.set(entry.id, listeners);
71
+ // Every value given to a listener came from this setting's decoder.
72
+ const untyped = listener as (value: unknown) => void;
73
+ listeners.add(untyped);
74
+ return () => listeners.delete(untyped);
75
+ }
76
+
77
+ onWrite(listener: () => void): () => void {
78
+ this.#writeListeners.add(listener);
79
+ return () => this.#writeListeners.delete(listener);
80
+ }
81
+
82
+ /** Pass the project file only when pi trusts the project. */
83
+ load(projectPath?: string): string[] {
84
+ this.#fresh = true;
85
+ this.#projectPath = projectPath;
86
+ const global = readSettingsFile(this.globalPath);
87
+ const project = projectPath === undefined ? undefined : readSettingsFile(projectPath);
88
+ const warnings = [...global.warnings, ...(project?.warnings ?? [])];
89
+
90
+ for (const entry of this.#known.values()) {
91
+ let loaded: Loaded = { value: entry.default, layer: "default" };
92
+
93
+ const fromGlobal = decodeAt(entry, global.data, this.globalPath, warnings);
94
+ if (fromGlobal.found) loaded = { value: fromGlobal.value, layer: "global" };
95
+
96
+ if (project !== undefined && projectPath !== undefined) {
97
+ if (entry.project) {
98
+ const fromProject = decodeAt(entry, project.data, projectPath, warnings);
99
+ if (fromProject.found)
100
+ loaded = { value: fromProject.value, layer: "project", global: loaded.value };
101
+ } else if (lookup(project.data, entry.id) !== undefined) {
102
+ warnings.push(
103
+ `${projectPath}: ${entry.id}: only the global settings file can set this; ignored`,
104
+ );
105
+ }
106
+ }
107
+
108
+ this.#update(entry, loaded);
109
+ }
110
+
111
+ return warnings;
112
+ }
113
+
114
+ /** Keeps the rest of the file. Returns an error message on failure. */
115
+ set<T>(entry: Setting<T>, value: T): string | undefined {
116
+ return this.setAll([[entry, value]]);
117
+ }
118
+
119
+ /** A value equal to the current one is not written, so the file never freezes a default. */
120
+ setAll(entries: readonly (readonly [Setting<unknown>, unknown])[]): string | undefined {
121
+ for (const [entry] of entries) this.#assertKnown(entry);
122
+ const changed = entries.filter(([entry, value]) => !same(this.#globalOf(entry), value));
123
+ if (changed.length === 0) return undefined;
124
+
125
+ return this.#edit(this.globalPath, (data) => {
126
+ for (const [entry, value] of changed) assign(data, entry.id, value);
127
+ });
128
+ }
129
+
130
+ unset(entry: Setting<unknown>, layer: Exclude<Layer, "default">): string | undefined {
131
+ this.#assertKnown(entry);
132
+ const path = layer === "global" ? this.globalPath : this.#projectPath;
133
+ if (path === undefined) return "no trusted project to change";
134
+ return this.#edit(path, (data) => remove(data, entry.id));
135
+ }
136
+
137
+ #edit(path: string, change: (data: SettingsData) => void): string | undefined {
138
+ const { data } = readSettingsFile(path);
139
+ change(data);
140
+ try {
141
+ writeSettingsFile(path, data);
142
+ } catch (error) {
143
+ return error instanceof Error ? error.message : String(error);
144
+ }
145
+ this.load(this.#projectPath);
146
+ for (const listener of this.#writeListeners) listener();
147
+ return undefined;
148
+ }
149
+
150
+ #globalOf(entry: Setting<unknown>): unknown {
151
+ const loaded = this.#read(entry);
152
+ return loaded.layer === "project" ? loaded.global : loaded.value;
153
+ }
154
+
155
+ #read(entry: Setting<unknown>): Loaded {
156
+ this.#assertKnown(entry);
157
+ if (!this.#fresh) this.load(this.#projectPath);
158
+ return this.#loaded.get(entry.id) ?? { value: entry.default, layer: "default" };
159
+ }
160
+
161
+ #assertKnown(entry: Setting<unknown>): void {
162
+ if (this.#known.get(entry.id) !== entry)
163
+ throw new Error(`the setting "${entry.id}" was not declared by any feature of this app`);
164
+ }
165
+
166
+ #update(entry: Setting<unknown>, loaded: Loaded): void {
167
+ const previous = this.#loaded.get(entry.id)?.value ?? entry.default;
168
+ this.#loaded.set(entry.id, loaded);
169
+ if (same(previous, loaded.value)) return;
170
+ for (const listener of this.#listeners.get(entry.id) ?? []) listener(loaded.value);
171
+ }
172
+ }
173
+
174
+ function decodeAt<T>(
175
+ entry: Setting<T>,
176
+ data: SettingsData,
177
+ path: string,
178
+ warnings: string[],
179
+ ): Found<T> {
180
+ const input = lookup(data, entry.id);
181
+ if (input === undefined) return { found: false };
182
+
183
+ const result = entry.decoder.decode(input, entry.id);
184
+ const suffix = result.ok ? "" : "; ignored";
185
+ for (const line of formatProblems(result.problems)) warnings.push(`${path}: ${line}${suffix}`);
186
+ return result.ok ? { found: true, value: result.value } : { found: false };
187
+ }
188
+
189
+ /** Decoders build new objects on every read, so values compare by content. */
190
+ function same(left: unknown, right: unknown): boolean {
191
+ return JSON.stringify(left) === JSON.stringify(right);
192
+ }
@@ -0,0 +1,129 @@
1
+ // A session read from its file on this machine. Read-only: no questions, queue or way in.
2
+ import {
3
+ calculateContextTokens,
4
+ type ExtensionAPI,
5
+ type SessionEntry,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ import {
9
+ askSource,
10
+ type SessionSummary,
11
+ type SessionSource,
12
+ type SourceAddress,
13
+ } from "../contracts/sources.ts";
14
+ import { HOST } from "../events/log.ts";
15
+ import { FileTail, followFile, type FileRecord, type TailItem } from "./follow.ts";
16
+
17
+ export interface FileSourceOptions {
18
+ /** Milliseconds between polls when the directory watch stays quiet. */
19
+ every?: number;
20
+ }
21
+
22
+ export function sourceFor(
23
+ events: ExtensionAPI["events"],
24
+ address: SourceAddress,
25
+ options: FileSourceOptions = {},
26
+ ): SessionSource | undefined {
27
+ const provided = askSource(events, address);
28
+ if (provided !== undefined) return provided;
29
+ if (address.machine !== HOST || address.file === undefined) return undefined;
30
+ return fileSource(address.file, options);
31
+ }
32
+
33
+ export function fileSource(path: string, options: FileSourceOptions = {}): SessionSource {
34
+ return {
35
+ open() {
36
+ const fold = new Fold();
37
+ for (const item of new FileTail(path).poll()) fold.apply(item);
38
+ const summary = fold.snapshot();
39
+ if (summary === undefined) return Promise.reject(new Error(`no session in ${path} yet`));
40
+ return Promise.resolve(summary);
41
+ },
42
+ follow(after, on) {
43
+ const fold = new Fold();
44
+ let waiting = after?.entry;
45
+ // A cursor with nothing this source reads, such as a newer one, is unknown.
46
+ if (after !== undefined && waiting === undefined) on({ kind: "reset" });
47
+ let sent = "";
48
+ return followFile(
49
+ path,
50
+ (items) => {
51
+ if (items[0]?.kind === "reset") {
52
+ fold.reset();
53
+ on({ kind: "reset" });
54
+ }
55
+ let entries = items.flatMap((item) => fold.apply(item));
56
+ if (waiting !== undefined) {
57
+ const at = entries.findIndex((entry) => entry.id === waiting);
58
+ // The first read holds the whole file, so a cursor it lacks is unknown to the file.
59
+ if (at === -1) on({ kind: "reset" });
60
+ entries = entries.slice(at + 1);
61
+ waiting = undefined;
62
+ }
63
+ for (const entry of entries) on({ kind: "entry", entry });
64
+ const summary = fold.snapshot();
65
+ const text = JSON.stringify(summary);
66
+ if (summary === undefined || text === sent) return;
67
+ sent = text;
68
+ on({ kind: "summary", summary });
69
+ },
70
+ { every: options.every },
71
+ );
72
+ },
73
+ };
74
+ }
75
+
76
+ class Fold {
77
+ #header: { session: string; cwd: string } | undefined;
78
+ #rest: Omit<SessionSummary, "session" | "machine" | "cwd"> = {};
79
+
80
+ reset(): void {
81
+ this.#header = undefined;
82
+ this.#rest = {};
83
+ }
84
+
85
+ apply(item: TailItem): SessionEntry[] {
86
+ if (item.kind === "reset") return [];
87
+ const record = item.record;
88
+ if (record.type === "session") {
89
+ if (typeof record.cwd === "string") this.#header = { session: record.id, cwd: record.cwd };
90
+ return [];
91
+ }
92
+ if (!isEntry(record)) return [];
93
+ this.#rest = { ...this.#rest, ...changes(record), leafId: record.id };
94
+ return [record];
95
+ }
96
+
97
+ snapshot(): SessionSummary | undefined {
98
+ if (this.#header === undefined) return undefined;
99
+ return { ...this.#header, machine: HOST, ...this.#rest };
100
+ }
101
+ }
102
+
103
+ function changes(entry: SessionEntry): Partial<SessionSummary> {
104
+ switch (entry.type) {
105
+ case "session_info":
106
+ return { name: entry.name };
107
+ case "model_change":
108
+ return { model: { provider: entry.provider, id: entry.modelId } };
109
+ case "thinking_level_change":
110
+ return { thinkingLevel: entry.thinkingLevel };
111
+ case "compaction":
112
+ // Pi does not know the context size again until the next answer.
113
+ return { context: undefined };
114
+ case "message": {
115
+ const message = entry.message;
116
+ if (message.role !== "assistant") return {};
117
+ const model = { provider: message.provider, id: message.model };
118
+ if (message.stopReason === "aborted" || message.stopReason === "error") return { model };
119
+ const tokens = calculateContextTokens(message.usage);
120
+ return tokens > 0 ? { model, context: { tokens } } : { model };
121
+ }
122
+ default:
123
+ return {};
124
+ }
125
+ }
126
+
127
+ function isEntry(record: FileRecord): record is FileRecord & SessionEntry {
128
+ return typeof record.type === "string" && "parentId" in record;
129
+ }
@@ -0,0 +1,155 @@
1
+ // Follows a JSONL file of records with an `id`. Pi rewrites the whole file when it migrates it, so a
2
+ // rewrite is detected and read again without repeating a record. Imports only `node:*`.
3
+ import { closeSync, fstatSync, openSync, readSync, watch } from "node:fs";
4
+ import { dirname } from "node:path";
5
+
6
+ export interface FollowState {
7
+ /** Always at the end of a line. */
8
+ offset: number;
9
+ lastId: string | undefined;
10
+ ino: number;
11
+ size: number;
12
+ /** A rewrite can keep the size, but not the modification time. */
13
+ mtimeMs: number;
14
+ }
15
+
16
+ export interface FileRecord {
17
+ id: string;
18
+ [field: string]: unknown;
19
+ }
20
+
21
+ /** `reset`: the file was rewritten; drop what came before. */
22
+ export type TailItem = { kind: "record"; record: FileRecord } | { kind: "reset" };
23
+
24
+ const START: FollowState = { offset: 0, lastId: undefined, ino: 0, size: 0, mtimeMs: 0 };
25
+
26
+ export class FileTail {
27
+ readonly #path: string;
28
+ #state: FollowState;
29
+
30
+ constructor(path: string, from: FollowState = START) {
31
+ this.#path = path;
32
+ this.#state = { ...from };
33
+ }
34
+
35
+ get state(): FollowState {
36
+ return { ...this.#state };
37
+ }
38
+
39
+ /** A file that does not exist yet gives nothing. */
40
+ poll(): TailItem[] {
41
+ let fd: number;
42
+ try {
43
+ fd = openSync(this.#path, "r");
44
+ } catch {
45
+ return [];
46
+ }
47
+ try {
48
+ return this.#read(fd);
49
+ } finally {
50
+ closeSync(fd);
51
+ }
52
+ }
53
+
54
+ #read(fd: number): TailItem[] {
55
+ const { ino, size, mtimeMs } = fstatSync(fd);
56
+ const state = this.#state;
57
+ if (ino === state.ino && size === state.size && mtimeMs === state.mtimeMs) return [];
58
+ const rewritten =
59
+ (state.ino !== 0 && ino !== state.ino) ||
60
+ size < state.offset ||
61
+ (state.offset > 0 && idEndingAt(fd, state.offset) !== state.lastId);
62
+ const skipTo = rewritten ? state.lastId : undefined;
63
+ const from = rewritten ? 0 : state.offset;
64
+ const { records, end } = readLines(fd, from, size);
65
+ this.#state = {
66
+ offset: end,
67
+ lastId: records.at(-1)?.id ?? (rewritten ? undefined : state.lastId),
68
+ ino,
69
+ size,
70
+ mtimeMs,
71
+ };
72
+ if (skipTo === undefined) return records.map(asItem);
73
+ const seen = records.findIndex((record) => record.id === skipTo);
74
+ if (seen === -1) return [{ kind: "reset" }, ...records.map(asItem)];
75
+ return records.slice(seen + 1).map(asItem);
76
+ }
77
+ }
78
+
79
+ /** `every` is the poll behind the directory watch, which some filesystems never fire. */
80
+ export function followFile(
81
+ path: string,
82
+ on: (items: TailItem[]) => void,
83
+ options: { from?: FollowState; every?: number } = {},
84
+ ): () => void {
85
+ const tail = new FileTail(path, options.from);
86
+ let stopped = false;
87
+ function check(): void {
88
+ if (stopped) return;
89
+ const items = tail.poll();
90
+ if (items.length > 0) on(items);
91
+ }
92
+ const poll = setInterval(check, options.every ?? 1_000);
93
+ poll.unref();
94
+ const watcher = watchFolder(dirname(path), check);
95
+ check();
96
+ return () => {
97
+ stopped = true;
98
+ clearInterval(poll);
99
+ watcher?.close();
100
+ };
101
+ }
102
+
103
+ function watchFolder(folder: string, changed: () => void): { close(): void } | undefined {
104
+ try {
105
+ const watcher = watch(folder, changed);
106
+ watcher.on("error", () => {});
107
+ return watcher;
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ function asItem(record: FileRecord): TailItem {
114
+ return { kind: "record", record };
115
+ }
116
+
117
+ function readLines(fd: number, from: number, size: number): { records: FileRecord[]; end: number } {
118
+ if (size <= from) return { records: [], end: from };
119
+ const buffer = Buffer.alloc(size - from);
120
+ readSync(fd, buffer, 0, buffer.length, from);
121
+ const last = buffer.lastIndexOf(0x0a);
122
+ if (last === -1) return { records: [], end: from };
123
+ const records = buffer
124
+ .subarray(0, last)
125
+ .toString("utf8")
126
+ .split("\n")
127
+ .flatMap((line) => recordOf(line));
128
+ return { records, end: from + last + 1 };
129
+ }
130
+
131
+ /** Reads back only as far as that line starts. */
132
+ function idEndingAt(fd: number, offset: number): string | undefined {
133
+ for (let window = 4_096; ; window *= 2) {
134
+ const start = Math.max(0, offset - window);
135
+ const buffer = Buffer.alloc(offset - start);
136
+ readSync(fd, buffer, 0, buffer.length, start);
137
+ if (buffer.at(-1) !== 0x0a) return undefined;
138
+ const before = buffer.lastIndexOf(0x0a, buffer.length - 2);
139
+ if (before !== -1 || start === 0) {
140
+ return recordOf(buffer.subarray(before + 1, -1).toString("utf8"))[0]?.id;
141
+ }
142
+ }
143
+ }
144
+
145
+ function recordOf(line: string): FileRecord[] {
146
+ if (line.trim() === "") return [];
147
+ try {
148
+ const value: unknown = JSON.parse(line);
149
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return [];
150
+ if (!("id" in value) || typeof value.id !== "string") return [];
151
+ return [value as FileRecord];
152
+ } catch {
153
+ return [];
154
+ }
155
+ }