blyuecode 0.3.4 → 0.3.6

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.
@@ -36,9 +36,40 @@ export class Agent {
36
36
  this.messages = [{ role: 'system', content: SYSTEM_PROMPT }];
37
37
  clearSession();
38
38
  }
39
+ estimateTokens() {
40
+ let chars = 0;
41
+ for (const m of this.messages) {
42
+ if (typeof m.content === 'string')
43
+ chars += m.content.length;
44
+ if (m.tool_calls)
45
+ chars += JSON.stringify(m.tool_calls).length;
46
+ }
47
+ return Math.ceil(chars / 4);
48
+ }
49
+ async maybeAutoCompact() {
50
+ if (process.env.BLYUE_NO_AUTO_COMPACT === '1')
51
+ return null;
52
+ const limit = Number(process.env.BLYUE_AUTO_COMPACT_TOKENS) || 80_000;
53
+ const est = this.estimateTokens();
54
+ if (est < limit)
55
+ return null;
56
+ if (this.messages.length <= 4)
57
+ return null;
58
+ try {
59
+ const r = await this.compact();
60
+ return { before: r.before, after: r.after };
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
39
66
  async *run(userInput) {
40
67
  this.messages.push({ role: 'user', content: userInput });
41
68
  this.persist();
69
+ const compacted = await this.maybeAutoCompact();
70
+ if (compacted) {
71
+ yield { type: 'compact', before: compacted.before, after: compacted.after };
72
+ }
42
73
  while (true) {
43
74
  const tools = getToolDefs();
44
75
  let content = '';
@@ -0,0 +1,51 @@
1
+ // Tracks which models the user has picked, so the picker can surface a
2
+ // RECENT section. Stored at ~/.blyue/model-usage.json. Bounded — one
3
+ // entry per unique model ever picked.
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
5
+ import { homedir } from 'os';
6
+ import { join, dirname } from 'path';
7
+ const filePath = () => join(homedir(), '.blyue', 'model-usage.json');
8
+ let cache = null;
9
+ function load() {
10
+ if (cache)
11
+ return cache;
12
+ const p = filePath();
13
+ if (!existsSync(p)) {
14
+ cache = {};
15
+ return cache;
16
+ }
17
+ try {
18
+ cache = JSON.parse(readFileSync(p, 'utf8'));
19
+ return cache;
20
+ }
21
+ catch {
22
+ cache = {};
23
+ return cache;
24
+ }
25
+ }
26
+ function save(m) {
27
+ const p = filePath();
28
+ mkdirSync(dirname(p), { recursive: true });
29
+ writeFileSync(p, JSON.stringify(m, null, 2), 'utf8');
30
+ cache = m;
31
+ }
32
+ /** Call when the user picks a model (Ctrl+L or /model). */
33
+ export function recordModelUse(id) {
34
+ if (!id)
35
+ return;
36
+ const m = load();
37
+ const prev = m[id] ?? { count: 0, lastUsed: 0 };
38
+ m[id] = { count: prev.count + 1, lastUsed: Date.now() };
39
+ save(m);
40
+ }
41
+ /** Top N most-recently-used model IDs, newest first. */
42
+ export function getRecentModels(limit = 5) {
43
+ const m = load();
44
+ return Object.entries(m)
45
+ .sort(([, a], [, b]) => b.lastUsed - a.lastUsed)
46
+ .slice(0, limit)
47
+ .map(([id]) => id);
48
+ }
49
+ export function clearModelUsage() {
50
+ save({});
51
+ }
@@ -21,20 +21,89 @@ export function loadSession(cwd = process.cwd()) {
21
21
  return null;
22
22
  }
23
23
  }
24
+ const MAX_SESSION_BYTES = Number(process.env.BLYUE_MAX_SESSION_BYTES) || 5 * 1024 * 1024; // 5 MB per session
25
+ const MAX_DIR_BYTES = Number(process.env.BLYUE_MAX_SESSIONS_DIR_BYTES) || 50 * 1024 * 1024; // 50 MB total
26
+ /**
27
+ * Trim the in-memory messages array so a session file never grows past
28
+ * MAX_SESSION_BYTES. Drops oldest non-system messages first, keeping a
29
+ * marker so the user (and the model) knows history was clipped.
30
+ */
31
+ function trimToBudget(messages) {
32
+ const size = (arr) => Buffer.byteLength(JSON.stringify(arr, null, 2), 'utf8');
33
+ if (size(messages) <= MAX_SESSION_BYTES)
34
+ return messages;
35
+ // Keep the most recent N messages, dropping from the front. Stop as
36
+ // soon as we're under budget; always keep at least the last 6.
37
+ let arr = messages.slice();
38
+ while (arr.length > 6 && size(arr) > MAX_SESSION_BYTES) {
39
+ arr.shift();
40
+ }
41
+ if (size(arr) > MAX_SESSION_BYTES) {
42
+ // Even the tail is too big — hard-truncate the largest text fields.
43
+ arr = arr.map((m) => {
44
+ const c = m.content;
45
+ if (typeof c === 'string' && c.length > 8000) {
46
+ return { ...m, content: c.slice(0, 2000) + '\n[truncated]\n' + c.slice(-4000) };
47
+ }
48
+ return m;
49
+ });
50
+ }
51
+ return [
52
+ { role: 'system', content: '[session trimmed by BLYUE to fit size limit]' },
53
+ ...arr,
54
+ ];
55
+ }
56
+ /**
57
+ * Evict oldest session files (never the active one) until the sessions
58
+ * directory is under MAX_DIR_BYTES.
59
+ */
60
+ function pruneDirectory(activeFile) {
61
+ if (!fs.existsSync(SESSIONS_DIR))
62
+ return;
63
+ const entries = fs.readdirSync(SESSIONS_DIR).filter((f) => f.endsWith('.json'));
64
+ let total = 0;
65
+ const stats = [];
66
+ for (const f of entries) {
67
+ const full = path.join(SESSIONS_DIR, f);
68
+ try {
69
+ const st = fs.statSync(full);
70
+ total += st.size;
71
+ stats.push({ file: f, size: st.size, mtime: st.mtimeMs });
72
+ }
73
+ catch { }
74
+ }
75
+ if (total <= MAX_DIR_BYTES)
76
+ return;
77
+ stats.sort((a, b) => a.mtime - b.mtime); // oldest first
78
+ for (const { file, size } of stats) {
79
+ if (total <= MAX_DIR_BYTES)
80
+ break;
81
+ if (path.join(SESSIONS_DIR, file) === activeFile)
82
+ continue;
83
+ try {
84
+ fs.unlinkSync(path.join(SESSIONS_DIR, file));
85
+ total -= size;
86
+ }
87
+ catch { }
88
+ }
89
+ }
24
90
  export function saveSession(messages, cwd = process.cwd()) {
25
91
  const file = sessionFile(cwd);
92
+ const trimmed = trimToBudget(messages);
26
93
  const data = {
27
94
  project: cwd,
28
95
  updatedAt: Date.now(),
29
- messages,
96
+ messages: trimmed,
30
97
  };
31
98
  fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
99
+ pruneDirectory(file);
32
100
  }
33
101
  export function clearSession(cwd = process.cwd()) {
34
102
  const file = sessionFile(cwd);
35
103
  if (fs.existsSync(file))
36
104
  fs.unlinkSync(file);
37
105
  }
106
+ export { MAX_SESSION_BYTES, MAX_DIR_BYTES };
38
107
  export function listSessions() {
39
108
  if (!fs.existsSync(SESSIONS_DIR))
40
109
  return [];
package/dist/ui/Chat.js CHANGED
@@ -18,6 +18,7 @@ import { pastelAt } from './pastel.js';
18
18
  import { bumpActivity } from './activity.js';
19
19
  import { theme } from './theme.js';
20
20
  import { getConfig, saveConfig, applyConfig } from '../core/config.js';
21
+ import { recordModelUse } from '../core/modelUsage.js';
21
22
  let nextId = 0;
22
23
  const mkItem = (kind, text) => ({ kind, text, id: nextId++ });
23
24
  const HistoryLine = ({ item }) => {
@@ -213,6 +214,12 @@ export const Chat = ({ resumedCount = 0 }) => {
213
214
  // React to the specific tool
214
215
  setMascotState(toolToState(ev.name));
215
216
  }
217
+ else if (ev.type === 'compact') {
218
+ setHistory((h) => [
219
+ ...h,
220
+ mkItem('tool', `auto-compact: ${ev.before} → ${ev.after} messages`),
221
+ ]);
222
+ }
216
223
  else if (ev.type === 'tool_end') {
217
224
  flush('tool', toolBuf + '[Result]: ' + ev.preview + '\n');
218
225
  toolBuf = '';
@@ -234,6 +241,7 @@ export const Chat = ({ resumedCount = 0 }) => {
234
241
  setMascotState(undefined);
235
242
  };
236
243
  const handleModelSelect = (id) => {
244
+ recordModelUse(id);
237
245
  process.env.MODEL = id;
238
246
  try {
239
247
  const cfg = getConfig();
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect, useMemo } from 'react';
3
3
  import { Box, Text, useInput, useStdout } from 'ink';
4
4
  import { fetchModels, currentProviderLabel, } from '../core/models.js';
5
+ import { getRecentModels } from '../core/modelUsage.js';
5
6
  import { theme } from './theme.js';
6
7
  export const ModelPicker = ({ current, onSelect, onCancel }) => {
7
8
  const { stdout } = useStdout();
@@ -36,20 +37,33 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
36
37
  cancelled = true;
37
38
  };
38
39
  }, [current]);
39
- const { free, paid } = useMemo(() => {
40
+ const { recent, free, paid } = useMemo(() => {
40
41
  const q = filter.toLowerCase();
41
42
  const matched = q
42
43
  ? models.filter((m) => m.id.toLowerCase().includes(q))
43
44
  : models;
45
+ const byId = new Map(matched.map((m) => [m.id, m]));
46
+ const recentIds = getRecentModels(5);
47
+ const recent = [];
48
+ const seen = new Set();
49
+ for (const id of recentIds) {
50
+ const m = byId.get(id);
51
+ if (m && !seen.has(id)) {
52
+ recent.push(m);
53
+ seen.add(id);
54
+ }
55
+ }
44
56
  const free = [];
45
57
  const paid = [];
46
58
  for (const m of matched) {
59
+ if (seen.has(m.id))
60
+ continue; // already in recent
47
61
  if (m.isFree)
48
62
  free.push(m);
49
63
  else
50
64
  paid.push(m);
51
65
  }
52
- return { free, paid };
66
+ return { recent, free, paid };
53
67
  }, [models, filter]);
54
68
  const rows = useMemo(() => {
55
69
  const out = [];
@@ -62,12 +76,23 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
62
76
  out.push({ kind: 'model', model: m, absoluteIdx: abs++ });
63
77
  }
64
78
  };
65
- if (tier === 'all' || tier === 'free')
79
+ if (tier === 'recent') {
80
+ pushSection('RECENT', recent);
81
+ }
82
+ else if (tier === 'free') {
83
+ pushSection('FREE', free);
84
+ }
85
+ else if (tier === 'paid') {
86
+ pushSection('PAID', paid);
87
+ }
88
+ else {
89
+ // 'all' — show recent first, then free, then paid
90
+ pushSection('RECENT', recent);
66
91
  pushSection('FREE', free);
67
- if (tier === 'all' || tier === 'paid')
68
92
  pushSection('PAID', paid);
93
+ }
69
94
  return out;
70
- }, [free, paid, tier]);
95
+ }, [recent, free, paid, tier]);
71
96
  const selectableIdxToRow = [];
72
97
  rows.forEach((r, i) => {
73
98
  if (r.kind === 'model')
@@ -87,7 +112,13 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
87
112
  if (key.escape)
88
113
  return onCancel();
89
114
  if (key.tab) {
90
- setTier((t) => (t === 'all' ? 'free' : t === 'free' ? 'paid' : 'all'));
115
+ setTier((t) => t === 'all'
116
+ ? 'free'
117
+ : t === 'free'
118
+ ? 'paid'
119
+ : t === 'paid'
120
+ ? 'recent'
121
+ : 'all');
91
122
  setIdx(0);
92
123
  return;
93
124
  }
@@ -116,7 +147,7 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
116
147
  }
117
148
  });
118
149
  const inner = width - 4;
119
- const total = free.length + paid.length;
150
+ const total = free.length + paid.length + recent.length;
120
151
  const headerLabel = filter
121
152
  ? `filter: ${filter} · ${total} match`
122
153
  : `${provider} · ${total} total · ${free.length} free`;
@@ -128,11 +159,18 @@ export const ModelPicker = ({ current, onSelect, onCancel }) => {
128
159
  const windowEnd = windowStart + maxVisible;
129
160
  const visibleRows = rows.slice(windowStart, windowEnd);
130
161
  const cursorAbs = idx;
131
- const tierBadge = tier === 'all' ? 'all' : tier === 'free' ? 'FREE only' : 'PAID only';
162
+ const tierBadge = tier === 'all'
163
+ ? 'all'
164
+ : tier === 'free'
165
+ ? 'FREE only'
166
+ : tier === 'paid'
167
+ ? 'PAID only'
168
+ : 'RECENT only';
132
169
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '╭─ ' }), _jsx(Text, { color: theme.accent, bold: true, children: headerLabel }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(topPad) + '╮' })] }), loading ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.muted, children: ["fetching ", provider, " catalog\u2026"] })] })) : error ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.error, children: ["\u2717 ", error.slice(0, inner - 6)] })] })) : rows.length === 0 ? (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsxs(Text, { color: theme.error, children: ["no matches", tier !== 'all' ? ` in ${tierBadge}` : ''] })] })) : (visibleRows.map((row, i) => {
133
170
  if (row.kind === 'header') {
134
171
  const sectionPad = Math.max(1, inner - row.label.length - row.count.toString().length - 5);
135
- return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: theme.accent, bold: true, children: row.label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(sectionPad) + ' ' + row.count })] }, `h-${row.label}`));
172
+ const headerColor = row.label === 'RECENT' ? theme.warning : theme.accent;
173
+ return (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: '│ ' }), _jsx(Text, { color: headerColor, bold: true, children: row.label }), _jsx(Text, { color: theme.muted, children: ' ' + '─'.repeat(sectionPad) + ' ' + row.count })] }, `h-${row.label}`));
136
174
  }
137
175
  const m = row.model;
138
176
  const active = row.absoluteIdx === cursorAbs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blyuecode",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",