pi-famulus 0.1.7 → 0.1.8

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.
@@ -13,19 +13,71 @@ export interface LineBatcherOptions {
13
13
  flushMs?: number; // batching window, default 200
14
14
  maxLineChars?: number; // per-line cap, default 500
15
15
  maxBatchChars?: number; // per-batch cap, default 3000
16
- onFlush: (text: string) => void;
16
+ onFlush: (text: string, info: { truncated: boolean; omittedChars: number }) => void;
17
17
  clock?: Clock;
18
18
  }
19
19
 
20
+ function sourceOfSlice(text: string, source: number, start: number, end: number): number {
21
+ if (text.length === 0 || end <= start) return 0;
22
+ return Math.round(source * ((end - start) / text.length));
23
+ }
24
+
25
+ /** Fixed-memory head/tail preview. Counts omitted source units, not generated ellipses. */
26
+ export class BoundedPreview {
27
+ private head = "";
28
+ private tail = "";
29
+ private sourceHead = 0;
30
+ private sourceTail = 0;
31
+ total = 0;
32
+ private sourceTotal = 0;
33
+ constructor(readonly limit: number) {
34
+ if (!Number.isSafeInteger(limit) || limit < 1) throw new RangeError("preview limit must be positive");
35
+ }
36
+ append(text: string, sourceChars = text.length): void {
37
+ const headLimit = Math.ceil((this.limit - 1) / 2);
38
+ if (this.total + text.length <= this.limit) {
39
+ this.head += text;
40
+ this.sourceHead += sourceChars;
41
+ } else {
42
+ const old = this.head;
43
+ const oldSourceHead = this.sourceHead;
44
+ if (this.total <= this.limit) {
45
+ const combined = old + text;
46
+ const combinedSource = oldSourceHead + sourceChars;
47
+ this.head = combined.slice(0, headLimit);
48
+ this.sourceHead = sourceOfSlice(combined, combinedSource, 0, this.head.length);
49
+ }
50
+ const tailLimit = this.limit - 1 - headLimit;
51
+ if (tailLimit > 0) {
52
+ const from = (this.tail || old).concat(text);
53
+ const fromSource = (this.tail ? this.sourceTail : oldSourceHead) + sourceChars;
54
+ this.tail = from.slice(-tailLimit);
55
+ this.sourceTail = sourceOfSlice(from, fromSource, from.length - this.tail.length, from.length);
56
+ } else {
57
+ this.tail = "";
58
+ this.sourceTail = 0;
59
+ }
60
+ }
61
+ this.total += text.length;
62
+ this.sourceTotal += sourceChars;
63
+ }
64
+ get omittedChars(): number {
65
+ if (this.total <= this.limit) return 0;
66
+ return Math.max(0, this.sourceTotal - this.sourceHead - this.sourceTail);
67
+ }
68
+ get text(): string { return this.total > this.limit ? `${this.head}…${this.tail}` : this.head; }
69
+ }
70
+
20
71
  export class LineBatcher {
21
72
  private readonly flushMs: number;
22
73
  private readonly maxLineChars: number;
23
74
  private readonly maxBatchChars: number;
24
- private readonly onFlush: (text: string) => void;
75
+ private readonly onFlush: LineBatcherOptions["onFlush"];
25
76
  private readonly clock: Clock;
26
-
27
- private partial = ""; // trailing bytes not yet terminated by \n
28
- private lines: string[] = []; // complete lines awaiting emission
77
+ private partial: BoundedPreview;
78
+ private lines: BoundedPreview;
79
+ private lineCount = 0;
80
+ private omittedLineChars = 0;
29
81
  private timer: ClockTimer | null = null;
30
82
  private disposed = false;
31
83
 
@@ -35,49 +87,52 @@ export class LineBatcher {
35
87
  this.maxBatchChars = opts.maxBatchChars ?? 3000;
36
88
  this.onFlush = opts.onFlush;
37
89
  this.clock = opts.clock ?? realClock;
90
+ this.partial = new BoundedPreview(this.maxLineChars);
91
+ this.lines = new BoundedPreview(this.maxBatchChars);
38
92
  }
39
93
 
40
- /** Feed a raw output chunk (may contain no/newlines or multiple lines). */
94
+ /** Bound retained data during ingestion, not just after the timer fires. */
41
95
  push(chunk: string): void {
42
96
  if (this.disposed || chunk.length === 0) return;
43
- const combined = this.partial + chunk;
44
- const parts = combined.split("\n");
45
- this.partial = parts.pop() ?? "";
46
- for (const line of parts) {
47
- this.lines.push(this.capLine(line));
97
+ const parts = chunk.split("\n");
98
+ for (let i = 0; i < parts.length; i++) {
99
+ this.partial.append(parts[i]);
100
+ if (i < parts.length - 1) this.finishLine();
48
101
  }
49
- if (this.lines.length > 0 || this.partial.length > 0) this.schedule();
102
+ this.schedule();
50
103
  }
51
104
 
52
- /**
53
- * Immediately emit the current buffer when non-empty, including any
54
- * unterminated trailing line. Used when a monitored process exits.
55
- */
56
105
  flush(): void {
57
106
  if (this.disposed) return;
58
107
  this.clearTimer();
59
108
  this.emit();
60
109
  }
61
110
 
62
- /** Clear the pending timer and drop buffered content. */
63
111
  dispose(): void {
64
112
  this.disposed = true;
65
113
  this.clearTimer();
66
- this.lines = [];
67
- this.partial = "";
114
+ this.reset();
68
115
  }
69
116
 
70
- private capLine(line: string): string {
71
- if (line.length <= this.maxLineChars) return line;
72
- return line.slice(0, this.maxLineChars);
117
+ private finishLine(): void {
118
+ if (this.lineCount++ > 0) this.lines.append("\n", 1);
119
+ const displayed = this.partial.text;
120
+ this.lines.append(displayed, this.partial.total - this.partial.omittedChars);
121
+ this.omittedLineChars += this.partial.omittedChars;
122
+ this.partial = new BoundedPreview(this.maxLineChars);
123
+ }
124
+
125
+ private reset(): void {
126
+ this.partial = new BoundedPreview(this.maxLineChars);
127
+ this.lines = new BoundedPreview(this.maxBatchChars);
128
+ this.lineCount = 0;
129
+ this.omittedLineChars = 0;
73
130
  }
74
131
 
75
132
  private schedule(): void {
76
133
  if (this.timer !== null) return;
77
134
  this.timer = this.clock.setTimeout(() => {
78
135
  this.timer = null;
79
- // Window end drains everything, including an unterminated trailing
80
- // line (no data loss); a fresh window starts on the next push.
81
136
  this.emit();
82
137
  }, this.flushMs);
83
138
  this.clock.unref?.(this.timer);
@@ -91,18 +146,11 @@ export class LineBatcher {
91
146
  }
92
147
 
93
148
  private emit(): void {
94
- const lines = this.lines;
95
- const partial = this.partial.length > 0 ? this.capLine(this.partial) : "";
96
- if (lines.length === 0 && partial.length === 0) return;
97
- this.lines = [];
98
- this.partial = "";
99
-
100
- let text = partial.length > 0 ? [...lines, partial].join("\n") : lines.join("\n");
101
- if (text.length > this.maxBatchChars) {
102
- // Keep the tail: for monitoring, the most recent lines matter most.
103
- text = `…${text.slice(-(this.maxBatchChars - 1))}`;
104
- }
105
- if (text.length > 0) this.onFlush(text);
149
+ if (this.partial.total > 0) this.finishLine();
150
+ const text = this.lines.text;
151
+ const omittedChars = this.omittedLineChars + this.lines.omittedChars;
152
+ this.reset();
153
+ if (text.length > 0) this.onFlush(text, { truncated: omittedChars > 0, omittedChars });
106
154
  }
107
155
  }
108
156
 
@@ -0,0 +1,159 @@
1
+ import { LineBatcher } from "./monitor-batching";
2
+ import { realClock, type Clock } from "./clock";
3
+ import type { MonitorOutputRecord } from "./manager-client";
4
+ import type { MonitorOutputInfo } from "./wake";
5
+
6
+ export const DEFAULT_DEDUP_WINDOW_MS = 60_000;
7
+ export const MAX_DEDUP_KEYS = 2048;
8
+ export type FingerprintMode = "exact" | "ignore-timestamps";
9
+ export interface MonitorOutputBatch { text: string; output: MonitorOutputInfo }
10
+ interface Identity { phase: "buffered" | "queued" | "emitted"; lastEmitted?: number; preview: string }
11
+
12
+ /** Merge original-byte range envelopes, not a promise of contiguous identical events. */
13
+ export function mergeOutputInfo(a: MonitorOutputInfo, b: MonitorOutputInfo): MonitorOutputInfo {
14
+ const min = (x?: number, y?: number) => x === undefined ? y : y === undefined ? x : Math.min(x, y);
15
+ const max = (x?: number, y?: number) => x === undefined ? y : y === undefined ? x : Math.max(x, y);
16
+ return {
17
+ firstObservedAt: min(a.firstObservedAt, b.firstObservedAt),
18
+ lastObservedAt: max(a.lastObservedAt, b.lastObservedAt),
19
+ cursorStart: min(a.cursorStart, b.cursorStart), cursorEnd: max(a.cursorEnd, b.cursorEnd),
20
+ truncated: a.truncated || b.truncated,
21
+ suppressedCount: (a.suppressedCount ?? 0) + (b.suppressedCount ?? 0),
22
+ omittedChars: (a.omittedChars ?? 0) + (b.omittedChars ?? 0),
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Rust owns framing and hashing; this module owns the notification window.
28
+ * Pending identities are suppressed until onDelivered starts their fixed window.
29
+ * No timers send repeat-only summaries. Original bytes stay in the manager log.
30
+ */
31
+ export class MonitorOutputBuffer {
32
+ private readonly clock: Clock;
33
+ private readonly batcher: LineBatcher;
34
+ private readonly identities = new Map<string, Identity>();
35
+ private batchKeys = new Set<string>();
36
+ private batchOutput: MonitorOutputInfo = {};
37
+ private suppressed: MonitorOutputInfo = {};
38
+ private disposed = false;
39
+ private lastNow = 0;
40
+
41
+ constructor(private readonly options: {
42
+ clock?: Clock;
43
+ windowMs?: number;
44
+ mode?: FingerprintMode;
45
+ onFlush: (batch: MonitorOutputBatch) => boolean;
46
+ onSuppressed?: () => void;
47
+ }) {
48
+ this.clock = options.clock ?? realClock;
49
+ this.batcher = new LineBatcher({ clock: this.clock, onFlush: (text, info) => {
50
+ this.batchOutput = mergeOutputInfo(this.batchOutput, info);
51
+ this.emit(text);
52
+ } });
53
+ }
54
+
55
+ /** Stable callback identity: NotifyCenter retains it once per pending monitor. */
56
+ readonly onDelivered = (visiblePreview?: string): void => {
57
+ const now = this.now();
58
+ const visibleLines = visiblePreview === undefined ? undefined : new Set(visiblePreview ? visiblePreview.split("\n") : []);
59
+ const queuedPreviews = new Map<string, number>();
60
+ if (visibleLines) {
61
+ for (const identity of this.identities.values()) {
62
+ if (identity.phase === "queued") {
63
+ queuedPreviews.set(identity.preview, (queuedPreviews.get(identity.preview) ?? 0) + 1);
64
+ }
65
+ }
66
+ }
67
+ for (const [key, identity] of this.identities) {
68
+ if (identity.phase !== "queued") continue;
69
+ // Both local batching and busy coalescing can omit an entire identity.
70
+ // Never pretend the model saw it: truncated representatives fail open.
71
+ // Distinct fingerprints can share a truncated preview; fail those open too.
72
+ const ambiguous = (queuedPreviews.get(identity.preview) ?? 0) > 1;
73
+ if (visibleLines && (ambiguous || !visibleLines.has(identity.preview))) {
74
+ if (identity.lastEmitted === undefined) this.identities.delete(key);
75
+ else identity.phase = "emitted";
76
+ } else {
77
+ identity.phase = "emitted";
78
+ identity.lastEmitted = now;
79
+ }
80
+ }
81
+ };
82
+
83
+ push(record: MonitorOutputRecord): void {
84
+ if (this.disposed || !record.preview) return;
85
+ const now = this.now();
86
+ const info: MonitorOutputInfo = {
87
+ firstObservedAt: record.observed_at, lastObservedAt: record.observed_at,
88
+ cursorStart: record.cursor_start, cursorEnd: record.cursor_end, truncated: record.truncated,
89
+ };
90
+ const hash = record.kind === "line"
91
+ ? this.options.mode === "ignore-timestamps" ? record.timestamp_fingerprint : record.fingerprint
92
+ : undefined;
93
+ const window = this.options.windowMs ?? DEFAULT_DEDUP_WINDOW_MS;
94
+ const key = window > 0 ? hash : undefined;
95
+ if (key) {
96
+ const previous = this.identities.get(key);
97
+ if (previous && (previous.phase !== "emitted" || now - previous.lastEmitted! < window)) {
98
+ this.suppressed = mergeOutputInfo(this.suppressed, { ...info, suppressedCount: 1 });
99
+ this.options.onSuppressed?.();
100
+ return;
101
+ }
102
+ // Capacity eviction fails open: extra output is preferable to false suppression.
103
+ if (!previous && this.identities.size >= MAX_DEDUP_KEYS) {
104
+ const oldest = this.identities.keys().next().value;
105
+ if (oldest !== undefined) {
106
+ this.identities.delete(oldest);
107
+ this.batchKeys.delete(oldest);
108
+ }
109
+ }
110
+ this.identities.set(key, { phase: "buffered", lastEmitted: previous?.lastEmitted, preview: record.preview });
111
+ this.batchKeys.add(key);
112
+ }
113
+ this.batchOutput = mergeOutputInfo(mergeOutputInfo(this.batchOutput, this.suppressed), info);
114
+ this.suppressed = {};
115
+ this.batcher.push(`${record.preview}\n`);
116
+ }
117
+
118
+ flush(): void { this.batcher.flush(); }
119
+
120
+ /** Attach unreported repeats to the terminal notice without another event/wake. */
121
+ takeSummary(): MonitorOutputInfo {
122
+ const summary = this.suppressed;
123
+ this.suppressed = {};
124
+ return summary;
125
+ }
126
+
127
+ dispose(): void {
128
+ this.disposed = true;
129
+ this.batcher.dispose();
130
+ this.identities.clear();
131
+ this.batchKeys.clear();
132
+ }
133
+
134
+ private now(): number {
135
+ this.lastNow = Math.max(this.lastNow, this.clock.now());
136
+ return this.lastNow;
137
+ }
138
+
139
+ private emit(text: string): void {
140
+ const keys = this.batchKeys;
141
+ const output = this.batchOutput;
142
+ this.batchKeys = new Set();
143
+ this.batchOutput = {};
144
+ for (const key of keys) {
145
+ const identity = this.identities.get(key);
146
+ if (identity?.phase === "buffered") identity.phase = "queued";
147
+ }
148
+ if (!this.options.onFlush({ text, output })) {
149
+ // A rate-limited batch wasn't delivered: don't start a suppression window.
150
+ for (const key of keys) {
151
+ const identity = this.identities.get(key);
152
+ if (identity?.phase !== "queued") continue;
153
+ if (identity.lastEmitted === undefined) this.identities.delete(key);
154
+ else identity.phase = "emitted";
155
+ }
156
+ this.suppressed = mergeOutputInfo(this.suppressed, { ...output, suppressedCount: output.suppressedCount ?? 0 });
157
+ }
158
+ }
159
+ }