sideby 0.4.0 → 0.4.2

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/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261013T091101-030f9b"}
1
+ {"id":"20261013T095821-360786"}
@@ -312,6 +312,8 @@ function tuneChangeLines(plan, home) {
312
312
  ];
313
313
  if (plan.affected.length > 1)
314
314
  lines.push(` Careful: this file is shared, so it also changes ${plan.affected.slice(1).join(', ')}`);
315
+ if (plan.overrides.length)
316
+ lines.push(` Careful: these models save a size of their own, which wins over this one: ${plan.overrides.map((o) => `${o.model} (${compactNumber(o.value)})`).join(', ')}`);
315
317
  lines.push(' Sessions already running keep the old value; new ones use the new one. You can undo it.');
316
318
  return lines;
317
319
  }
@@ -83,7 +83,7 @@ const TUNING_REASON = {
83
83
  'already-best': 'already the cheapest setting we can estimate',
84
84
  'insufficient-calls': `too few calls to estimate (needs ${MIN_CALLS})`,
85
85
  'no-compactions': 'no compaction recorded to estimate from',
86
- 'no-optimum': 'the cheapest point is at the bottom of the range tried, so there is no clear best value',
86
+ 'no-optimum': 'the model window is below the lowest size Claude Code accepts (100K), so there is nothing to set',
87
87
  'unknown-model': 'not a Claude model, so the cost weights do not apply',
88
88
  'observe-only': 'observation only (no public default for this host)',
89
89
  };
@@ -93,7 +93,7 @@ export function tuningLines(t) {
93
93
  return [t.reason === 'no-source' ? 'no public source for this host' : (t.detail ?? t.reason)];
94
94
  return t.models.map((m) => {
95
95
  const now = m.configured === null ? 'Host default' : `${compactNumber(m.configured)}`;
96
- const head = `${m.model} ${m.calls} calls now ${now} ${m.compactions.total} compactions`;
96
+ const head = `${m.model} ${m.calls} calls now ${now} ${m.compactions.total} compactions${m.compactions.idle ? ` (${m.compactions.idle} after idle)` : ''}`;
97
97
  if (m.suggestion) {
98
98
  const saved = m.suggestion.savedPercent === null
99
99
  ? ''
@@ -115,6 +115,9 @@ const TuningModel = Type.Object({
115
115
  }),
116
116
  auto: Type.Number(),
117
117
  manual: Type.Number(),
118
+ idle: Type.Number({
119
+ description: 'Automatic compactions Claude Code did after the conversation sat idle; the size setting does not control them.',
120
+ }),
118
121
  preTokensP50: Type.Union([Type.Number(), Type.Null()]),
119
122
  preTokensMax: Type.Union([Type.Number(), Type.Null()]),
120
123
  }),
@@ -211,6 +214,7 @@ export const OUTPUT_SCHEMAS = {
211
214
  current: Type.Union([Type.Number(), Type.Null()]),
212
215
  next: Type.Union([Type.Number(), Type.Null()]),
213
216
  creates: Type.Boolean(),
217
+ overrides: Type.Optional(Type.Array(Type.Object({ model: Str, value: Type.Number() }))),
214
218
  reason: Type.Optional(Str),
215
219
  })),
216
220
  message: Type.Optional(Str),
@@ -1,7 +1,7 @@
1
1
  import type { Account, ReadContext } from '../types.ts';
2
2
  export declare const TUNE_KEY = "autoCompactWindow";
