pi-observability 1.0.1 → 1.3.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.
@@ -0,0 +1,161 @@
1
+ import { DEFAULT_SETTINGS, PRESETS } from "./metadata.js";
2
+ import type { PresetName, SegmentKey, SettingsConfig, SettingsUpdateResult } from "./types.js";
3
+
4
+ export function createDefaultSettings(): SettingsConfig {
5
+ return structuredClone(DEFAULT_SETTINGS);
6
+ }
7
+
8
+ export function applyPreset(config: SettingsConfig, preset: PresetName): SettingsConfig {
9
+ const next = structuredClone(config);
10
+ next.preset = preset;
11
+ const p = PRESETS[preset];
12
+ for (const [key, val] of Object.entries(p) as [SegmentKey, boolean][]) {
13
+ next.segments[key] = val;
14
+ }
15
+ return next;
16
+ }
17
+
18
+ export function toggleSegment(config: SettingsConfig, key: SegmentKey): SettingsConfig {
19
+ const next = structuredClone(config);
20
+ next.segments[key] = !next.segments[key];
21
+ return next;
22
+ }
23
+
24
+ export function setSegment(
25
+ config: SettingsConfig,
26
+ key: SegmentKey,
27
+ value: boolean,
28
+ ): SettingsConfig {
29
+ const next = structuredClone(config);
30
+ next.segments[key] = value;
31
+ return next;
32
+ }
33
+
34
+ export function setZone(
35
+ config: SettingsConfig,
36
+ key: "expert" | "warning",
37
+ value: number,
38
+ ): SettingsConfig {
39
+ const next = structuredClone(config);
40
+ next.contextZones[key] = Math.max(0, Math.min(100, value));
41
+ // Ensure expert <= warning
42
+ if (next.contextZones.expert > next.contextZones.warning) {
43
+ if (key === "expert") {
44
+ next.contextZones.warning = next.contextZones.expert;
45
+ } else {
46
+ next.contextZones.expert = next.contextZones.warning;
47
+ }
48
+ }
49
+ return next;
50
+ }
51
+
52
+ export function validateSettings(raw: unknown): SettingsConfig {
53
+ if (!raw || typeof raw !== "object") {
54
+ return createDefaultSettings();
55
+ }
56
+ const r = raw as Record<string, unknown>;
57
+
58
+ const preset = isPresetName(r.preset) ? r.preset : DEFAULT_SETTINGS.preset;
59
+ const segments = validateSegments(r.segments);
60
+ const contextZones = validateZones(r.contextZones);
61
+
62
+ return { version: 1, preset, segments, contextZones };
63
+ }
64
+
65
+ export function migrateSettings(raw: unknown): SettingsConfig {
66
+ const validated = validateSettings(raw);
67
+ // If we ever need version migrations, add them here
68
+ return validated;
69
+ }
70
+
71
+ export function updateSetting(
72
+ config: SettingsConfig,
73
+ id: string,
74
+ value: string,
75
+ ): SettingsUpdateResult {
76
+ let next = structuredClone(config);
77
+ const derivedUpdates: Array<{ id: string; value: string }> = [];
78
+
79
+ switch (id) {
80
+ case "preset": {
81
+ if (isPresetName(value)) {
82
+ next = applyPreset(next, value);
83
+ for (const key of Object.keys(next.segments) as SegmentKey[]) {
84
+ derivedUpdates.push({ id: key, value: next.segments[key] ? "true" : "false" });
85
+ }
86
+ }
87
+ break;
88
+ }
89
+ case "modelThink":
90
+ case "runtime":
91
+ case "pwd":
92
+ case "git":
93
+ case "contextUsage":
94
+ case "contextProgress":
95
+ case "contextPercentage":
96
+ case "contextNumbers":
97
+ case "tokens":
98
+ case "tps":
99
+ case "cost": {
100
+ next = setSegment(next, id, value === "true");
101
+ // Context sub-toggle dependency: if contextUsage is turned off, children are hidden
102
+ if (id === "contextUsage" && value === "false") {
103
+ for (const child of [
104
+ "contextProgress",
105
+ "contextPercentage",
106
+ "contextNumbers",
107
+ ] as SegmentKey[]) {
108
+ derivedUpdates.push({ id: child, value: "false" });
109
+ }
110
+ }
111
+ break;
112
+ }
113
+ case "expertZone": {
114
+ next = setZone(next, "expert", parseInt(value, 10));
115
+ break;
116
+ }
117
+ case "warningZone": {
118
+ next = setZone(next, "warning", parseInt(value, 10));
119
+ break;
120
+ }
121
+ }
122
+
123
+ return { config: next, derivedUpdates };
124
+ }
125
+
126
+ function isPresetName(v: unknown): v is PresetName {
127
+ return v === "minimal" || v === "standard" || v === "verbose" || v === "performance";
128
+ }
129
+
130
+ function validateSegments(raw: unknown): Record<SegmentKey, boolean> {
131
+ const segments = { ...DEFAULT_SETTINGS.segments };
132
+ if (!raw || typeof raw !== "object") return segments;
133
+ for (const [key, val] of Object.entries(raw)) {
134
+ if (key in segments && typeof val === "boolean") {
135
+ segments[key as SegmentKey] = val;
136
+ }
137
+ }
138
+ return segments;
139
+ }
140
+
141
+ function validateZones(raw: unknown): { expert: number; warning: number } {
142
+ const zones = { ...DEFAULT_SETTINGS.contextZones };
143
+ if (!raw || typeof raw !== "object") return zones;
144
+ const r = raw as Record<string, unknown>;
145
+
146
+ if (typeof r.expert === "number") zones.expert = clamp(0, r.expert, 100);
147
+ if (typeof r.warning === "number") zones.warning = clamp(0, r.warning, 100);
148
+
149
+ // Ensure expert <= warning
150
+ if (zones.expert > zones.warning) {
151
+ const avg = Math.round((zones.expert + zones.warning) / 2);
152
+ zones.expert = avg;
153
+ zones.warning = avg;
154
+ }
155
+
156
+ return zones;
157
+ }
158
+
159
+ function clamp(min: number, val: number, max: number): number {
160
+ return Math.max(min, Math.min(max, val));
161
+ }
@@ -0,0 +1,32 @@
1
+ export type {
2
+ SegmentKey,
3
+ PresetName,
4
+ SettingsConfig,
5
+ SettingsListItem,
6
+ SettingsUpdateResult,
7
+ SegmentMetadata,
8
+ } from "./types.js";
9
+
10
+ export { DEFAULT_SETTINGS, PRESETS, SEGMENT_METADATA, ZONE_VALUE_OPTIONS } from "./metadata.js";
11
+
12
+ export {
13
+ createDefaultSettings,
14
+ applyPreset,
15
+ toggleSegment,
16
+ setSegment,
17
+ setZone,
18
+ validateSettings,
19
+ migrateSettings,
20
+ updateSetting,
21
+ } from "./domain.js";
22
+
23
+ export { toSettingsListItems } from "./tui.js";
24
+
25
+ export {
26
+ createSettingsStorage,
27
+ createMemorySettingsStorage,
28
+ loadSettings,
29
+ saveSettings,
30
+ } from "./storage.js";
31
+
32
+ export { createSettingsManager, type SettingsManager } from "./manager.js";
@@ -0,0 +1,58 @@
1
+ import { loadSettings, saveSettings } from "./storage.js";
2
+ import {
3
+ applyPreset,
4
+ toggleSegment,
5
+ setSegment,
6
+ setZone,
7
+ createDefaultSettings,
8
+ } from "./domain.js";
9
+ import type { PresetName, SegmentKey, SettingsConfig } from "./types.js";
10
+ import type { Storage } from "../storage/index.js";
11
+
12
+ export interface SettingsManager {
13
+ load(): Promise<void>;
14
+ save(): Promise<void>;
15
+ getConfig(): SettingsConfig;
16
+ applyPreset(preset: PresetName): SettingsConfig;
17
+ toggleSegment(key: SegmentKey): SettingsConfig;
18
+ setSegment(key: SegmentKey, value: boolean): SettingsConfig;
19
+ setZone(key: "expert" | "warning", value: number): SettingsConfig;
20
+ }
21
+
22
+ export function createSettingsManager(storage: Storage): SettingsManager {
23
+ let config: SettingsConfig = createDefaultSettings();
24
+
25
+ return {
26
+ async load() {
27
+ config = await loadSettings(storage);
28
+ },
29
+
30
+ async save() {
31
+ await saveSettings(config, storage);
32
+ },
33
+
34
+ getConfig() {
35
+ return structuredClone(config);
36
+ },
37
+
38
+ applyPreset(preset) {
39
+ config = applyPreset(config, preset);
40
+ return structuredClone(config);
41
+ },
42
+
43
+ toggleSegment(key) {
44
+ config = toggleSegment(config, key);
45
+ return structuredClone(config);
46
+ },
47
+
48
+ setSegment(key, value) {
49
+ config = setSegment(config, key, value);
50
+ return structuredClone(config);
51
+ },
52
+
53
+ setZone(key, value) {
54
+ config = setZone(config, key, value);
55
+ return structuredClone(config);
56
+ },
57
+ };
58
+ }
@@ -0,0 +1,114 @@
1
+ import type { PresetName, SegmentKey, SegmentMetadata, SettingsConfig } from "./types.js";
2
+
3
+ export const DEFAULT_SETTINGS: SettingsConfig = {
4
+ version: 1,
5
+ preset: "standard",
6
+ segments: {
7
+ modelThink: true,
8
+ runtime: true,
9
+ pwd: true,
10
+ git: true,
11
+ contextUsage: true,
12
+ contextProgress: true,
13
+ contextPercentage: true,
14
+ contextNumbers: true,
15
+ tokens: true,
16
+ tps: true,
17
+ cost: true,
18
+ },
19
+ contextZones: { expert: 70, warning: 85 },
20
+ };
21
+
22
+ export const PRESETS: Record<PresetName, Partial<Record<SegmentKey, boolean>>> = {
23
+ minimal: {
24
+ modelThink: true,
25
+ contextUsage: true,
26
+ contextProgress: true,
27
+ contextPercentage: false,
28
+ contextNumbers: true,
29
+ runtime: false,
30
+ pwd: false,
31
+ git: false,
32
+ tokens: false,
33
+ tps: false,
34
+ cost: false,
35
+ },
36
+ standard: {
37
+ modelThink: true,
38
+ runtime: true,
39
+ pwd: true,
40
+ git: true,
41
+ contextUsage: true,
42
+ contextProgress: true,
43
+ contextPercentage: true,
44
+ contextNumbers: true,
45
+ tokens: true,
46
+ tps: false,
47
+ cost: true,
48
+ },
49
+ verbose: {
50
+ modelThink: true,
51
+ runtime: true,
52
+ pwd: true,
53
+ git: true,
54
+ contextUsage: true,
55
+ contextProgress: true,
56
+ contextPercentage: true,
57
+ contextNumbers: true,
58
+ tokens: true,
59
+ tps: true,
60
+ cost: true,
61
+ },
62
+ performance: {
63
+ modelThink: true,
64
+ runtime: false,
65
+ pwd: false,
66
+ git: false,
67
+ contextUsage: true,
68
+ contextProgress: false,
69
+ contextPercentage: true,
70
+ contextNumbers: true,
71
+ tokens: false,
72
+ tps: true,
73
+ cost: true,
74
+ },
75
+ };
76
+
77
+ export const SEGMENT_METADATA: SegmentMetadata[] = [
78
+ {
79
+ id: "modelThink",
80
+ label: "Model & Thinking",
81
+ description: "Show current model and thinking level",
82
+ },
83
+ { id: "runtime", label: "Runtime", description: "Show session runtime timer" },
84
+ { id: "pwd", label: "Working Directory", description: "Show current working directory" },
85
+ { id: "git", label: "Git Branch & Diff", description: "Show git branch and diff stats" },
86
+ {
87
+ id: "contextUsage",
88
+ label: "Context Usage",
89
+ description: "Master toggle for the context usage segment",
90
+ },
91
+ {
92
+ id: "contextProgress",
93
+ label: " └ Progress Bar",
94
+ description: "Show the progress bar in context usage",
95
+ },
96
+ {
97
+ id: "contextPercentage",
98
+ label: " └ Percentage",
99
+ description: "Show the percentage in context usage",
100
+ },
101
+ {
102
+ id: "contextNumbers",
103
+ label: " └ Used / Total",
104
+ description: "Show the token count in context usage",
105
+ },
106
+ { id: "tokens", label: "Session Tokens", description: "Show total input/output token counts" },
107
+ { id: "tps", label: "TPS (Tokens/Sec)", description: "Show live and last-turn TPS" },
108
+ { id: "cost", label: "Cost", description: "Show estimated session cost" },
109
+ ];
110
+
111
+ export const ZONE_VALUE_OPTIONS = {
112
+ expert: ["60", "65", "70", "75", "80"],
113
+ warning: ["75", "80", "85", "90", "95"],
114
+ };
@@ -0,0 +1,38 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import {
4
+ createFileBackend,
5
+ createMemoryBackend,
6
+ createStorage,
7
+ type Storage,
8
+ } from "../storage/index.js";
9
+ import { migrateSettings } from "./domain.js";
10
+ import type { SettingsConfig } from "./types.js";
11
+
12
+ const DEFAULT_DIR = join(homedir(), ".pi", "agent", "observability");
13
+
14
+ export function createSettingsStorage(options?: { dir?: string }): Storage {
15
+ const dir = options?.dir ?? DEFAULT_DIR;
16
+ const backend = createFileBackend({ dir });
17
+ return createStorage(backend);
18
+ }
19
+
20
+ export function createMemorySettingsStorage(): Storage {
21
+ const backend = createMemoryBackend();
22
+ return createStorage(backend);
23
+ }
24
+
25
+ export async function loadSettings(storage: Storage): Promise<SettingsConfig> {
26
+ const store = storage.json<SettingsConfig>("settings", { defaults: undefined });
27
+ try {
28
+ const raw = await store.load();
29
+ return migrateSettings(raw);
30
+ } catch {
31
+ return migrateSettings(undefined);
32
+ }
33
+ }
34
+
35
+ export async function saveSettings(config: SettingsConfig, storage: Storage): Promise<void> {
36
+ const store = storage.json<SettingsConfig>("settings");
37
+ await store.save(config);
38
+ }
@@ -0,0 +1,44 @@
1
+ import { SEGMENT_METADATA, ZONE_VALUE_OPTIONS } from "./metadata.js";
2
+ import type { SettingsConfig, SettingsListItem } from "./types.js";
3
+
4
+ export function toSettingsListItems(config: SettingsConfig): SettingsListItem[] {
5
+ const items: SettingsListItem[] = [
6
+ {
7
+ id: "preset",
8
+ label: "Layout Preset",
9
+ description:
10
+ "Quick layout presets. Individual segments can still be toggled after applying a preset.",
11
+ currentValue: config.preset,
12
+ values: ["minimal", "standard", "verbose", "performance"],
13
+ },
14
+ ];
15
+
16
+ for (const meta of SEGMENT_METADATA) {
17
+ items.push({
18
+ id: meta.id,
19
+ label: meta.label,
20
+ description: meta.description,
21
+ currentValue: config.segments[meta.id] ? "true" : "false",
22
+ values: ["true", "false"],
23
+ });
24
+ }
25
+
26
+ items.push(
27
+ {
28
+ id: "expertZone",
29
+ label: "Expert Zone Threshold",
30
+ description: "Context usage percentage where the bar turns green (0-100)",
31
+ currentValue: `${config.contextZones.expert}`,
32
+ values: ZONE_VALUE_OPTIONS.expert,
33
+ },
34
+ {
35
+ id: "warningZone",
36
+ label: "Warning Zone Threshold",
37
+ description: "Context usage percentage where the bar turns yellow (0-100)",
38
+ currentValue: `${config.contextZones.warning}`,
39
+ values: ZONE_VALUE_OPTIONS.warning,
40
+ },
41
+ );
42
+
43
+ return items;
44
+ }
@@ -0,0 +1,40 @@
1
+ export type SegmentKey =
2
+ | "modelThink"
3
+ | "runtime"
4
+ | "pwd"
5
+ | "git"
6
+ | "contextUsage"
7
+ | "contextProgress"
8
+ | "contextPercentage"
9
+ | "contextNumbers"
10
+ | "tokens"
11
+ | "tps"
12
+ | "cost";
13
+
14
+ export type PresetName = "minimal" | "standard" | "verbose" | "performance";
15
+
16
+ export interface SettingsConfig {
17
+ version: number;
18
+ preset: PresetName;
19
+ segments: Record<SegmentKey, boolean>;
20
+ contextZones: { expert: number; warning: number };
21
+ }
22
+
23
+ export interface SettingsListItem {
24
+ id: string;
25
+ label: string;
26
+ description: string;
27
+ currentValue: string;
28
+ values: string[];
29
+ }
30
+
31
+ export interface SettingsUpdateResult {
32
+ config: SettingsConfig;
33
+ derivedUpdates: Array<{ id: string; value: string }>;
34
+ }
35
+
36
+ export interface SegmentMetadata {
37
+ id: SegmentKey;
38
+ label: string;
39
+ description: string;
40
+ }
@@ -0,0 +1,62 @@
1
+ import { mkdir, readFile, rename, writeFile, appendFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { RawBackend } from "./types.js";
4
+
5
+ export interface FileBackendOptions {
6
+ dir: string;
7
+ }
8
+
9
+ export function createFileBackend(options: FileBackendOptions): RawBackend {
10
+ const { dir } = options;
11
+ let ensured = false;
12
+
13
+ async function ensureDir() {
14
+ if (ensured) return;
15
+ await mkdir(dir, { recursive: true });
16
+ ensured = true;
17
+ }
18
+
19
+ function pathFor(name: string): string {
20
+ return join(dir, name);
21
+ }
22
+
23
+ return {
24
+ async read(name) {
25
+ try {
26
+ return await readFile(pathFor(name), "utf8");
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ },
31
+
32
+ async write(name, content) {
33
+ await ensureDir();
34
+ const target = pathFor(name);
35
+ const temp = `${target}.tmp`;
36
+ await writeFile(temp, content, "utf8");
37
+ await rename(temp, target);
38
+ },
39
+
40
+ async append(name, line) {
41
+ await ensureDir();
42
+ await appendFile(pathFor(name), `${line}\n`, "utf8");
43
+ },
44
+
45
+ async readLines(name, options) {
46
+ const text = await this.read(name);
47
+ if (!text) return [];
48
+ const lines = text.split("\n").filter((l) => l.trim());
49
+ if (options?.last !== undefined && options.last > 0) {
50
+ return lines.slice(-options.last);
51
+ }
52
+ return lines;
53
+ },
54
+
55
+ async trimLines(name, keepLast) {
56
+ const lines = await this.readLines(name);
57
+ if (lines.length <= keepLast) return;
58
+ const kept = lines.slice(-keepLast);
59
+ await this.write(name, kept.map((l) => `${l}\n`).join(""));
60
+ },
61
+ };
62
+ }
@@ -0,0 +1,33 @@
1
+ export { createJsonStore } from "./json-store.js";
2
+ export { createJsonlStore } from "./jsonl-store.js";
3
+ export { createFileBackend, type FileBackendOptions } from "./file-backend.js";
4
+ export { createMemoryBackend } from "./memory-backend.js";
5
+ export type { JsonStore, JsonlStore, Storage, RawBackend } from "./types.js";
6
+
7
+ import { createJsonStore } from "./json-store.js";
8
+ import { createJsonlStore } from "./jsonl-store.js";
9
+ import type { Storage, RawBackend } from "./types.js";
10
+
11
+ export function createStorage(backend: RawBackend): Storage {
12
+ return {
13
+ json<T>(name: string, options?: { defaults?: T }) {
14
+ return createJsonStore(backend, name, options);
15
+ },
16
+ jsonl(name: string) {
17
+ return createJsonlStore(backend, name);
18
+ },
19
+ };
20
+ }
21
+
22
+ import { createFileBackend } from "./file-backend.js";
23
+ import { createMemoryBackend } from "./memory-backend.js";
24
+
25
+ export function createFileStorage(options: { dir: string }): Storage {
26
+ const backend = createFileBackend(options);
27
+ return createStorage(backend);
28
+ }
29
+
30
+ export function createMemoryStorage(): Storage {
31
+ const backend = createMemoryBackend();
32
+ return createStorage(backend);
33
+ }
@@ -0,0 +1,32 @@
1
+ import type { JsonStore, RawBackend } from "./types.js";
2
+
3
+ export function createJsonStore<T>(
4
+ backend: RawBackend,
5
+ name: string,
6
+ options?: { defaults?: T },
7
+ ): JsonStore<T> {
8
+ const fileName = `${name}.json`;
9
+ const defaults = options?.defaults;
10
+
11
+ return {
12
+ async load(): Promise<T> {
13
+ const text = await backend.read(fileName);
14
+ if (text === undefined) {
15
+ if (defaults !== undefined) return defaults;
16
+ throw new Error(`Missing file and no defaults for ${fileName}`);
17
+ }
18
+ try {
19
+ return JSON.parse(text) as T;
20
+ } catch (err) {
21
+ console.error(`[storage] corrupt JSON in ${fileName}, using defaults:`, err);
22
+ if (defaults !== undefined) return defaults;
23
+ throw err;
24
+ }
25
+ },
26
+
27
+ async save(value: T): Promise<void> {
28
+ const text = JSON.stringify(value, null, 2);
29
+ await backend.write(fileName, text);
30
+ },
31
+ };
32
+ }
@@ -0,0 +1,29 @@
1
+ import type { JsonlStore, RawBackend } from "./types.js";
2
+
3
+ export function createJsonlStore<T>(backend: RawBackend, name: string): JsonlStore<T> {
4
+ const fileName = `${name}.jsonl`;
5
+
6
+ return {
7
+ async append(value: T): Promise<void> {
8
+ const line = JSON.stringify(value);
9
+ await backend.append(fileName, line);
10
+ },
11
+
12
+ async read(options?: { last?: number }): Promise<T[]> {
13
+ const lines = await backend.readLines(fileName, options);
14
+ const results: T[] = [];
15
+ for (const line of lines) {
16
+ try {
17
+ results.push(JSON.parse(line) as T);
18
+ } catch (err) {
19
+ console.error(`[storage] corrupt JSONL line in ${fileName}, skipping:`, err);
20
+ }
21
+ }
22
+ return results;
23
+ },
24
+
25
+ async trim(options: { keepLast: number }): Promise<void> {
26
+ await backend.trimLines(fileName, options.keepLast);
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,37 @@
1
+ import type { RawBackend } from "./types.js";
2
+
3
+ export function createMemoryBackend(initial?: Map<string, string>): RawBackend {
4
+ const store = initial ?? new Map<string, string>();
5
+
6
+ return {
7
+ async read(name) {
8
+ return store.get(name);
9
+ },
10
+
11
+ async write(name, content) {
12
+ store.set(name, content);
13
+ },
14
+
15
+ async append(name, line) {
16
+ const existing = store.get(name) ?? "";
17
+ store.set(name, `${existing}${line}\n`);
18
+ },
19
+
20
+ async readLines(name, options) {
21
+ const text = store.get(name);
22
+ if (!text) return [];
23
+ const lines = text.split("\n").filter((l) => l.trim());
24
+ if (options?.last !== undefined && options.last > 0) {
25
+ return lines.slice(-options.last);
26
+ }
27
+ return lines;
28
+ },
29
+
30
+ async trimLines(name, keepLast) {
31
+ const lines = await this.readLines(name);
32
+ if (lines.length <= keepLast) return;
33
+ const kept = lines.slice(-keepLast);
34
+ store.set(name, kept.map((l) => `${l}\n`).join(""));
35
+ },
36
+ };
37
+ }