pi-optchat 0.6.7 → 0.6.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.
@@ -0,0 +1,42 @@
1
+ import { NODE } from './memory.ts';
2
+
3
+ /** Per-profile choices where OptChat departs from Victor's recipe. Each default is the recipe's behaviour unless noted. */
4
+ export const SETTINGS = {
5
+ subagentLevels: { kind: 'number', label: 'Subagent levels', default: 1, min: 1, unit: '',
6
+ description: 'How many levels of subagents. 1: only the main agent starts subagents, as in Victor\'s recipe. 2: those subagents may start their own, and so on.',
7
+ applies: 'Applies to subagents started or resumed after this.' },
8
+ maxAgents: { kind: 'number', label: 'Max active agents', default: 8, min: 1, unit: '',
9
+ description: 'Subagents running at once in this profile, all levels together. It also caps how deep a chain can go.',
10
+ applies: 'Applies to the next spawn.' },
11
+ previousExchange: { kind: 'toggle', label: 'Previous exchange', default: true,
12
+ description: 'Send your last request and its answer in full with the next turn, so a follow-up reads the exact wording. Off: memory view only, as in Victor\'s recipe. On by default: a study found it costs little and saves zooms.',
13
+ applies: 'Applies from the next turn.' },
14
+ previousExchangeKB: { kind: 'number', label: 'Previous exchange limit', default: 16, min: 1, unit: 'KB',
15
+ description: 'A larger last exchange, usually a big paste, is left out and the model zooms into memory instead.',
16
+ applies: 'Applies from the next turn.' },
17
+ summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
18
+ description: `The compactor is always asked for ${NODE}-byte lines; a longer line up to this size is kept instead of retried. ${NODE}: strict, as in Victor's recipe. The default saves retries for a little more view space.`,
19
+ applies: 'Applies to the next summary.' },
20
+ } as const;
21
+
22
+ export type SettingKey = keyof typeof SETTINGS;
23
+ export type Settings = { -readonly [K in SettingKey]: (typeof SETTINGS)[K]['default'] extends boolean ? boolean : number };
24
+ export type NumberKey = { [K in SettingKey]: Settings[K] extends number ? K : never }[SettingKey];
25
+ export const SETTING_KEYS = Object.keys(SETTINGS) as SettingKey[];
26
+ export const isNumberKey = (key: SettingKey): key is NumberKey => SETTINGS[key].kind === 'number';
27
+
28
+ /** Why a value can't be used, or undefined if it can. Numbers are whole and at least their minimum. */
29
+ export function invalid(key: SettingKey, value: unknown) {
30
+ if (isNumberKey(key)) { const { label, min } = SETTINGS[key]; return Number.isSafeInteger(value) && Number(value) >= min ? undefined : `${label} must be a whole number of ${min} or more.`; }
31
+ return typeof value === 'boolean' ? undefined : `${SETTINGS[key].label} must be true or false.`;
32
+ }
33
+
34
+ /** Missing keys take their defaults, so config files written before a setting existed keep working. */
35
+ export function readSettings(value: Record<string, unknown>): Settings {
36
+ return Object.fromEntries(SETTING_KEYS.map(key => {
37
+ const raw = Object.hasOwn(value, key) ? value[key] : SETTINGS[key].default, problem = invalid(key, raw);
38
+ if (problem) throw new Error(problem);
39
+ return [key, raw];
40
+ })) as Settings;
41
+ }
42
+ export const DEFAULT_SETTINGS = readSettings({});
package/src/transcript.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
2
2
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
3
  import { getCurrentSystemMessage, type SystemMessage, type UserMessage } from '@earendil-works/pi-ai';
4
- import { cap, type Memory } from './memory.ts';
4
+ import { CAP, cap, type Memory } from './memory.ts';
5
5
  import { record } from './cache.ts';
6
+ import { DEFAULT_SETTINGS } from './settings.ts';
6
7
 
7
8
  export const RUN_BOUNDARY = 'optchat.run';
8
9
  /** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
@@ -44,7 +45,7 @@ export function logMessage(memory: Memory, message: AgentMessage, receipt?: stri
44
45
  export function boundedMessage(message: AgentMessage): AgentMessage {
45
46
  if (message.role !== 'toolResult') return message;
46
47
  const text = message.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
47
- if (text.length <= 30_000) return message;
48
+ if (text.length <= CAP) return message;
48
49
  return { ...message, content: [{ type: 'text', text: cap(text) }, ...message.content.filter(c => c.type === 'image')] };
49
50
  }
50
51
  /** The caller supplies a single settled run, including any steering after text-only replies. */
@@ -63,8 +64,18 @@ function completedExchange(history: readonly AgentMessage[]) {
63
64
  return [...requests, { ...answer, content }];
64
65
  }
65
66
 