3
- export declare const MIN_WINDOW = 10000;
4
- export declare const MAX_WINDOW = 2000000;
3
+ export declare const MIN_WINDOW = 100000;
4
+ export declare const MAX_WINDOW = 1000000;
5
5
  export interface TuneApplyPlan {
6
6
  /** `ready`: will write; `unchanged`: already that value; `blocked`: `reason` says why and what to do. */
7
7
  status: 'ready' | 'unchanged' | 'blocked';
@@ -17,8 +17,14 @@ export interface TuneApplyPlan {
17
17
  next: number | null;
18
18
  /** The file does not exist yet and will be created. */
19
19
  creates: boolean;
20
+ /** Models that save a size of their own (`modelSettings`), which wins over this one for them. */
21
+ overrides: ModelOverride[];
20
22
  reason?: string;
21
23
  }
24
+ export interface ModelOverride {
25
+ model: string;
26
+ value: number;
27
+ }
22
28
  export interface TuneUndoRecord {
23
29
  file: string;
24
30
  /** The value before sideby wrote, or null when the key was not there. */
@@ -47,5 +53,11 @@ export declare function undoTune(account: Account, ctx: ReadContext): Promise<{
47
53
  }>;
48
54
  /** The real file an Account's setting lives in (canonical, so Accounts sharing it get the same answer). */
49
55
  export declare function settingsFileOf(account: Account): Promise<string>;
50
- /** The size in the Account's settings file now, or null when there is none (the default). Reads only that key. */
51
- export declare function currentTuneValue(account: Account): Promise<number | null>;
56
+ /**
57
+ * The size in the Account's settings file now (null: the default) and the sizes models save for themselves. Reads
58
+ * only those `autoCompactWindow` numbers.
59
+ */
60
+ export declare function readTuneSettings(account: Account): Promise<{
61
+ current: number | null;
62
+ overrides: ModelOverride[];
63
+ }>;
@@ -7,9 +7,10 @@ import { mkdir, readFile, rm, stat } from 'node:fs/promises';
7
7
  import { join } from 'node:path';
8
8
  import { UserError } from './errors.js';
9
9
  import { fileHash, lstatOrNull, realpathOrNull, sha256, writeFileAtomic, writeIfUnchanged, } from './fs-safe.js';
10
+ import { MAX_COMPACT_WINDOW, MIN_COMPACT_WINDOW } from './tuning.js';
10
11
  export const TUNE_KEY = 'autoCompactWindow';
11
- export const MIN_WINDOW = 10_000;
12
- export const MAX_WINDOW = 2_000_000;
12
+ export const MIN_WINDOW = MIN_COMPACT_WINDOW;
13
+ export const MAX_WINDOW = MAX_COMPACT_WINDOW;
13
14
  const MAX_BYTES = 5_000_000;
14
15
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
15
16
  function checkValue(value) {
@@ -47,6 +48,19 @@ async function readObject(file) {
47
48
  return { error: 'it is not a JSON object' };
48
49
  return { text, obj: parsed };
49
50
  }
51
+ /** The sizes models save for themselves under `modelSettings`: only those `autoCompactWindow` numbers are read. */
52
+ function overridesOf(obj) {
53
+ const ms = obj.modelSettings;
54
+ if (!isObj(ms))
55
+ return [];
56
+ const out = [];
57
+ for (const [model, entry] of Object.entries(ms)) {
58
+ const v = isObj(entry) ? entry[TUNE_KEY] : undefined;
59
+ if (typeof v === 'number' && Number.isFinite(v))
60
+ out.push({ model, value: v });
61
+ }
62
+ return out;
63
+ }
50
64
  /** Where the Account's settings really live, and whether `settings.json` is a link to it. */
51
65
  async function locate(account) {
52
66
  const path = join(account.dir, 'settings.json');
@@ -91,6 +105,7 @@ export async function planTuneApply(account, value, accounts) {
91
105
  current: null,
92
106
  next: value,
93
107
  creates: false,
108
+ overrides: [],
94
109
  };
95
110
  if (where.broken)
96
111
  return {
@@ -117,7 +132,13 @@ export async function planTuneApply(account, value, accounts) {
117
132
  if (loc.exists && loc.file === where.file)
118
133
  affected.push(other.ref);
119
134
  }
120
- return { ...base, affected, current, status: current === value ? 'unchanged' : 'ready' };
135
+ return {
136
+ ...base,
137
+ affected,
138
+ current,
139
+ overrides: overridesOf(read.obj),
140
+ status: current === value ? 'unchanged' : 'ready',
141
+ };
121
142
  }
122
143
  /** Plans, and writes only when the plan is `ready`. */
123
144
  export async function applyTune(account, value, accounts, ctx) {
@@ -224,14 +245,20 @@ export async function undoTune(account, ctx) {
224
245
  export async function settingsFileOf(account) {
225
246
  return (await locate(account)).file;
226
247
  }
227
- /** The size in the Account's settings file now, or null when there is none (the default). Reads only that key. */
228
- export async function currentTuneValue(account) {
248
+ /**
249
+ * The size in the Account's settings file now (null: the default) and the sizes models save for themselves. Reads
250
+ * only those `autoCompactWindow` numbers.
251
+ */
252
+ export async function readTuneSettings(account) {
229
253
  const where = await locate(account);
230
254
  if (!where.exists)
231
- return null;
255
+ return { current: null, overrides: [] };
232
256
  const read = await readObject(where.file);
233
257
  if ('error' in read)
234
- return null;
258
+ return { current: null, overrides: [] };
235
259
  const v = read.obj[TUNE_KEY];
236
- return typeof v === 'number' && Number.isFinite(v) ? v : null;
260
+ return {
261
+ current: typeof v === 'number' && Number.isFinite(v) ? v : null,
262
+ overrides: overridesOf(read.obj),
263
+ };
237
264
  }
@@ -7,6 +7,26 @@ export declare const WEIGHT: {
7
7
  readonly write1h: 2;
8
8
  readonly output: 5;
9
9
  };
10
+ /**
11
+ * The sizes Claude Code accepts for the auto-compact window: 100K to 1M tokens, never above the model's context
12
+ * window (https://code.claude.com/docs/en/model-config#set-the-auto-compact-window).
13
+ */
14
+ export declare const MIN_COMPACT_WINDOW = 100000;
15
+ export declare const MAX_COMPACT_WINDOW = 1000000;
16
+ /**
17
+ * Claude Code compacts a conversation on its own when it has sat idle for about 90% of the one-hour prompt cache
18
+ * and holds at least 200K tokens (read from Claude Code 2.1.296 and matched against real logs: 30 of 31 automatic
19
+ * compactions came after 54-55 minutes of silence and none was smaller than 202K). `autoCompactWindow` does not
20
+ * control it. A compaction after this much silence is recorded as `idle`.
21
+ */
22
+ export declare const IDLE_AFTER_MS: number;
23
+ export declare const IDLE_MIN_CTX = 200000;
24
+ /**
25
+ * How long a silence must be to call a logged compaction `idle`. Deliberately a little below `IDLE_AFTER_MS`, since the log
26
+ * only has the timestamp of the last message. Replaying with 45, 50 or 54 minutes gave the same simulated counts on real
27
+ * logs, so the difference does not matter; why the replay finds fewer idle compactions than were logged is not this.
28
+ */
29
+ export declare const IDLE_OBSERVED_MS: number;
10
30
  /** A model with fewer turns than this in the window gets no suggestion. */
11
31
  export declare const MIN_CALLS = 200;
12
32
  /** Candidate thresholds as a share of the model's context window. */
@@ -24,6 +44,8 @@ export interface Turn {
24
44
  export interface Compaction {
25
45
  ts: number;
26
46
  trigger: 'auto' | 'manual' | 'unknown';
47
+ /** Claude Code did it by itself after the conversation sat idle (not because a size was reached). */
48
+ idle?: boolean;
27
49
  pre: number;
28
50
  post: number;
29
51
  }
@@ -33,7 +55,11 @@ export interface ModelRecords {
33
55
  sessions: Turn[][];
34
56
  compactions: Compaction[];
35
57
  contextWindow: number;
58
+ /** Where the Host compacts when nothing is set (documented), or null when that is not known. */
59
+ defaultThreshold?: number | null;
36
60
  }
61
+ /** Weighted cost of the sessions at a threshold. Exported for the tests of the engine. */
62
+ export declare function replay(sessions: Turn[][], threshold: number, post: number, idle: boolean): number;
37
63
  export interface SummarizeOptions {
38
64
  /** The Account's configured threshold in tokens; null is the Host default. */
39
65
  configured: number | null;
@@ -77,6 +103,13 @@ export interface TuneEntry {
77
103
  }
78
104
  /** The Accounts that share one settings file: they get one suggestion and one thing to apply. */
79
105
  export interface TuneGroup {
106
+ /** The size in the file is outside what Claude Code accepts, so it cannot be offered again. */
107
+ outOfRange: boolean;
108
+ /** Models that save an auto-compact size of their own, which wins over the file's size for them. */
109
+ overrides: {
110
+ model: string;
111
+ value: number;
112
+ }[];
80
113
  /** The real settings file. */
81
114
  file: string;
82
115
  members: string[];
@@ -1,5 +1,25 @@
1
1
  /** Relative cost of a token, input = 1. These are Anthropic's published multipliers, not prices (spec §3.18). */
2
2
  export const WEIGHT = { input: 1, cacheRead: 0.1, write5m: 1.25, write1h: 2, output: 5 };
3
+ /**
4
+ * The sizes Claude Code accepts for the auto-compact window: 100K to 1M tokens, never above the model's context
5
+ * window (https://code.claude.com/docs/en/model-config#set-the-auto-compact-window).
6
+ */
7
+ export const MIN_COMPACT_WINDOW = 100_000;
8
+ export const MAX_COMPACT_WINDOW = 1_000_000;
9
+ /**
10
+ * Claude Code compacts a conversation on its own when it has sat idle for about 90% of the one-hour prompt cache
11
+ * and holds at least 200K tokens (read from Claude Code 2.1.296 and matched against real logs: 30 of 31 automatic
12
+ * compactions came after 54-55 minutes of silence and none was smaller than 202K). `autoCompactWindow` does not
13
+ * control it. A compaction after this much silence is recorded as `idle`.
14
+ */
15
+ export const IDLE_AFTER_MS = 54 * 60_000;
16
+ export const IDLE_MIN_CTX = 200_000;
17
+ /**
18
+ * How long a silence must be to call a logged compaction `idle`. Deliberately a little below `IDLE_AFTER_MS`, since the log
19
+ * only has the timestamp of the last message. Replaying with 45, 50 or 54 minutes gave the same simulated counts on real
20
+ * logs, so the difference does not matter; why the replay finds fewer idle compactions than were logged is not this.
21
+ */
22
+ export const IDLE_OBSERVED_MS = 50 * 60_000;
3
23
  /** A model with fewer turns than this in the window gets no suggestion. */
4
24
  export const MIN_CALLS = 200;
5
25
  /** Candidate thresholds as a share of the model's context window. */
@@ -20,7 +40,7 @@ function writeWeight(t) {
20
40
  return n > 0 ? (t.write5m * WEIGHT.write5m + t.write1h * WEIGHT.write1h) / n : WEIGHT.write5m;
21
41
  }
22
42
  /** Weighted cost of one session's turns if the Host compacted whenever the context reached `threshold`. */
23
- function replaySession(turns, threshold, post) {
43
+ function replaySession(turns, threshold, post, idle) {
24
44
  const first = turns[0];
25
45
  if (!first)
26
46
  return 0;
@@ -41,7 +61,9 @@ function replaySession(turns, threshold, post) {
41
61
  continue;
42
62
  const d = t.ctx - prev.ctx;
43
63
  const g = d >= 0 ? d : usual;
44
- if (ctx + g >= threshold) {
64
+ // Long silence with a big context: Claude Code compacts by itself, whatever the threshold is.
65
+ const lapsed = idle && t.ts - prev.ts >= IDLE_AFTER_MS && ctx >= IDLE_MIN_CTX;
66
+ if (lapsed || ctx + g >= threshold) {
45
67
  // The whole context goes in as input, a summary of about `post` tokens comes out, and the next turn
46
68
  // writes that summary to the cache again.
47
69
  cost += ctx * WEIGHT.input + post * WEIGHT.output + post * WEIGHT.write5m;
@@ -55,10 +77,11 @@ function replaySession(turns, threshold, post) {
55
77
  }
56
78
  return cost;
57
79
  }
58
- function replay(sessions, threshold, post) {
80
+ /** Weighted cost of the sessions at a threshold. Exported for the tests of the engine. */
81
+ export function replay(sessions, threshold, post, idle) {
59
82
  let total = 0;
60
83
  for (const s of sessions)
61
- total += replaySession(s, threshold, post);
84
+ total += replaySession(s, threshold, post, idle);
62
85
  return total;
63
86
  }
64
87
  const roundTokens = (n) => Math.round(n / 1000) * 1000;
@@ -67,8 +90,21 @@ export function summarizeModel(rec, o) {
67
90
  const auto = rec.compactions.filter((c) => c.trigger === 'auto');
68
91
  const pre = rec.compactions.map((c) => c.pre).filter((n) => n > 0);
69
92
  const posts = rec.compactions.map((c) => c.post).filter((n) => n > 0);
70
- const autoPre = auto.map((c) => c.pre).filter((n) => n > 0);
71
- const effective = o.configured !== null ? Math.min(o.configured, rec.contextWindow) : median(autoPre);
93
+ // Threshold-triggered compactions only: the idle ones say nothing about the size Claude Code was set to.
94
+ const autoPre = auto
95
+ .filter((c) => !c.idle)
96
+ .map((c) => c.pre)
97
+ .filter((n) => n > 0);
98
+ const idleCount = auto.filter((c) => c.idle).length;
99
+ // Replay idle compaction too once the records show Claude Code doing it for this Account.
100
+ const idleMode = idleCount > 0;
101
+ // What is in effect: the configured size, else where Claude Code compacts by default, else what it did.
102
+ // A size outside 100K-1M is not one Claude Code runs with (the documentation does not say what it does with it),
103
+ // so it is not taken as the baseline.
104
+ const usable = o.configured !== null && o.configured >= MIN_COMPACT_WINDOW && o.configured <= MAX_COMPACT_WINDOW;
105
+ const effective = usable && o.configured !== null
106
+ ? Math.min(o.configured, rec.contextWindow)
107
+ : (rec.defaultThreshold ?? median(autoPre));
72
108
  const base = {
73
109
  model: rec.model,
74
110
  calls,
@@ -79,6 +115,7 @@ export function summarizeModel(rec, o) {
79
115
  total: rec.compactions.length,
80
116
  auto: auto.length,
81
117
  manual: rec.compactions.filter((c) => c.trigger === 'manual').length,
118
+ idle: idleCount,
82
119
  preTokensP50: pre.length ? Math.round(median(pre)) : null,
83
120
  preTokensMax: pre.length ? Math.max(...pre) : null,
84
121
  },
@@ -96,27 +133,31 @@ export function summarizeModel(rec, o) {
96
133
  const post = median(posts);
97
134
  if (post === null)
98
135
  return stop('no-compactions');
136
+ // Only sizes Claude Code accepts: the lowest allowed one is always a candidate.
137
+ const top = Math.min(rec.contextWindow, MAX_COMPACT_WINDOW);
138
+ const candidates = new Set([MIN_COMPACT_WINDOW]);
139
+ for (const pct of CANDIDATE_PERCENTS)
140
+ candidates.add(roundTokens((rec.contextWindow * pct) / 100));
141
+ const sizes = [...candidates].filter((t) => t >= MIN_COMPACT_WINDOW && t <= top).sort((x, y) => x - y);
142
+ if (!sizes.length)
143
+ return stop('no-optimum');
99
144
  let best = null;
100
- for (const pct of CANDIDATE_PERCENTS) {
101
- const threshold = roundTokens((rec.contextWindow * pct) / 100);
102
- const cost = replay(rec.sessions, threshold, post);
145
+ for (const threshold of sizes) {
146
+ const cost = replay(rec.sessions, threshold, post, idleMode);
103
147
  // On a tie the larger threshold wins: fewer compactions for the same cost.
104
148
  if (!best || cost < best.cost || (cost === best.cost && threshold > best.threshold))
105
149
  best = { threshold, cost };
106
150
  }
107
151
  if (!best)
108
152
  return stop('insufficient-calls');
109
- // A best value at the lowest candidate means the cheapest point may lie below the range that was replayed, so it
110
- // is a cut-off of the search, not an optimum, and is not suggested.
111
- if (best.threshold === roundTokens((rec.contextWindow * CANDIDATE_PERCENTS[0]) / 100))
112
- return stop('no-optimum');
153
+ // The lowest allowed size can still be the cheapest: that is a real answer, "as low as Claude Code lets you".
113
154
  if (effective === null)
114
155
  return {
115
156
  ...base,
116
157
  reason: 'suggested',
117
158
  suggestion: { threshold: best.threshold, savedPercent: null, savedWeighted: null },
118
159
  };
119
- const current = replay(rec.sessions, effective, post);
160
+ const current = replay(rec.sessions, effective, post, idleMode);
120
161
  const saved = current - best.cost;
121
162
  const percent = current > 0 ? (saved / current) * 100 : 0;
122
163
  if (percent < MIN_SAVING_PERCENT)
@@ -4,8 +4,8 @@ import type { Account, ReadContext, TuningResult } from '../../types.ts';
4
4
  export declare const isKnownClaudeModel: (id: string) => boolean;
5
5
  /** `[key, ts, session, model, ctx, out, write5m, write1h]` */
6
6
  type TurnRow = [string, number, string, string, number, number, number, number];
7
- /** `[ts, session, model, trigger, pre, post]` */
8
- type CompactionRow = [number, string, string, string, number, number];
7
+ /** `[ts, session, model, trigger, pre, post, silentMs]`; `silentMs` is how long the session was quiet before it (-1 unknown). */
8
+ type CompactionRow = [number, string, string, string, number, number, number];
9
9
  /** What one session log contributes: only what the replay needs, never message text. */
10
10
  interface Summary {
11
11
  lines: number;
@@ -7,13 +7,19 @@ import { join } from 'node:path';
7
7
  import { createInterface } from 'node:readline';
8
8
  import { FileMemo } from '../../core/file-memo.js';
9
9
  import { USAGE_DAYS } from '../../core/quota-levels.js';
10
- import { getOrCreate, tuningResult, } from '../../core/tuning.js';
10
+ import { getOrCreate, IDLE_OBSERVED_MS, tuningResult, } from '../../core/tuning.js';
11
11
  import { listJsonl } from './usage.js';
12
12
  const DAY_MS = 86_400_000;
13
13
  const SETTINGS_MAX_BYTES = 1_000_000;
14
14
  /** Context window of a model, from the table or the records: a context that grew past 200K is a 1M window. */
15
15
  export const isKnownClaudeModel = (id) => id.startsWith('claude-');
16
16
  const windowOf = (maxCtx) => (maxCtx > 200_000 ? 1_000_000 : 200_000);
17
+ /**
18
+ * Models with a native 1M window, which compact at about 967K when nothing is set: the Fable models, Sonnet 5 and
19
+ * later, Haiku 5.5 and Opus 4.7 and later (https://code.claude.com/docs/en/model-config).
20
+ */
21
+ const NATIVE_1M = /^claude-(fable|sonnet-[5-9]|haiku-[5-9]|opus-(4-[7-9]|[5-9]))/;
22
+ const NATIVE_DEFAULT_THRESHOLD = 967_000;
17
23
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
18
24
  async function parseFile(file, since) {
19
25
  const out = { lines: 0, parsed: 0, turns: [], compactions: [], failed: false };
@@ -21,6 +27,8 @@ async function parseFile(file, since) {
21
27
  const lastModel = new Map();
22
28
  // Compactions logged before the first assistant row of their session in this file: they take that row's model.
23
29
  const orphans = new Map();
30
+ // When each session last spoke, to tell a compaction that came after a long silence from one a size triggered.
31
+ const lastSpoke = new Map();
24
32
  try {
25
33
  const rl = createInterface({ input: createReadStream(file), crlfDelay: Number.POSITIVE_INFINITY });
26
34
  for await (const line of rl) {
@@ -44,6 +52,7 @@ async function parseFile(file, since) {
44
52
  if (!Number.isFinite(ts) || ts < since || !pre || !post)
45
53
  continue;
46
54
  const t = m?.trigger;
55
+ const before = lastSpoke.get(session);
47
56
  const entry = [
48
57
  ts,
49
58
  session,
@@ -51,12 +60,19 @@ async function parseFile(file, since) {
51
60
  t === 'auto' || t === 'manual' ? t : 'unknown',
52
61
  pre,
53
62
  post,
63
+ before === undefined ? -1 : ts - before,
54
64
  ];
55
65
  out.compactions.push(entry);
66
+ // The compaction itself is activity: a second one right after it did not follow a silence of its own.
67
+ lastSpoke.set(session, ts);
56
68
  if (!entry[2])
57
69
  orphans.set(session, [...(orphans.get(session) ?? []), entry]);
58
70
  continue;
59
71
  }
72
+ if ((row?.type === 'assistant' || row?.type === 'user') &&
73
+ row.isSidechain !== true &&
74
+ Number.isFinite(ts))
75
+ lastSpoke.set(session, ts);
60
76
  if (row?.type !== 'assistant' || row.isSidechain === true)
61
77
  continue;
62
78
  const usage = row.message?.usage;
@@ -95,7 +111,7 @@ async function parseFile(file, since) {
95
111
  return out;
96
112
  }
97
113
  /** Per-file summaries per Account directory; a file is parsed again only after it changed. */
98
- export const claudeTuningMemo = new FileMemo({ name: 'claude-tuning', version: 2 });
114
+ export const claudeTuningMemo = new FileMemo({ name: 'claude-tuning', version: 3 });
99
115
  /** The Account's configured Compaction Threshold, or null for the Host default. Reads exactly one key. */
100
116
  export async function readAutoCompactWindow(dir) {
101
117
  try {
@@ -157,7 +173,7 @@ export async function readClaudeTuning(account, ctx) {
157
173
  const sessionModel = new Map();
158
174
  for (const t of [...turns.values()].sort((x, y) => x[1] - y[1]))
159
175
  sessionModel.set(t[2], t[3]);
160
- const resolved = compactions.map((c) => (c[2] ? c : [c[0], c[1], sessionModel.get(c[1]) ?? '', c[3], c[4], c[5]]));
176
+ const resolved = compactions.map((c) => (c[2] ? c : [c[0], c[1], sessionModel.get(c[1]) ?? '', c[3], c[4], c[5], c[6]]));
161
177
  // Group per model, then per session; a session that switched models is replayed once per model.
162
178
  const byModel = new Map();
163
179
  for (const [, ts, session, model, c, out, w5, w1] of turns.values()) {
@@ -179,9 +195,25 @@ export async function readClaudeTuning(account, ctx) {
179
195
  const maxCtx = Math.max(0, ...list.flat().map((t) => t.ctx));
180
196
  const comps = resolved
181
197
  .filter((c) => c[2] === model && c[0] >= since && c[0] <= now)
182
- .map((c) => ({ ts: c[0], trigger: c[3], pre: c[4], post: c[5] }));
183
- const window = Math.max(windowOf(maxCtx), windowOf(Math.max(0, ...comps.map((c) => c.pre))));
184
- return { model, sessions: list, compactions: comps, contextWindow: window };
198
+ .map((c) => ({
199
+ ts: c[0],
200
+ trigger: c[3],
201
+ pre: c[4],
202
+ post: c[5],
203
+ idle: c[3] === 'auto' && c[6] >= IDLE_OBSERVED_MS,
204
+ }));
205
+ const native = NATIVE_1M.test(model);
206
+ // Elsewhere the window comes from the records: a context past 200K means the model runs with 1M.
207
+ const window = native
208
+ ? 1_000_000
209
+ : Math.max(windowOf(maxCtx), windowOf(Math.max(0, ...comps.map((c) => c.pre))));
210
+ return {
211
+ model,
212
+ sessions: list,
213
+ compactions: comps,
214
+ contextWindow: window,
215
+ defaultThreshold: native ? NATIVE_DEFAULT_THRESHOLD : window,
216
+ };
185
217
  });
186
218
  return tuningResult(USAGE_DAYS, records, { configured, known: isKnownClaudeModel, mayRecommend: true });
187
219
  }
@@ -251,6 +251,9 @@ declare const en: {
251
251
  'tune.noCompactions': string;
252
252
  'tune.groupShared': string;
253
253
  'tune.basis': string;
254
+ 'tune.outOfRange': string;
255
+ 'tune.overrides': string;
256
+ 'tune.idle': string;
254
257
  'tune.groupNone': string;
255
258
  'tune.details': string;
256
259
  'tune.detailsObserved': string;
@@ -253,6 +253,9 @@ const en = {
253
253
  'tune.noCompactions': 'It did not compact in this period.',
254
254
  'tune.groupShared': 'One setting for these accounts, because they share a settings file: {list}',
255
255
  'tune.basis': 'Based on {model} on {ref}, the one used most.',
256
+ 'tune.outOfRange': 'The size now in the file ({size}) is outside what Claude Code accepts (100K to 1M). Pick Default or a size from the menu to replace it.',
257
+ 'tune.overrides': 'These models save a size of their own (set with /autocompact), which wins over this one: {list}',
258
+ 'tune.idle': '{n} of its {total} compactions came after the conversation sat idle for about an hour. Claude Code does that by itself, and this size does not change it.',
256
259
  'tune.groupNone': 'Not used enough to suggest a size yet. You can still pick one below.',
257
260
  'tune.details': 'Details for each account',
258
261
  'tune.detailsObserved': 'Only observed (there is no setting to change)',
@@ -277,7 +280,7 @@ const en = {
277
280
  'tune.reason.already-best': 'The current setting is already about the cheapest we can estimate for how you work.',
278
281
  'tune.reason.insufficient-calls': 'Not enough use in this period to estimate (needs {min} calls).',
279
282
  'tune.reason.no-compactions': 'It has not compacted in this period, so there is nothing to estimate from.',
280
- 'tune.reason.no-optimum': 'No clear best value: the cheapest point is outside the range we tried.',
283
+ 'tune.reason.no-optimum': "This model's window is smaller than the lowest size Claude Code accepts (100K), so there is nothing to set.",
281
284
  'tune.reason.unknown-model': 'This is not a Claude model, so sideby cannot estimate it.',
282
285
  'tune.reason.observe-only': 'For Codex sideby only shows what happened; it cannot suggest a value yet.',
283
286
  'tpage.title': 'Context tuning',
@@ -632,6 +635,9 @@ const zh = {
632
635
  'tune.noCompactions': '这段时间没有压缩过。',
633
636
  'tune.groupShared': '这些账号共用一个设置文件,所以用同一个设置:{list}',
634
637
  'tune.basis': '按用得最多的 {model}({ref})来定。',
638
+ 'tune.outOfRange': '文件里现在的大小({size})超出了 Claude Code 接受的范围(100K 到 1M)。请选「默认」或菜单里的某个大小来替换它。',
639
+ 'tune.overrides': '这些模型自己保存了一个大小(用 /autocompact 设的),对它们来说那个优先于这里的设置:{list}',
640
+ 'tune.idle': '它的 {total} 次压缩里有 {n} 次是对话安静了大约一小时之后发生的。这是 Claude Code 自己做的,这个大小改不了它。',
635
641
  'tune.groupNone': '用得还不够多,暂时给不出建议值。你仍然可以在下面自己选一个。',
636
642
  'tune.details': '各账号的详情',
637
643
  'tune.detailsObserved': '只做观测(没有可以改的设置)',
@@ -656,7 +662,7 @@ const zh = {
656
662
  'tune.reason.already-best': '按你的用法,现在的设置已经差不多是我们能估算出的最省了。',
657
663
  'tune.reason.insufficient-calls': '这段时间用得太少,估不准(至少要 {min} 次)。',
658
664
  'tune.reason.no-compactions': '这段时间还没压缩过,没有可以参考的数据。',
659
- 'tune.reason.no-optimum': '没有明确的最佳值:最省的点在我们试的范围之外。',
665
+ 'tune.reason.no-optimum': '这个模型的窗口比 Claude Code 接受的最小值(100K)还小,没有可以设置的值。',
660
666
  'tune.reason.unknown-model': '这不是 Claude 的模型,sideby 没法估算。',
661
667
  'tune.reason.observe-only': 'Codex 目前只能看到发生了什么,还不能给建议值。',
662
668
  'tpage.title': '上下文调优',
@@ -1315,6 +1315,7 @@ const MAIN = String.raw `
1315
1315
  h('li', null, t('tune.change') + ': ' + tuneSize(plan.current) + ' → ' + tuneSize(plan.next)),
1316
1316
  h('li', null, t('tune.file') + ': ', h('code', null, plan.file + (plan.creates ? ' ' + t('tune.willCreate') : '')))),
1317
1317
  plan.affected.length > 1 ? h('p', { class: 'note note-warn' }, t('tune.shared', { list: plan.affected.join(', ') })) : null,
1318
+ plan.overrides && plan.overrides.length ? h('p', { class: 'note note-warn' }, t('tune.overrides', { list: plan.overrides.map((o) => o.model + ' (' + tokens(o.value) + ')').join(', ') })) : null,
1318
1319
  h('p', { class: 'muted small' }, t('tune.newSessions')),
1319
1320
  h('div', { class: 'row-actions' },
1320
1321
  h('button', { class: 'btn btn-primary btn-sm', type: 'button', onclick: useConfirm }, t('tune.confirm')),
@@ -1323,12 +1324,12 @@ const MAIN = String.raw `
1323
1324
  // The card for one settings file.
1324
1325
  function tuneGroupCard(g, entries) {
1325
1326
  const pick = g.pick
1326
- const sizes = new Set([50000, 80000, 100000, 120000, 160000, 200000, 300000, 500000, 800000])
1327
+ const sizes = new Set([100000, 150000, 200000, 300000, 500000, 800000, 1000000])
1327
1328
  let top = 200000
1328
1329
  for (const e of entries) if (e.tuning.status === 'ok') for (const m of e.tuning.models) top = Math.max(top, m.contextWindow)
1329
1330
  for (const v of [...sizes]) if (v > top) sizes.delete(v)
1330
1331
  if (pick) sizes.add(pick.threshold)
1331
- if (g.current) sizes.add(g.current)
1332
+ if (g.current && !g.outOfRange) sizes.add(g.current)
1332
1333
  const key = (v) => (v === null ? 'default' : String(v))
1333
1334
  const chosen = tuneSel[g.id] !== undefined ? tuneSel[g.id] : key(g.current)
1334
1335
  const sel = h('select', { 'aria-label': t('tune.setting') },
@@ -1346,7 +1347,17 @@ const MAIN = String.raw `
1346
1347
  pick ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'tune-num tune-best' }, tokens(pick.threshold))) : null))
1347
1348
  if (pick && pick.savedPercent !== null) rows.push(h('p', { class: 'small' }, t('tune.saved', { n: Math.round(pick.savedPercent) })))
1348
1349
  if (pick) rows.push(h('p', { class: 'muted small' }, t('tune.basis', { model: pick.model, ref: pick.ref })))
1349
- else rows.push(h('p', { class: 'note' }, t('tune.groupNone')))
1350
+ if (pick) {
1351
+ const seen = entries.find((e) => e.ref === pick.ref)
1352
+ const stats = seen && seen.tuning.status === 'ok' ? seen.tuning.models.find((m) => m.model === pick.model) : null
1353
+ if (stats && stats.compactions.idle > 0) rows.push(h('p', { class: 'muted small' }, t('tune.idle', { n: stats.compactions.idle, total: stats.compactions.total })))
1354
+ } else {
1355
+ // Say why there is no suggestion: when a model is already at its cheapest, that is the news, not "not enough use".
1356
+ const why = entries.flatMap((e) => (e.tuning.status === 'ok' ? e.tuning.models : [])).some((m) => m.reason === 'already-best') ? t('tune.reason.already-best') : t('tune.groupNone')
1357
+ rows.push(h('p', { class: 'note' }, why))
1358
+ }
1359
+ if (g.overrides && g.overrides.length) rows.push(h('p', { class: 'note note-warn' }, t('tune.overrides', { list: g.overrides.map((o) => o.model + ' (' + tokens(o.value) + ')').join(', ') })))
1360
+ if (g.outOfRange) rows.push(h('p', { class: 'note note-warn' }, t('tune.outOfRange', { size: tokens(g.current) })))
1350
1361
  if (!BOOT.readOnly) {
1351
1362
  if (pick && !asking) rows.push(h('div', { class: 'row-actions' }, h('button', { class: 'btn btn-primary', type: 'button', onclick: () => useAsk(g, pick.threshold) }, t('tune.use', { size: tokens(pick.threshold) }))))
1352
1363
  rows.push(h('div', { class: 'tune-setting' },
@@ -1369,6 +1380,7 @@ const MAIN = String.raw `
1369
1380
  m.suggestion ? h('div', null, h('span', { class: 'muted small' }, t('tune.best')), h('div', { class: 'small' }, tokens(m.suggestion.threshold))) : null))
1370
1381
  if (!m.suggestion) rows.push(h('p', { class: 'muted small' }, t('tune.reason.' + m.reason, { min: minCalls })))
1371
1382
  rows.push(h('p', { class: 'muted small' }, m.compactions.total ? tn('tune.compactions', m.compactions.total, { size: tokens(m.compactions.preTokensP50 || 0) }) : t('tune.noCompactions')))
1383
+ if (m.compactions.idle > 0) rows.push(h('p', { class: 'muted small' }, t('tune.idle', { n: m.compactions.idle, total: m.compactions.total })))
1372
1384
  return h('div', { class: 'tune-detail' }, rows)
1373
1385
  }
1374
1386
  function tuneDetails(entries, minCalls, label) {
@@ -15,8 +15,8 @@ import { resolvePaths } from './core/paths.js';
15
15
  import { findResumeRoute } from './core/resume.js';
16
16
  import { shareMeaning } from './core/share-meaning.js';
17
17
  import { SHELLS, shellInitScript, writeShellInitFile, } from './core/shell-init.js';
18
- import { groupSuggestion } from './core/tuning.js';
19
- import { applyTune, currentTuneValue, planTuneApply, settingsFileOf, tuneUndoInfo, undoTune, } from './core/tuning-apply.js';
18
+ import { groupSuggestion, MAX_COMPACT_WINDOW, MIN_COMPACT_WINDOW, } from './core/tuning.js';
19
+ import { applyTune, planTuneApply, readTuneSettings, settingsFileOf, tuneUndoInfo, undoTune, } from './core/tuning-apply.js';
20
20
  import { packageVersion } from './core/version.js';
21
21
  import { BUILTIN_PLUGINS } from './families/index.js';
22
22
  import { HookBus } from './plugins/bus.js';
@@ -492,7 +492,7 @@ export async function createRuntime(opts = {}) {
492
492
  for (const m of members)
493
493
  list.push(await entryOf(m));
494
494
  const pick = groupSuggestion(list);
495
- const current = await currentTuneValue(members[0]);
495
+ const { current, overrides } = await readTuneSettings(members[0]);
496
496
  const target = members.find((m) => m.ref === pick?.ref) ?? members[0];
497
497
  const undo = await tuneUndoInfo(target, readContext());
498
498
  groups.push({
@@ -500,6 +500,8 @@ export async function createRuntime(opts = {}) {
500
500
  members: members.map((m) => m.ref),
501
501
  target: target.ref,
502
502
  current,
503
+ outOfRange: current !== null && (current < MIN_COMPACT_WINDOW || current > MAX_COMPACT_WINDOW),
504
+ overrides,
503
505
  pick,
504
506
  undo: undo ? { value: undo.applied, previous: undo.previous } : null,
505
507
  });
@@ -261,6 +261,8 @@ export interface TuningModel {
261
261
  total: number;
262
262
  auto: number;
263
263
  manual: number;
264
+ /** Automatic compactions Claude Code did after the conversation sat idle; the size setting does not control them. */
265
+ idle: number;
264
266
  preTokensP50: number | null;
265
267
  preTokensMax: number | null;
266
268
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideby",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Run work, personal and API-key accounts at the same time, without their configs drifting apart.",
5
5
  "license": "MIT",
6
6
  "author": "Adonis0123 (https://github.com/Adonis0123)",
@@ -95,6 +95,24 @@
95
95
  "creates": {
96
96
  "type": "boolean"
97
97
  },
98
+ "overrides": {
99
+ "type": "array",
100
+ "items": {
101
+ "type": "object",
102
+ "required": [
103
+ "model",
104
+ "value"
105
+ ],
106
+ "properties": {
107
+ "model": {
108
+ "type": "string"
109
+ },
110
+ "value": {
111
+ "type": "number"
112
+ }
113
+ }
114
+ }
115
+ },
98
116
  "reason": {
99
117
  "type": "string"
100
118
  }
package/schemas/tune.json CHANGED
@@ -100,6 +100,7 @@
100
100
  "total",
101
101
  "auto",
102
102
  "manual",
103
+ "idle",
103
104
  "preTokensP50",
104
105
  "preTokensMax"
105
106
  ],
@@ -114,6 +115,10 @@
114
115
  "manual": {
115
116
  "type": "number"
116
117
  },
118
+ "idle": {
119
+ "type": "number",
120
+ "description": "Automatic compactions Claude Code did after the conversation sat idle; the size setting does not control them."
121
+ },
117
122
  "preTokensP50": {
118
123
  "anyOf": [
119
124
  {
@@ -2,7 +2,7 @@
2
2
  name: sideby
3
3
  description: Use when the user runs, or wants to run, several AI coding accounts (Claude Code, Codex, Grok Build, pi) side by side. Covers installing sideby and setting up a new account ("install sideby", "set up another Claude account", "add cc008", "配置一个 cc00x", "再加一个 Codex 账号"), explaining what accounts share and what stays separate ("what do my accounts share?", "这些账号共享了什么"), showing which account a session is ("show [cc001] in the status line", "加上账号标识"), seeing accounts, quota and usage, moving on when one hits its 5-hour or weekly limit, adding a short command (alias), and repairing drifted account directories (skills, hooks, rules or settings not shared, wrong symlinks, credential file permissions). Drives the `sideby` CLI with `--json`; the agent does every step except browser sign-in, filling in secrets and starting interactive sessions, which it hands to the user as one command.
4
4
  metadata:
5
- version: "0.4.0"
5
+ version: "0.4.2"
6
6
  requires:
7
7
  bins: [sideby, node]
8
8
  cliHelp: sideby --help
@@ -48,4 +48,4 @@ Quota is only as fresh as each account's last session; `observedAt` says when it
48
48
 
49
49
  ## Context tuning
50
50
 
51
- `sideby tune [ref|family] --json` estimates, per model, the auto-compact threshold that spends the fewest tokens, from the account's last 7 days of local sessions. It is read-only and an estimate: each model has `reason` (`suggested` carries `suggestion.threshold`; the others say why there is none: `insufficient-calls`, `no-compactions`, `no-optimum` (the cheapest point is at the bottom of the range tried, so there is no clear best), `unknown-model`, `already-best`, `observe-only`). Only Claude gets suggestions; Codex is observation only; Grok and pi return `no-source`. The analysis changes nothing. To use a suggestion run `sideby tune apply <ref> [tokens]`: it prints the file, the change and every account sharing that file (a linked `settings.json` is one shared file) and exits 10. Show that to the user in plain words, and only after a yes rerun with `--yes`. `sideby tune undo <ref> --yes` puts the old value back. Do not hand-edit `settings.json` for the user.
51
+ `sideby tune [ref|family] --json` estimates, per model, the auto-compact threshold that spends the fewest tokens, from the account's last 7 days of local sessions. It is read-only and an estimate: each model has `reason` (`suggested` carries `suggestion.threshold`; the others say why there is none: `insufficient-calls`, `no-compactions`, `no-optimum` (the model window is below 100K, the lowest size Claude Code accepts), `unknown-model`, `already-best`, `observe-only`). Only Claude gets suggestions; Codex is observation only; Grok and pi return `no-source`. The analysis changes nothing. To use a suggestion run `sideby tune apply <ref> [tokens]`: it prints the file, the change and every account sharing that file (a linked `settings.json` is one shared file) and exits 10. Show that to the user in plain words, and only after a yes rerun with `--yes`. `sideby tune undo <ref> --yes` puts the old value back. Do not hand-edit `settings.json` for the user.