@tjamescouch/gro 1.3.15 → 1.3.16

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/_base.md CHANGED
@@ -58,3 +58,23 @@ You are connected to a **PUBLIC** AgentChat server.
58
58
  - Personal/open-source work only.
59
59
  - Do not paste or process confidential/proprietary code or secrets.
60
60
  - If a task looks like work-for-hire/proprietary, move it to a private instance.
61
+
62
+ ### Virtual Memory (context paging)
63
+
64
+ When running with VirtualMemory, your context is managed as a sliding window:
65
+
66
+ ```
67
+ [system prompt]
68
+ [page index — one-line descriptions of available pages]
69
+ [active pages — loaded via @@ref@@]
70
+ [recent messages — sliding window within token budget]
71
+ ```
72
+
73
+ - **Pages** are immutable summaries of older conversation windows, stored in `~/.gro/pages/`.
74
+ - The **page index** is always in context — you can see what's available without loading everything.
75
+ - Use `` to load a page into context for your next turn.
76
+ - Use `` to release a page when you're done with it.
77
+ - Pages load/unload on the **next API call** (after your response completes).
78
+ - Each model has its own context window budget (haiku: smaller, sonnet/opus: larger).
79
+
80
+ This lets you work with long conversation histories without burning tokens on context you don't need right now.
package/dist/main.js CHANGED
@@ -529,6 +529,20 @@ async function executeTurn(driver, memory, mcp, cfg, sessionId) {
529
529
  cfg.model = newModel; // persist across turns
530
530
  memory.setModel(newModel); // persist in session metadata on save
531
531
  }
532
+ else if (marker.name === "ref" && marker.arg) {
533
+ // VirtualMemory page ref — load a page into context for next turn
534
+ if ("ref" in memory && typeof memory.ref === "function") {
535
+ memory.ref(marker.arg);
536
+ Logger.info(`Stream marker: ref('${marker.arg}') — page will load next turn`);
537
+ }
538
+ }
539
+ else if (marker.name === "unref" && marker.arg) {
540
+ // VirtualMemory page unref — release a page from context
541
+ if ("unref" in memory && typeof memory.unref === "function") {
542
+ memory.unref(marker.arg);
543
+ Logger.info(`Stream marker: unref('${marker.arg}') — page released`);
544
+ }
545
+ }
532
546
  else {
533
547
  Logger.debug(`Stream marker: ${marker.name}('${marker.arg}')`);
534
548
  }
@@ -3,3 +3,4 @@ export * from "./agenthnsw.js";
3
3
  export { AgentMemory } from "./agent-memory.js";
4
4
  export { AdvancedMemory } from "./advanced-memory.js";
5
5
  export { SimpleMemory } from "./simple-memory.js";