66
- /** Recover the latest successful run on this branch, skipping failed and unfinished runs. */
67
- export function previousExchange(branch: readonly SessionEntry[]) {
67
+ /** Default limit in bytes of text (16 KB, ~4,000 tokens). Most exchanges are 1-7 KB; a larger one, usually a big paste, is
68
+ * left out entirely and the model falls back to its summaries in the memory view, zooming for the full text if it needs it. */
69
+ export const PREVIOUS_EXCHANGE = DEFAULT_SETTINGS.previousExchangeKB * 1000;
70
+
71
+ /** The latest successful run on this branch, skipping failed and unfinished runs; none if it is over `limit` bytes. */
72
+ export function previousExchange(branch: readonly SessionEntry[], limit = PREVIOUS_EXCHANGE) {
73
+ const exchange = latestExchange(branch);
74
+ const bytes = exchange.reduce((sum, message) => sum + Buffer.byteLength(textContent(message.content)), 0);
75
+ return bytes <= limit ? exchange : [];
76
+ }
77
+
78
+ function latestExchange(branch: readonly SessionEntry[]) {
68
79
  let end = -1;
69
80
  let legacyEnd = branch.length;
70
81
  const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' && entry.customType === REPORT_TYPE ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
package/src/usage.ts CHANGED
@@ -36,27 +36,27 @@ export function addUsage(total: Usage, usage: Usage) {
36
36
  /** One profile's ledger. Stable message IDs make resume/backfill idempotent. */
37
37
  export class UsageLedger {
38
38
  readonly entries: UsageEntry[] = [];
39
- readonly warnings: string[] = [];
39
+ private invalid = 0;
40
40
  private readonly ids = new Set<string>();
41
41
  private readonly cursors = new Map<string, number>();
42
42
  private readonly file: string;
43
43
  constructor(directory: string) {
44
44
  this.file = join(directory, 'usage.jsonl');
45
- let invalid = 0;
46
45
  const contents = existsSync(this.file) ? readFileSync(this.file, 'utf8') : '';
47
46
  for (const line of contents.split('\n')) {
48
47
  if (!line.trim()) continue;
49
48
  try {
50
49
  const entry: unknown = JSON.parse(line);
51
- if (!validEntry(entry)) { invalid++; continue; }
50
+ if (!validEntry(entry)) { this.invalid++; continue; }
52
51
  if (entry.id && this.ids.has(entry.id)) continue;
53
52
  this.entries.push(entry); if (entry.id) this.ids.add(entry.id);
54
- } catch { invalid++; }
53
+ } catch { this.invalid++; }
55
54
  }
56
- if (invalid) this.warnings.push(`${invalid} unreadable usage records excluded; totals may be incomplete.`);
57
55
  if (contents && !contents.endsWith('\n')) appendFileSync(this.file, '\n');
58
56
  }
57
+ get warnings() { return this.invalid ? [`${this.invalid} unreadable usage records excluded; totals may be incomplete.`] : []; }
59
58
  add(entry: UsageEntry) {
59
+ if (!validEntry(entry)) { this.invalid++; return; }
60
60
  if (entry.id && this.ids.has(entry.id)) return;
61
61
  appendJson(this.file, entry);
62
62
  this.entries.push(entry); if (entry.id) this.ids.add(entry.id);
@@ -86,7 +86,7 @@ export class UsageLedger {
86
86
  select(range: UsageRange, session: string, now = Date.now()) {
87
87
  const today = new Date(now); today.setHours(0, 0, 0, 0);
88
88
  const since = range === 'Last hour' ? now - 3600_000 : range === 'Today' ? today.getTime() : range === 'Last 7 days' ? now - 7 * 86400_000 : 0;
89
- return this.entries.filter(e => range === 'This session' ? e.session === session : Date.parse(e.date) >= since && Date.parse(e.date) <= now);
89
+ return this.entries.filter(e => range === 'This session' ? e.session === session : range === 'All time' || (Date.parse(e.date) >= since && Date.parse(e.date) <= now));
90
90
  }
91
91
  }
92
92
 
@@ -1,9 +1,9 @@
1
- import { chmodSync, existsSync, unlinkSync } from 'node:fs';
1
+ import { chmodSync } from 'node:fs';
2
2
  import { createConnection, createServer, type Socket } from 'node:net';
3
3
  import type { Children } from './agents.ts';
4
4
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
5
5
  import { record } from './cache.ts';
6
- import { profileSocket } from './profiles.ts';
6
+ import { checkSocketPath, profileSocket, removeStaleSocket } from './profiles.ts';
7
7
  import { textContent } from './transcript.ts';
8
8
  import { isActiveRun } from './runs.ts';
9
9
 
@@ -83,8 +83,8 @@ function wire(socket: Socket, receive: (frame: Frame) => void) {
83
83
  /** Call only while holding the profile lock. The owner hosts every child session. */
84
84
  export async function serveWindows(directory: string, children: Children, available: () => boolean,
85
85
  report: (text: string) => Promise<void>) {
86
- const path = profileSocket(directory, 'windows');
87
- if (existsSync(path)) unlinkSync(path); // A previous owner can no longer hold the profile lock.
86
+ const path = profileSocket(directory, 'windows'); checkSocketPath(path);
87
+ removeStaleSocket(path); // A previous owner can no longer hold the profile lock.
88
88
  const connections = new Set<Socket>();
89
89
  const work = new Set<Promise<void>>();
90
90
  let closing = false;