@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.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -1
- package/package.json +6 -7
- package/packages/board/package.json +0 -2
- package/packages/board/src/core/rows.mjs +21 -5
- package/packages/board/src/ui/dashboard.ts +36 -20
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/README.md +1 -1
- package/packages/frame/package.json +1 -4
- package/packages/frame/src/data/github.ts +1 -1
- package/packages/frame/src/data/mode.ts +28 -2
- package/packages/frame/src/data/snapshot.ts +1 -1
- package/packages/frame/src/data/start-notes.ts +1 -1
- package/packages/frame/src/data/totals.ts +2 -2
- package/packages/frame/src/data/visits.ts +1 -1
- package/packages/frame/src/index.ts +22 -2
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +55 -2
- package/packages/frame/src/render/fit.ts +1 -1
- package/packages/frame/src/render/format.ts +1 -1
- package/packages/frame/src/render/segments.ts +1 -1
- package/packages/frame/src/render/streaming.ts +1 -1
- package/packages/frame/src/render/tools.ts +1 -1
- package/packages/frame/src/settings.ts +1 -1
- package/packages/frame/src/ui/editor.ts +1 -1
- package/packages/frame/src/ui/effort.ts +1 -1
- package/packages/frame/src/ui/header.ts +1 -1
- package/packages/frame/src/ui/outputs.ts +1 -1
- package/packages/frame/src/ui/pi-entries.ts +28 -6
- package/packages/frame/src/ui/previews.ts +1 -1
- package/packages/frame/src/ui/queue.ts +1 -1
- package/packages/frame/src/ui/todos.ts +1 -1
- package/packages/frame/src/ui/tool-frame.ts +1 -1
- package/packages/frame/src/view/state.ts +1 -1
- package/packages/frame/src/view/transcript.ts +1 -1
- package/packages/frame/src/view/view.ts +1 -1
- package/packages/web/package.json +1 -3
- package/packages/web/src/extract.ts +2 -2
- package/packages/web/src/markdown.ts +122 -0
- package/src/index.ts +1 -1
- package/src/settings.ts +1 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Decoders for everything from outside. No decoder throws, and only this module inspects `typeof`.
|
|
2
|
+
import type { Control } from "./control.ts";
|
|
3
|
+
|
|
4
|
+
export interface Problem {
|
|
5
|
+
path: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type Decoded<T> =
|
|
10
|
+
| { ok: true; value: T; problems: Problem[] }
|
|
11
|
+
| { ok: false; problems: Problem[] };
|
|
12
|
+
|
|
13
|
+
export interface Decoder<T> {
|
|
14
|
+
decode(input: unknown, path: string): Decoded<T>;
|
|
15
|
+
control?: Control;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function pass<T>(value: T, problems: Problem[] = []): Decoded<T> {
|
|
19
|
+
return { ok: true, value, problems };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function fail<T>(entry: Problem): Decoded<T> {
|
|
23
|
+
return { ok: false, problems: [entry] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function problem(path: string, message: string): Problem {
|
|
27
|
+
return { path, message };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function fieldPath(path: string, key: string): string {
|
|
31
|
+
return path === "" ? key : `${path}.${key}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatProblems(problems: Problem[]): string[] {
|
|
35
|
+
return problems.map((entry) => `${entry.path}: ${entry.message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isObject(input: unknown): input is Record<string, unknown> {
|
|
39
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const string: Decoder<string> = {
|
|
43
|
+
control: { type: "text" },
|
|
44
|
+
decode(input, path) {
|
|
45
|
+
return typeof input === "string" ? pass(input) : fail(problem(path, "expected a string"));
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const trimmedString: Decoder<string> = {
|
|
50
|
+
control: { type: "text" },
|
|
51
|
+
decode(input, path) {
|
|
52
|
+
if (typeof input !== "string" || input.trim() === "")
|
|
53
|
+
return fail(problem(path, "expected a non-empty string"));
|
|
54
|
+
return pass(input.trim());
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const boolean: Decoder<boolean> = {
|
|
59
|
+
control: { type: "toggle" },
|
|
60
|
+
decode(input, path) {
|
|
61
|
+
return typeof input === "boolean" ? pass(input) : fail(problem(path, "expected a boolean"));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const unit: Decoder<number> = {
|
|
66
|
+
control: { type: "number", min: 0, max: 1, step: 0.05 },
|
|
67
|
+
decode(input, path) {
|
|
68
|
+
if (typeof input !== "number" || !Number.isFinite(input) || input < 0 || input > 1)
|
|
69
|
+
return fail(problem(path, "expected a number from 0 to 1"));
|
|
70
|
+
return pass(input);
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const duration: Decoder<number> = {
|
|
75
|
+
control: { type: "number", min: 0, step: 500, unit: "ms" },
|
|
76
|
+
decode(input, path) {
|
|
77
|
+
if (typeof input !== "number" || !Number.isFinite(input) || input < 0)
|
|
78
|
+
return fail(problem(path, "expected a non-negative number of milliseconds"));
|
|
79
|
+
return pass(input);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export function integer(min: number, max: number): Decoder<number> {
|
|
84
|
+
return {
|
|
85
|
+
control: { type: "number", min, max, step: 1 },
|
|
86
|
+
decode(input, path) {
|
|
87
|
+
if (typeof input !== "number" || !Number.isInteger(input) || input < min || input > max) {
|
|
88
|
+
return fail(problem(path, `expected a whole number from ${min} to ${max}`));
|
|
89
|
+
}
|
|
90
|
+
return pass(input);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `expected` is how the value is described to the user. */
|
|
96
|
+
export function matching(pattern: RegExp, expected: string): Decoder<string> {
|
|
97
|
+
return {
|
|
98
|
+
control: { type: "text" },
|
|
99
|
+
decode(input, path) {
|
|
100
|
+
if (typeof input === "string" && pattern.test(input)) return pass(input);
|
|
101
|
+
return fail(problem(path, `expected ${expected}`));
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function literal<const T extends readonly string[]>(...values: T): Decoder<T[number]> {
|
|
107
|
+
const expected = values.map((value) => `"${value}"`).join(" or ");
|
|
108
|
+
return {
|
|
109
|
+
control: { type: "choice", options: values.map((value) => ({ value })) },
|
|
110
|
+
decode(input, path) {
|
|
111
|
+
if (typeof input === "string" && (values as readonly string[]).includes(input))
|
|
112
|
+
return pass(input as T[number]);
|
|
113
|
+
return fail(problem(path, `expected ${expected}`));
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function nullable<T>(inner: Decoder<T>): Decoder<T | null> {
|
|
119
|
+
const control: Control | undefined =
|
|
120
|
+
inner.control?.type === "number" ? { ...inner.control, nullable: true } : inner.control;
|
|
121
|
+
return {
|
|
122
|
+
control,
|
|
123
|
+
decode(input, path) {
|
|
124
|
+
return input === null ? pass(null) : inner.decode(input, path);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function withDefaultOf<T>(inner: Decoder<T>, fallback: () => T): Decoder<T> {
|
|
130
|
+
return {
|
|
131
|
+
control: inner.control,
|
|
132
|
+
decode(input, path) {
|
|
133
|
+
if (input === undefined) return pass(fallback());
|
|
134
|
+
const result = inner.decode(input, path);
|
|
135
|
+
return result.ok ? result : pass(fallback(), result.problems);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function withDefault<T>(inner: Decoder<T>, fallback: T): Decoder<T> {
|
|
141
|
+
return withDefaultOf(inner, () => fallback);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function stringList(expected: string): Decoder<string[]> {
|
|
145
|
+
const notAList = `expected an array of ${expected}`;
|
|
146
|
+
const dropped = "ignored entries that are not non-empty strings";
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
control: { type: "list" },
|
|
150
|
+
decode(input, path) {
|
|
151
|
+
if (!Array.isArray(input)) return fail(problem(path, notAList));
|
|
152
|
+
|
|
153
|
+
const value: string[] = [];
|
|
154
|
+
let missing = 0;
|
|
155
|
+
for (const entry of input) {
|
|
156
|
+
if (typeof entry === "string" && entry !== "") value.push(entry);
|
|
157
|
+
else missing++;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const problems = missing > 0 ? [problem(path, dropped)] : [];
|
|
161
|
+
return pass(value, problems);
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function stringListOrEmpty(
|
|
167
|
+
fallback: readonly string[],
|
|
168
|
+
expected: string,
|
|
169
|
+
): Decoder<string[]> {
|
|
170
|
+
const parse = stringList(expected);
|
|
171
|
+
return {
|
|
172
|
+
control: parse.control,
|
|
173
|
+
decode(input, path) {
|
|
174
|
+
if (input === undefined) return pass([...fallback]);
|
|
175
|
+
const result = parse.decode(input, path);
|
|
176
|
+
return result.ok ? result : pass([], result.problems);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function guarded<T>(test: (input: unknown) => input is T, expected: string): Decoder<T> {
|
|
182
|
+
return {
|
|
183
|
+
decode(input, path) {
|
|
184
|
+
return test(input) ? pass(input) : fail(problem(path, `expected ${expected}`));
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function object<T extends Record<string, unknown>>(shape: {
|
|
190
|
+
[K in keyof T]: Decoder<T[K]>;
|
|
191
|
+
}): Decoder<T> {
|
|
192
|
+
return {
|
|
193
|
+
decode(input, path) {
|
|
194
|
+
if (!isObject(input)) return fail(problem(path, "expected an object"));
|
|
195
|
+
|
|
196
|
+
const problems: Problem[] = [];
|
|
197
|
+
const value = {} as T;
|
|
198
|
+
let complete = true;
|
|
199
|
+
|
|
200
|
+
for (const key of Object.keys(shape) as (keyof T & string)[]) {
|
|
201
|
+
const result = shape[key].decode(input[key], fieldPath(path, key));
|
|
202
|
+
problems.push(...result.problems);
|
|
203
|
+
if (result.ok) value[key] = result.value;
|
|
204
|
+
else complete = false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return complete ? pass(value, problems) : { ok: false, problems };
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// One log per session, followed live by other sessions and replayed later. Only the session writes
|
|
2
|
+
// its file and the extensions share one thread, so lines never interleave.
|
|
3
|
+
import {
|
|
4
|
+
appendFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readSync,
|
|
9
|
+
openSync,
|
|
10
|
+
closeSync,
|
|
11
|
+
fstatSync,
|
|
12
|
+
rmSync,
|
|
13
|
+
statSync,
|
|
14
|
+
watch,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
import type { LogEventMap } from "../contracts/events.ts";
|
|
21
|
+
import { isObject } from "../decode.ts";
|
|
22
|
+
|
|
23
|
+
export const EVENTS_VERSION = 1;
|
|
24
|
+
/** Every event is also published here, inside the process. */
|
|
25
|
+
export const LOGGED_EVENT = "harness:logged";
|
|
26
|
+
|
|
27
|
+
export type EventType = keyof LogEventMap;
|
|
28
|
+
|
|
29
|
+
export interface LoggedEvent<K extends EventType = EventType> {
|
|
30
|
+
v: typeof EVENTS_VERSION;
|
|
31
|
+
id: string;
|
|
32
|
+
/** Milliseconds since the epoch. */
|
|
33
|
+
at: number;
|
|
34
|
+
session: string;
|
|
35
|
+
type: K;
|
|
36
|
+
data: LogEventMap[K];
|
|
37
|
+
/** So a viewer can open that point of the conversation. */
|
|
38
|
+
entry?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface LogTarget {
|
|
42
|
+
dir: string;
|
|
43
|
+
session: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Bus {
|
|
47
|
+
emit(channel: string, data: unknown): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface LogSession {
|
|
51
|
+
getCwd(): string;
|
|
52
|
+
getSessionId(): string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Lets tests and tools move every log. */
|
|
56
|
+
export function eventsRoot(): string {
|
|
57
|
+
return process.env.PI_HARNESS_EVENTS ?? join(getAgentDir(), "harness", "events");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function eventsDir(cwd: string, root = eventsRoot()): string {
|
|
61
|
+
return join(root, `--${cwd.replace(/^[/\\]+/, "").replace(/[/\\:]+/g, "-")}--`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A folder's directory starts with `--`, so a room never takes its name. */
|
|
65
|
+
export function roomsDir(root = eventsRoot()): string {
|
|
66
|
+
return join(root, "rooms");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function roomDir(id: string, root = eventsRoot()): string {
|
|
70
|
+
return join(roomsDir(root), id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One small file per open session, so finding them reads no logs. */
|
|
74
|
+
export function liveDir(root = eventsRoot()): string {
|
|
75
|
+
return join(root, "live");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function targetOf(session: LogSession, root?: string): LogTarget {
|
|
79
|
+
return { dir: eventsDir(session.getCwd(), root), session: session.getSessionId() };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function logPath(target: LogTarget): string {
|
|
83
|
+
return join(target.dir, `${target.session}.jsonl`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let counter = 0;
|
|
87
|
+
|
|
88
|
+
export function logEvent<K extends EventType>(
|
|
89
|
+
target: LogTarget,
|
|
90
|
+
bus: Bus | undefined,
|
|
91
|
+
type: K,
|
|
92
|
+
data: LogEventMap[K],
|
|
93
|
+
entry?: string,
|
|
94
|
+
at = Date.now(),
|
|
95
|
+
): LoggedEvent<K> {
|
|
96
|
+
counter = (counter + 1) % 1_296;
|
|
97
|
+
const event: LoggedEvent<K> = {
|
|
98
|
+
v: EVENTS_VERSION,
|
|
99
|
+
id: `${at.toString(36)}${counter.toString(36).padStart(2, "0")}${Math.random().toString(36).slice(2, 6)}`,
|
|
100
|
+
at,
|
|
101
|
+
session: target.session,
|
|
102
|
+
type,
|
|
103
|
+
data,
|
|
104
|
+
...(entry === undefined ? {} : { entry }),
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(target.dir, { recursive: true });
|
|
108
|
+
appendFileSync(logPath(target), `${JSON.stringify(event)}\n`);
|
|
109
|
+
} catch {
|
|
110
|
+
// A log that cannot be written must never stop the session.
|
|
111
|
+
}
|
|
112
|
+
bus?.emit(LOGGED_EVENT, event);
|
|
113
|
+
return event;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A context with no session behind it logs nothing. */
|
|
117
|
+
export function logFrom<K extends EventType>(
|
|
118
|
+
ctx: { sessionManager: Partial<LogSession> },
|
|
119
|
+
bus: Bus | undefined,
|
|
120
|
+
type: K,
|
|
121
|
+
data: LogEventMap[K],
|
|
122
|
+
entry?: string,
|
|
123
|
+
): LoggedEvent<K> | undefined {
|
|
124
|
+
const { getCwd, getSessionId } = ctx.sessionManager;
|
|
125
|
+
if (getCwd === undefined || getSessionId === undefined) return undefined;
|
|
126
|
+
return logEvent(targetOf(ctx.sessionManager as LogSession), bus, type, data, entry);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { HOST } from "../host.ts";
|
|
130
|
+
|
|
131
|
+
/** Which event types a reader folds; the others are skipped before they are parsed. */
|
|
132
|
+
export type Wanted = (type: string) => boolean;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The events written after `offset`; a line still being written waits for the next read. Lines are
|
|
136
|
+
* found and their types read in the bytes, so a line the reader does not want is never decoded.
|
|
137
|
+
*/
|
|
138
|
+
export function readFrom(
|
|
139
|
+
path: string,
|
|
140
|
+
offset: number,
|
|
141
|
+
wanted?: Wanted,
|
|
142
|
+
): { events: LoggedEvent[]; offset: number } {
|
|
143
|
+
let fd: number;
|
|
144
|
+
try {
|
|
145
|
+
fd = openSync(path, "r");
|
|
146
|
+
} catch {
|
|
147
|
+
return { events: [], offset };
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const size = fstatSync(fd).size;
|
|
151
|
+
if (size <= offset) return { events: [], offset: size < offset ? 0 : offset };
|
|
152
|
+
const buffer = Buffer.allocUnsafe(size - offset);
|
|
153
|
+
readSync(fd, buffer, 0, buffer.length, offset);
|
|
154
|
+
const end = buffer.lastIndexOf(NEWLINE);
|
|
155
|
+
if (end === -1) return { events: [], offset };
|
|
156
|
+
const events: LoggedEvent[] = [];
|
|
157
|
+
for (let start = 0; start < end;) {
|
|
158
|
+
const stop = buffer.indexOf(NEWLINE, start);
|
|
159
|
+
if (wanted === undefined || wanted(typeIn(buffer, start, stop)))
|
|
160
|
+
events.push(...parsed(buffer.toString("utf8", start, stop)));
|
|
161
|
+
start = stop + 1;
|
|
162
|
+
}
|
|
163
|
+
return { events, offset: offset + end + 1 };
|
|
164
|
+
} finally {
|
|
165
|
+
closeSync(fd);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const NEWLINE = 0x0a;
|
|
170
|
+
const QUOTE = 0x22;
|
|
171
|
+
const TYPE_BYTES = Buffer.from('"type":"');
|
|
172
|
+
|
|
173
|
+
/** `logEvent` writes `type` before `data`, so the first `"type":"` in a line is the event's. */
|
|
174
|
+
function typeIn(buffer: Buffer, start: number, stop: number): string {
|
|
175
|
+
const line = buffer.subarray(start, stop);
|
|
176
|
+
const at = line.indexOf(TYPE_BYTES);
|
|
177
|
+
if (at === -1) return "";
|
|
178
|
+
const from = at + TYPE_BYTES.length;
|
|
179
|
+
const close = line.indexOf(QUOTE, from);
|
|
180
|
+
return close === -1 ? "" : line.toString("latin1", from, close);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export const KEEP_LOGS_MS = 7 * 24 * 60 * 60_000;
|
|
184
|
+
|
|
185
|
+
/** Deletes the logs of sessions that are not open and have written nothing for `KEEP_LOGS_MS`. */
|
|
186
|
+
export function pruneLogs(dir: string, root = eventsRoot(), now = Date.now()): void {
|
|
187
|
+
let names: string[];
|
|
188
|
+
try {
|
|
189
|
+
names = readdirSync(dir);
|
|
190
|
+
} catch {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
for (const name of names) {
|
|
194
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
195
|
+
if (existsSync(join(liveDir(root), `${name.slice(0, -".jsonl".length)}.json`))) continue;
|
|
196
|
+
const path = join(dir, name);
|
|
197
|
+
const written = statSync(path, { throwIfNoEntry: false })?.mtimeMs ?? now;
|
|
198
|
+
if (now - written > KEEP_LOGS_MS) rmSync(path, { force: true });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function readTail(path: string, bytes = TAIL_BYTES): LoggedEvent[] {
|
|
203
|
+
let fd: number;
|
|
204
|
+
try {
|
|
205
|
+
fd = openSync(path, "r");
|
|
206
|
+
} catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const size = fstatSync(fd).size;
|
|
211
|
+
const from = Math.max(0, size - bytes);
|
|
212
|
+
const buffer = Buffer.alloc(size - from);
|
|
213
|
+
if (buffer.length === 0) return [];
|
|
214
|
+
readSync(fd, buffer, 0, buffer.length, from);
|
|
215
|
+
const text = buffer.toString("utf8");
|
|
216
|
+
// The first line of a tail is usually cut in half, and half an event is not one.
|
|
217
|
+
const whole = from === 0 ? text : text.slice(text.indexOf("\n") + 1);
|
|
218
|
+
const end = whole.lastIndexOf("\n");
|
|
219
|
+
if (end === -1) return [];
|
|
220
|
+
return whole
|
|
221
|
+
.slice(0, end)
|
|
222
|
+
.split("\n")
|
|
223
|
+
.flatMap((line) => parsed(line));
|
|
224
|
+
} finally {
|
|
225
|
+
closeSync(fd);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Enough for the last commands of a long session. */
|
|
230
|
+
const TAIL_BYTES = 512 * 1024;
|
|
231
|
+
|
|
232
|
+
/** Checks the shape, since any version may have written the line. */
|
|
233
|
+
function isLoggedEvent(value: unknown): value is LoggedEvent {
|
|
234
|
+
return (
|
|
235
|
+
isObject(value) &&
|
|
236
|
+
value.v === EVENTS_VERSION &&
|
|
237
|
+
typeof value.id === "string" &&
|
|
238
|
+
typeof value.type === "string" &&
|
|
239
|
+
typeof value.session === "string" &&
|
|
240
|
+
typeof value.at === "number" &&
|
|
241
|
+
isObject(value.data) &&
|
|
242
|
+
(value.entry === undefined || typeof value.entry === "string")
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parsed(line: string): LoggedEvent[] {
|
|
247
|
+
try {
|
|
248
|
+
const value: unknown = JSON.parse(line);
|
|
249
|
+
// A line without its `data` is not an event, and would hand `undefined` to whatever folds it.
|
|
250
|
+
if (!isLoggedEvent(value)) return [];
|
|
251
|
+
return [value];
|
|
252
|
+
} catch {
|
|
253
|
+
return [];
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Reads from where it was left: new events, oldest first. */
|
|
258
|
+
export class FolderFeed {
|
|
259
|
+
readonly #dir: string;
|
|
260
|
+
readonly #wanted: Wanted | undefined;
|
|
261
|
+
readonly #offsets = new Map<string, number>();
|
|
262
|
+
|
|
263
|
+
constructor(dir: string, wanted?: Wanted) {
|
|
264
|
+
this.#dir = dir;
|
|
265
|
+
this.#wanted = wanted;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
get dir(): string {
|
|
269
|
+
return this.#dir;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
read(): LoggedEvent[] {
|
|
273
|
+
let files: string[];
|
|
274
|
+
try {
|
|
275
|
+
files = readdirSync(this.#dir).filter((name) => name.endsWith(".jsonl"));
|
|
276
|
+
} catch {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
const fresh: LoggedEvent[] = [];
|
|
280
|
+
for (const name of files) {
|
|
281
|
+
const path = join(this.#dir, name);
|
|
282
|
+
const { events, offset } = readFrom(path, this.#offsets.get(path) ?? 0, this.#wanted);
|
|
283
|
+
this.#offsets.set(path, offset);
|
|
284
|
+
// Not a spread: a big log overflows the stack.
|
|
285
|
+
for (const event of events) fresh.push(event);
|
|
286
|
+
}
|
|
287
|
+
return fresh.toSorted((a, b) => a.at - b.at || a.id.localeCompare(b.id));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Polls every second where the folder cannot be watched. */
|
|
291
|
+
watch(changed: () => void): () => void {
|
|
292
|
+
mkdirSync(this.#dir, { recursive: true });
|
|
293
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
294
|
+
const soon = () => {
|
|
295
|
+
if (timer !== undefined) return;
|
|
296
|
+
timer = setTimeout(() => {
|
|
297
|
+
timer = undefined;
|
|
298
|
+
changed();
|
|
299
|
+
}, 30);
|
|
300
|
+
};
|
|
301
|
+
try {
|
|
302
|
+
const watcher = watch(this.#dir, soon);
|
|
303
|
+
watcher.on("error", () => {});
|
|
304
|
+
return () => {
|
|
305
|
+
watcher.close();
|
|
306
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
307
|
+
};
|
|
308
|
+
} catch {
|
|
309
|
+
const poll = setInterval(changed, 1_000);
|
|
310
|
+
return () => clearInterval(poll);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// How numbers read on screen, the same in every package.
|
|
2
|
+
|
|
3
|
+
export function formatTokens(count: number): string {
|
|
4
|
+
const n = Math.max(0, Math.round(count));
|
|
5
|
+
if (n < 1000) return String(n);
|
|
6
|
+
if (n < 10_000) return `${trim((n / 1000).toFixed(1))}k`;
|
|
7
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
8
|
+
if (n < 10_000_000) return `${trim((n / 1_000_000).toFixed(1))}M`;
|
|
9
|
+
return `${Math.round(n / 1_000_000)}M`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A price: cents are worth showing under $10, and noise above it. */
|
|
13
|
+
export function formatCost(amount: number): string {
|
|
14
|
+
if (amount === 0) return "$0.00";
|
|
15
|
+
return amount < 10 ? `$${amount.toFixed(3)}` : `$${amount.toFixed(2)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function trim(fixed: string): string {
|
|
19
|
+
return fixed.endsWith(".0") ? fixed.slice(0, -2) : fixed;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatClock(at: number): string {
|
|
23
|
+
const date = new Date(at);
|
|
24
|
+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The part of @adeildo/pi-kit 6.0.0 that frame uses. See LICENSE.
|
|
2
|
+
export { createApp } from "./app/builder.ts";
|
|
3
|
+
export { defineFeature, type FeatureScope } from "./app/feature.ts";
|
|
4
|
+
export { messageRendererFor, resolveToolRenderers } from "./app/renderers.ts";
|
|
5
|
+
export { type Board, type BoardCard, type BoardOp, emptyBoard, foldBoard } from "./board/model.ts";
|
|
6
|
+
export { ACCOUNT, type AccountState, type AccountWindow } from "./contracts/accounts.ts";
|
|
7
|
+
export { BOARD } from "./contracts/board.ts";
|
|
8
|
+
export { type CallRuling, CALLS_FRAMED, callTarget, RULING, type RulingTone } from "./contracts/calls.ts";
|
|
9
|
+
export { NAMES } from "./contracts/names.ts";
|
|
10
|
+
export { type PeerMark, PEERS, type RoomMark, type WritingMark } from "./contracts/peers.ts";
|
|
11
|
+
export { PROMPT_FROM } from "./contracts/prompts.ts";
|
|
12
|
+
export { drawSkillRefs, SKILL_REF, skillNames, skillQueryAt } from "./contracts/skills.ts";
|
|
13
|
+
export { type SessionSource, type SessionSummary, type SourceItem } from "./contracts/sources.ts";
|
|
14
|
+
export { provideViews } from "./contracts/views.ts";
|
|
15
|
+
export { type Json } from "./control.ts";
|
|
16
|
+
export { boolean, isObject, literal } from "./decode.ts";
|
|
17
|
+
export { formatClock, formatCost, formatTokens } from "./format.ts";
|
|
18
|
+
export { hideImagesUnderOverlays } from "./overlay-images.ts";
|
|
19
|
+
export { tildify } from "./paths.ts";
|
|
20
|
+
export { setting } from "./settings/setting.ts";
|
|
21
|
+
export { sourceFor } from "./sources/file.ts";
|
|
22
|
+
export { emptyTotals, promptOf, tokensOf, type Totals, totalsOf, type UsageLike, usageTotals } from "./totals.ts";
|
|
23
|
+
export { CHANGES_CUT, CHANGES_X, drawnByWidth, type EntryFrame, entryFrame } from "./ui/entry.ts";
|
|
24
|
+
export { frame, FRAME_Y, frameInner, type KeyHint } from "./ui/frame.ts";
|
|
25
|
+
export { CUT, GLYPH, LINE, SPIN } from "./ui/glyphs.ts";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Entries of the removed tasks feature: their model calls still count toward cost, and their handle
|
|
2
|
+
// names old sessions.
|
|
3
|
+
import { isObject } from "./decode.ts";
|
|
4
|
+
|
|
5
|
+
const TASKS_UPDATE = "pi-tasks:update";
|
|
6
|
+
const TASKS_USAGE = "pi-tasks:usage";
|
|
7
|
+
/** The same two, from the package's first name. */
|
|
8
|
+
const GOAL_UPDATE = "pi-goal:update";
|
|
9
|
+
const GOAL_USAGE = "pi-goal:usage";
|
|
10
|
+
|
|
11
|
+
const PAID: ReadonlySet<string> = new Set([TASKS_UPDATE, TASKS_USAGE, GOAL_UPDATE, GOAL_USAGE]);
|
|
12
|
+
|
|
13
|
+
export interface TasksUsage {
|
|
14
|
+
input: number;
|
|
15
|
+
output: number;
|
|
16
|
+
cost: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function tasksUsageOf(entry: unknown): TasksUsage | undefined {
|
|
20
|
+
if (!isObject(entry) || entry.type !== "custom" || !isObject(entry.data)) return undefined;
|
|
21
|
+
if (typeof entry.customType !== "string" || !PAID.has(entry.customType)) return undefined;
|
|
22
|
+
const usage = entry.data.usage;
|
|
23
|
+
if (!isObject(usage) || typeof usage.cost !== "number") return undefined;
|
|
24
|
+
return {
|
|
25
|
+
input: typeof usage.input === "number" ? usage.input : 0,
|
|
26
|
+
output: typeof usage.output === "number" ? usage.output : 0,
|
|
27
|
+
cost: usage.cost,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function tasksHandleOf(branch: readonly unknown[]): string | undefined {
|
|
32
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
33
|
+
const entry = branch[index];
|
|
34
|
+
if (!isObject(entry) || entry.type !== "custom" || entry.customType !== TASKS_UPDATE) continue;
|
|
35
|
+
const state = isObject(entry.data) ? entry.data.state : undefined;
|
|
36
|
+
if (isObject(state) && typeof state.handle === "string") return state.handle;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// pi-tui paints pictures over overlays. Delete this file and its calls once
|
|
2
|
+
// https://github.com/earendil-works/pi/issues/10863 is fixed.
|
|
3
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
const IMAGE = ["\x1b_G", "\x1b]1337;File="] as const;
|
|
6
|
+
|
|
7
|
+
interface Composer {
|
|
8
|
+
hasOverlay?: () => boolean;
|
|
9
|
+
compositeOverlays?: (lines: string[], width: number, height: number) => string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const wrapped = new WeakSet<object>();
|
|
13
|
+
|
|
14
|
+
export function hideImagesUnderOverlays(tui: TUI): void {
|
|
15
|
+
const composer = tui as unknown as Composer;
|
|
16
|
+
const compose = composer.compositeOverlays;
|
|
17
|
+
const hasOverlay = composer.hasOverlay;
|
|
18
|
+
if (wrapped.has(tui) || typeof compose !== "function" || typeof hasOverlay !== "function") return;
|
|
19
|
+
wrapped.add(tui);
|
|
20
|
+
composer.compositeOverlays = function (lines, width, height) {
|
|
21
|
+
const shown = hasOverlay.call(this) ? withoutImages(lines, height) : lines;
|
|
22
|
+
return compose.call(this, shown, width, height);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Only the last screenful; the scrollback stays untouched. */
|
|
27
|
+
export function withoutImages(lines: readonly string[], height: number): string[] {
|
|
28
|
+
const out = [...lines];
|
|
29
|
+
for (let index = Math.max(0, out.length - height); index < out.length; index++)
|
|
30
|
+
if (isImage(out[index] ?? "")) out[index] = "";
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isImage(line: string): boolean {
|
|
35
|
+
return IMAGE.some((prefix) => line.includes(prefix));
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The home folder as `~`, both ways: what you type expands, and what is shown shortens.
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function expandHome(path: string, home = homedir()): string {
|
|
6
|
+
if (path === "~") return home;
|
|
7
|
+
return path.startsWith("~/") ? join(home, path.slice(2)) : path;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function tildify(path: string, home = homedir()): string {
|
|
11
|
+
if (home === "" || !isAbsolute(path)) return path;
|
|
12
|
+
const rest = relative(resolve(home), resolve(path));
|
|
13
|
+
if (rest === "") return "~";
|
|
14
|
+
if (rest === ".." || rest.startsWith(`..${sep}`) || isAbsolute(rest)) return path;
|
|
15
|
+
return `~${sep}${rest}`;
|
|
16
|
+
}
|