6
+ export { VirtualMemory } from "./virtual-memory.js";
@@ -0,0 +1,334 @@
1
+ import { AgentMemory } from "./agent-memory.js";
2
+ import { saveSession, loadSession, ensureGroDir } from "../session.js";
3
+ import { writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ const DEFAULTS = {
7
+ pagesDir: join(process.env.HOME ?? "/tmp", ".gro", "pages"),
8
+ pageSlotTokens: 40_000,
9
+ workingMemoryTokens: 80_000,
10
+ avgCharsPerToken: 2.8,
11
+ minRecentMessages: 6,
12
+ highRatio: 0.75,
13
+ lowRatio: 0.50,
14
+ systemPrompt: "",
15
+ summarizerModel: "claude-sonnet-4-20250514",
16
+ };
17
+ // --- VirtualMemory ---
18
+ export class VirtualMemory extends AgentMemory {
19
+ constructor(config = {}) {
20
+ super(config.systemPrompt);
21
+ /** All known pages */
22
+ this.pages = new Map();
23
+ /** Currently loaded page IDs (in the page slot) */
24
+ this.activePageIds = new Set();
25
+ /** Pages requested by the model via @@ref markers */
26
+ this.pendingRefs = new Set();
27
+ /** Pages to unload */
28
+ this.pendingUnrefs = new Set();
29
+ /** Load order for eviction (oldest first) */
30
+ this.loadOrder = [];
31
+ this.model = "unknown";
32
+ this.cfg = {
33
+ pagesDir: config.pagesDir ?? DEFAULTS.pagesDir,
34
+ pageSlotTokens: config.pageSlotTokens ?? DEFAULTS.pageSlotTokens,
35
+ workingMemoryTokens: config.workingMemoryTokens ?? DEFAULTS.workingMemoryTokens,
36
+ avgCharsPerToken: config.avgCharsPerToken ?? DEFAULTS.avgCharsPerToken,
37
+ minRecentMessages: config.minRecentMessages ?? DEFAULTS.minRecentMessages,
38
+ highRatio: config.highRatio ?? DEFAULTS.highRatio,
39
+ lowRatio: config.lowRatio ?? DEFAULTS.lowRatio,
40
+ systemPrompt: config.systemPrompt ?? DEFAULTS.systemPrompt,
41
+ driver: config.driver ?? null,
42
+ summarizerModel: config.summarizerModel ?? DEFAULTS.summarizerModel,
43
+ };
44
+ mkdirSync(this.cfg.pagesDir, { recursive: true });
45
+ }
46
+ setModel(model) {
47
+ this.model = model;
48
+ }
49
+ // --- Persistence ---
50
+ async load(id) {
51
+ const session = loadSession(id);
52
+ if (session) {
53
+ this.messagesBuffer = session.messages;
54
+ }
55
+ this.loadPageIndex();
56
+ }
57
+ async save(id) {
58
+ ensureGroDir();
59
+ saveSession(id, this.messagesBuffer, {
60
+ id,
61
+ provider: "unknown",
62
+ model: this.model,
63
+ createdAt: new Date().toISOString(),
64
+ });
65
+ this.savePageIndex();
66
+ }
67
+ // --- Page Index (persisted metadata) ---
68
+ indexPath() {
69
+ return join(this.cfg.pagesDir, "index.json");
70
+ }
71
+ loadPageIndex() {
72
+ const p = this.indexPath();
73
+ if (!existsSync(p))
74
+ return;
75
+ try {
76
+ const data = JSON.parse(readFileSync(p, "utf8"));
77
+ this.pages.clear();
78
+ for (const page of data.pages ?? [])
79
+ this.pages.set(page.id, page);
80
+ this.activePageIds = new Set(data.activePageIds ?? []);
81
+ this.loadOrder = data.loadOrder ?? [];
82
+ }
83
+ catch {
84
+ this.pages.clear();
85
+ }
86
+ }
87
+ savePageIndex() {
88
+ mkdirSync(this.cfg.pagesDir, { recursive: true });
89
+ writeFileSync(this.indexPath(), JSON.stringify({
90
+ pages: Array.from(this.pages.values()),
91
+ activePageIds: Array.from(this.activePageIds),
92
+ loadOrder: this.loadOrder,
93
+ savedAt: new Date().toISOString(),
94
+ }, null, 2) + "\n");
95
+ }
96
+ // --- Page Storage ---
97
+ pagePath(id) {
98
+ return join(this.cfg.pagesDir, `${id}.json`);
99
+ }
100
+ savePage(page) {
101
+ mkdirSync(this.cfg.pagesDir, { recursive: true });
102
+ writeFileSync(this.pagePath(page.id), JSON.stringify(page, null, 2) + "\n");
103
+ this.pages.set(page.id, page);
104
+ }
105
+ loadPageContent(id) {
106
+ const cached = this.pages.get(id);
107
+ if (cached)
108
+ return cached.content;
109
+ const p = this.pagePath(id);
110
+ if (!existsSync(p))
111
+ return null;
112
+ try {
113
+ const page = JSON.parse(readFileSync(p, "utf8"));
114
+ this.pages.set(id, page);
115
+ return page.content;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ // --- Ref/Unref (called by marker handler) ---
122
+ ref(pageId) {
123
+ this.pendingRefs.add(pageId);
124
+ this.pendingUnrefs.delete(pageId);
125
+ }
126
+ unref(pageId) {
127
+ this.pendingUnrefs.add(pageId);
128
+ this.pendingRefs.delete(pageId);
129
+ }
130
+ // --- Token Math ---
131
+ tokensFor(text) {
132
+ return Math.ceil(text.length / this.cfg.avgCharsPerToken);
133
+ }
134
+ msgTokens(msgs) {
135
+ let chars = 0;
136
+ for (const m of msgs) {
137
+ const s = String(m.content ?? "");
138
+ chars += (s.length > 24_000 ? 24_000 : s.length) + 32;
139
+ }
140
+ return Math.ceil(chars / this.cfg.avgCharsPerToken);
141
+ }
142
+ // --- Page Creation ---
143
+ generatePageId(content) {
144
+ return "pg_" + createHash("sha256").update(content).digest("hex").slice(0, 12);
145
+ }
146
+ /**
147
+ * Create a page from raw messages and return a summary with embedded ref.
148
+ * The raw content is saved to disk; the returned summary replaces it in working memory.
149
+ */
150
+ async createPageFromMessages(messages, label) {
151
+ // Build raw content for the page
152
+ const rawContent = messages.map(m => `[${m.role}${m.from ? ` (${m.from})` : ""}]: ${String(m.content ?? "").slice(0, 8000)}`).join("\n\n");
153
+ const page = {
154
+ id: this.generatePageId(rawContent),
155
+ label,
156
+ content: rawContent,
157
+ createdAt: new Date().toISOString(),
158
+ messageCount: messages.length,
159
+ tokens: this.tokensFor(rawContent),
160
+ };
161
+ this.savePage(page);
162
+ // Generate summary with embedded ref
163
+ let summary;
164
+ if (this.cfg.driver) {
165
+ summary = await this.summarizeWithRef(messages, page.id, label);
166
+ }
167
+ else {
168
+ // Fallback: simple label + ref without LLM
169
+ summary = `[Summary of ${messages.length} messages: ${label}] `;
170
+ }
171
+ return { page, summary };
172
+ }
173
+ async summarizeWithRef(messages, pageId, label) {
174
+ const transcript = messages.map(m => {
175
+ const c = String(m.content ?? "").slice(0, 4000);
176
+ return `${m.role.toUpperCase()}: ${c}`;
177
+ }).join("\n");
178
+ const sys = {
179
+ role: "system",
180
+ from: "System",
181
+ content: [
182
+ "You are a precise summarizer. Output concise bullet points preserving facts, tasks, file paths, commands, and decisions.",
183
+ `End the summary with: `,
184
+ "This ref is a hyperlink to the full conversation. Always include it.",
185
+ "Hard limit: ~500 characters.",
186
+ ].join(" "),
187
+ };
188
+ const usr = {
189
+ role: "user",
190
+ from: "User",
191
+ content: `Summarize this conversation segment (${label}):\n\n${transcript.slice(0, 12000)}`,
192
+ };
193
+ try {
194
+ const out = await this.cfg.driver.chat([sys, usr], { model: this.cfg.summarizerModel });
195
+ let text = String(out?.text ?? "").trim();
196
+ // Ensure ref is present
197
+ if (!text.includes(``)) {
198
+ text += `\n`;
199
+ }
200
+ return text;
201
+ }
202
+ catch {
203
+ return `[Summary of ${messages.length} messages: ${label}] `;
204
+ }
205
+ }
206
+ // --- Context Assembly ---
207
+ messages() {
208
+ // Resolve pending refs/unrefs
209
+ for (const id of this.pendingUnrefs) {
210
+ this.activePageIds.delete(id);
211
+ this.loadOrder = this.loadOrder.filter(x => x !== id);
212
+ }
213
+ for (const id of this.pendingRefs) {
214
+ if (this.pages.has(id) || existsSync(this.pagePath(id))) {
215
+ this.activePageIds.add(id);
216
+ if (!this.loadOrder.includes(id))
217
+ this.loadOrder.push(id);
218
+ }
219
+ }
220
+ this.pendingRefs.clear();
221
+ this.pendingUnrefs.clear();
222
+ // Evict oldest pages if slot is over budget
223
+ this.evictPages();
224
+ const result = [];
225
+ let usedTokens = 0;
226
+ // 1. System prompt
227
+ const sysMsg = this.messagesBuffer.find(m => m.role === "system");
228
+ if (sysMsg) {
229
+ result.push(sysMsg);
230
+ usedTokens += this.msgTokens([sysMsg]);
231
+ }
232
+ // 2. Page slot — loaded pages
233
+ const pageMessages = this.buildPageSlot();
234
+ if (pageMessages.length > 0) {
235
+ result.push(...pageMessages);
236
+ usedTokens += this.msgTokens(pageMessages);
237
+ }
238
+ // 3. Working memory — recent messages within budget
239
+ const wmBudget = this.cfg.workingMemoryTokens;
240
+ const nonSystem = this.messagesBuffer.filter(m => m !== sysMsg);
241
+ const window = [];
242
+ let wmTokens = 0;
243
+ for (let i = nonSystem.length - 1; i >= 0; i--) {
244
+ const msg = nonSystem[i];
245
+ const mt = this.msgTokens([msg]);
246
+ if (wmTokens + mt > wmBudget && window.length >= this.cfg.minRecentMessages)
247
+ break;
248
+ window.unshift(msg);
249
+ wmTokens += mt;
250
+ if (wmTokens > wmBudget * 2)
251
+ break;
252
+ }
253
+ result.push(...window);
254
+ return result;
255
+ }
256
+ buildPageSlot() {
257
+ const msgs = [];
258
+ let slotTokens = 0;
259
+ for (const id of this.loadOrder) {
260
+ if (!this.activePageIds.has(id))
261
+ continue;
262
+ const content = this.loadPageContent(id);
263
+ if (!content)
264
+ continue;
265
+ const page = this.pages.get(id);
266
+ const tokens = this.tokensFor(content);
267
+ if (slotTokens + tokens > this.cfg.pageSlotTokens)
268
+ continue;
269
+ msgs.push({
270
+ role: "system",
271
+ from: "VirtualMemory",
272
+ content: `--- Loaded Page: ${id} (${page?.label ?? "unknown"}) ---\n${content}\n--- End Page: ${id} (use to release) ---`,
273
+ });
274
+ slotTokens += tokens;
275
+ }
276
+ return msgs;
277
+ }
278
+ evictPages() {
279
+ let slotTokens = 0;
280
+ for (const id of this.loadOrder) {
281
+ const page = this.pages.get(id);
282
+ if (page)
283
+ slotTokens += page.tokens;
284
+ }
285
+ while (slotTokens > this.cfg.pageSlotTokens && this.loadOrder.length > 0) {
286
+ const evictId = this.loadOrder.shift();
287
+ this.activePageIds.delete(evictId);
288
+ const page = this.pages.get(evictId);
289
+ if (page)
290
+ slotTokens -= page.tokens;
291
+ }
292
+ }
293
+ // --- Background Summarization ---
294
+ async onAfterAdd() {
295
+ if (!this.cfg.driver)
296
+ return;
297
+ const wmBudget = this.cfg.workingMemoryTokens;
298
+ const nonSystem = this.messagesBuffer.filter(m => m.role !== "system");
299
+ const currentTokens = this.msgTokens(nonSystem);
300
+ if (currentTokens <= wmBudget * this.cfg.highRatio)
301
+ return;
302
+ await this.runOnce(async () => {
303
+ const nonSys = this.messagesBuffer.filter(m => m.role !== "system");
304
+ const est = this.msgTokens(nonSys);
305
+ if (est <= wmBudget * this.cfg.highRatio)
306
+ return;
307
+ const targetTokens = Math.floor(wmBudget * this.cfg.lowRatio);
308
+ // Find how many old messages to summarize
309
+ const protect = this.cfg.minRecentMessages;
310
+ const summarizable = nonSys.slice(0, Math.max(0, nonSys.length - protect));
311
+ if (summarizable.length < 2)
312
+ return;
313
+ // Take a chunk of oldest messages to page out
314
+ const chunkSize = Math.min(summarizable.length, Math.max(4, Math.floor(summarizable.length / 2)));
315
+ const toPage = summarizable.slice(0, chunkSize);
316
+ // Create page + summary
317
+ const label = `session ${new Date().toISOString().slice(0, 16)} (${toPage.length} msgs)`;
318
+ const { summary } = await this.createPageFromMessages(toPage, label);
319
+ // Replace paged messages with summary in buffer
320
+ const sysMsg = this.messagesBuffer.find(m => m.role === "system");
321
+ const startIdx = sysMsg ? 1 : 0;
322
+ this.messagesBuffer.splice(startIdx, chunkSize, {
323
+ role: "assistant",
324
+ from: "VirtualMemory",
325
+ content: summary,
326
+ });
327
+ });
328
+ }
329
+ // --- Accessors ---
330
+ getPages() { return Array.from(this.pages.values()); }
331
+ getActivePageIds() { return Array.from(this.activePageIds); }
332
+ getPageCount() { return this.pages.size; }
333
+ hasPage(id) { return this.pages.has(id); }
334
+ }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tjamescouch/gro",
3
- "version": "1.3.15",
3
+ "version": "1.3.16",
4
4
  "description": "Provider-agnostic LLM runtime with context management",
5
5
  "bin": {
6
6
  "gro": "./dist/main.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tjamescouch/gro",
3
- "version": "1.3.15",
3
+ "version": "1.3.16",
4
4
  "description": "Provider-agnostic LLM runtime with context management",
5
5
  "bin": {
6
6
  "gro": "./dist/main.js"