blyuecode 0.3.4 → 0.3.5

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
+ }
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.5",
4
4
  "description": "BLYUE Code — a coding agent by Lumen",
5
5
  "type": "module",
6
6
  "license": "MIT",