agent-sh 0.14.8 → 0.14.9
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/agent/agent-loop.d.ts +0 -4
- package/dist/agent/agent-loop.js +8 -166
- package/dist/agent/entry-format.d.ts +5 -0
- package/dist/agent/entry-format.js +9 -0
- package/dist/agent/extensions/rolling-history/constants.d.ts +1 -0
- package/dist/agent/extensions/rolling-history/constants.js +1 -0
- package/dist/agent/extensions/rolling-history/index.d.ts +4 -0
- package/dist/agent/extensions/rolling-history/index.js +203 -0
- package/dist/agent/extensions/rolling-history/recall.d.ts +4 -0
- package/dist/agent/extensions/rolling-history/recall.js +122 -0
- package/dist/agent/extensions/rolling-history/strategy.d.ts +70 -0
- package/dist/agent/extensions/rolling-history/strategy.js +336 -0
- package/dist/agent/host-types.d.ts +0 -3
- package/dist/agent/index.js +44 -5
- package/dist/agent/live-view.d.ts +57 -0
- package/dist/agent/live-view.js +238 -0
- package/dist/agent/llm-client.d.ts +1 -0
- package/dist/agent/llm-client.js +1 -1
- package/dist/agent/session-store.d.ts +90 -0
- package/dist/agent/session-store.js +288 -0
- package/dist/agent/store.d.ts +74 -0
- package/dist/agent/store.js +284 -0
- package/dist/agent/subagent.js +2 -2
- package/dist/agent/tool-protocol.d.ts +11 -11
- package/dist/cli/index.js +4 -2
- package/dist/core/index.d.ts +0 -1
- package/dist/core/index.js +0 -1
- package/dist/core/settings.d.ts +5 -1
- package/dist/core/settings.js +62 -1
- package/dist/extensions/index.d.ts +1 -0
- package/dist/shell/events.d.ts +1 -0
- package/dist/shell/input-handler.js +4 -0
- package/dist/shell/tui-renderer.js +5 -2
- package/dist/utils/diff-renderer.js +9 -7
- package/examples/extensions/ash-acp-bridge/src/index.ts +1 -2
- package/examples/extensions/ashi/package.json +2 -2
- package/examples/extensions/ashi/src/capture.ts +1 -1
- package/examples/extensions/ashi/src/cli.ts +3 -4
- package/examples/extensions/ashi/src/compaction.ts +6 -2
- package/examples/extensions/ashi/src/frontend.ts +13 -13
- package/examples/extensions/ashi/src/multi-session-store.ts +35 -12
- package/examples/extensions/ashi/src/session-commands.ts +1 -1
- package/examples/extensions/ashi/src/user-shell-intents.ts +17 -0
- package/package.json +13 -1
- package/dist/agent/conversation-state.d.ts +0 -142
- package/dist/agent/conversation-state.js +0 -788
- package/dist/agent/history-file.d.ts +0 -81
- package/dist/agent/history-file.js +0 -271
- package/examples/extensions/ashi/src/session-store.ts +0 -363
|
@@ -16,7 +16,6 @@ import type { AgentMode } from "./host-types.js";
|
|
|
16
16
|
import type { LlmClient } from "./llm-client.js";
|
|
17
17
|
import type { HandlerFunctions } from "../utils/handler-registry.js";
|
|
18
18
|
import { type AgentBackend, type ToolDefinition } from "./types.js";
|
|
19
|
-
import { type HistoryAdapter } from "./history-file.js";
|
|
20
19
|
import type { Compositor } from "../utils/compositor.js";
|
|
21
20
|
export interface AgentLoopConfig {
|
|
22
21
|
bus: EventBus;
|
|
@@ -26,12 +25,10 @@ export interface AgentLoopConfig {
|
|
|
26
25
|
compositor?: Compositor;
|
|
27
26
|
/** Instance ID from core — ensures history entries match the ID in prompts. */
|
|
28
27
|
instanceId?: string;
|
|
29
|
-
history?: HistoryAdapter;
|
|
30
28
|
}
|
|
31
29
|
export declare class AgentLoop implements AgentBackend {
|
|
32
30
|
private abortController;
|
|
33
31
|
private toolRegistry;
|
|
34
|
-
private history;
|
|
35
32
|
private conversation;
|
|
36
33
|
private fileReadCache;
|
|
37
34
|
private activeMode;
|
|
@@ -111,7 +108,6 @@ export declare class AgentLoop implements AgentBackend {
|
|
|
111
108
|
private getRetryDelay;
|
|
112
109
|
/** Format an error with provider context for user-facing display. */
|
|
113
110
|
private formatError;
|
|
114
|
-
private registerCoreTools;
|
|
115
111
|
/**
|
|
116
112
|
* Register named handlers that extensions can advise.
|
|
117
113
|
* Only high-power use cases where multiple extensions compose.
|
package/dist/agent/agent-loop.js
CHANGED
|
@@ -3,9 +3,7 @@ import * as path from "node:path";
|
|
|
3
3
|
import { contentText } from "./types.js";
|
|
4
4
|
import { ToolRegistry } from "./tool-registry.js";
|
|
5
5
|
import { normalizeToolArgs } from "./normalize-args.js";
|
|
6
|
-
import {
|
|
7
|
-
import { HistoryFile } from "./history-file.js";
|
|
8
|
-
import { nucleate, formatNuclearLine, isReadOnly } from "./nuclear-form.js";
|
|
6
|
+
import { LiveView } from "./live-view.js";
|
|
9
7
|
import { STATIC_SYSTEM_PROMPT, buildStaticByCwd, formatSkillsBlock, loadGlobalAgentsMd } from "./system-prompt.js";
|
|
10
8
|
import { createToolUI } from "../utils/tool-interactive.js";
|
|
11
9
|
import { RESPONSE_RESERVE, DEFAULT_CONTEXT_WINDOW } from "./token-budget.js";
|
|
@@ -40,7 +38,6 @@ function summarizeDescription(desc) {
|
|
|
40
38
|
export class AgentLoop {
|
|
41
39
|
abortController = null;
|
|
42
40
|
toolRegistry;
|
|
43
|
-
history;
|
|
44
41
|
conversation;
|
|
45
42
|
fileReadCache;
|
|
46
43
|
activeMode;
|
|
@@ -90,19 +87,12 @@ export class AgentLoop {
|
|
|
90
87
|
this.instanceId = config.instanceId ?? "unknown";
|
|
91
88
|
this.toolRegistry = new ToolRegistry(this.handlers);
|
|
92
89
|
this.fileReadCache = this.handlers.call("agent:file-read-cache");
|
|
93
|
-
|
|
94
|
-
// `history:append` handler registered below; extensions swap the
|
|
95
|
-
// backend without touching this wiring.
|
|
96
|
-
const filePath = process.env.AGENT_SH_HISTORY_FILE || getSettings().historyFilePath;
|
|
97
|
-
this.history = config.history ?? new HistoryFile({ instanceId: this.instanceId, filePath });
|
|
98
|
-
this.conversation = new ConversationState(this.handlers, this.instanceId);
|
|
90
|
+
this.conversation = new LiveView(this.handlers, this.instanceId);
|
|
99
91
|
this.activeMode = config.initialMode ?? { model: config.llmClient.model };
|
|
100
92
|
// Tool protocol — controls how tools are presented to the LLM
|
|
101
93
|
const { names: fromExtensions } = this.bus.emitPipe("agent:core-tools:collect", { names: [] });
|
|
102
94
|
const coreTools = Array.from(new Set([...(getSettings().coreTools ?? []), ...fromExtensions]));
|
|
103
95
|
this.toolProtocol = createToolProtocol(getSettings().toolMode ?? "api", coreTools);
|
|
104
|
-
// Register core tools
|
|
105
|
-
this.registerCoreTools();
|
|
106
96
|
// Register any protocol-provided tools (e.g. load_tool for deferred-lookup).
|
|
107
97
|
const protocolTools = this.toolProtocol.getProtocolTools?.() ?? [];
|
|
108
98
|
for (const t of protocolTools)
|
|
@@ -249,7 +239,7 @@ export class AgentLoop {
|
|
|
249
239
|
});
|
|
250
240
|
on("agent:reset-session", () => {
|
|
251
241
|
this.cancel();
|
|
252
|
-
this.conversation = new
|
|
242
|
+
this.conversation = new LiveView(this.handlers, this.instanceId);
|
|
253
243
|
this.lastProjectSkillNames.clear();
|
|
254
244
|
});
|
|
255
245
|
on("agent:compact-request", async () => {
|
|
@@ -267,8 +257,6 @@ export class AgentLoop {
|
|
|
267
257
|
onPipe("context:get-stats", () => ({
|
|
268
258
|
activeTokens: this.conversation.estimateTokens(),
|
|
269
259
|
totalTokens: this.conversation.estimatePromptTokens(),
|
|
270
|
-
nuclearEntries: this.conversation.getNuclearEntryCount(),
|
|
271
|
-
recallArchiveSize: this.conversation.getRecallArchiveSize(),
|
|
272
260
|
budgetTokens: this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
273
261
|
}));
|
|
274
262
|
onPipe("context:snapshot", (payload) => {
|
|
@@ -283,14 +271,6 @@ export class AgentLoop {
|
|
|
283
271
|
payload.stats = { before: stats.before, after: stats.after, evictedCount: stats.evictedCount };
|
|
284
272
|
return payload;
|
|
285
273
|
});
|
|
286
|
-
// Prior-session preamble (non-blocking). Both the read and the
|
|
287
|
-
// layout go through advisable handlers.
|
|
288
|
-
Promise.resolve(this.handlers.call("history:read-recent"))
|
|
289
|
-
.then((entries) => {
|
|
290
|
-
if (entries && entries.length > 0)
|
|
291
|
-
this.conversation.loadPriorHistory(entries);
|
|
292
|
-
})
|
|
293
|
-
.catch(() => { });
|
|
294
274
|
// Track generic compaction metrics from the `conversation:after-compact`
|
|
295
275
|
// event. Whatever strategy ran, core accumulates these counters for
|
|
296
276
|
// status/introspect consumers.
|
|
@@ -560,77 +540,6 @@ export class AgentLoop {
|
|
|
560
540
|
const context = provider ? ` (${provider}, model: ${model})` : ` (model: ${model})`;
|
|
561
541
|
return `${raw}${context}`;
|
|
562
542
|
}
|
|
563
|
-
registerCoreTools() {
|
|
564
|
-
// Stateless core tools register in agentBackend; conversation_recall
|
|
565
|
-
// stays here because it needs this.conversation.
|
|
566
|
-
this.toolRegistry.register({
|
|
567
|
-
name: "conversation_recall",
|
|
568
|
-
displayName: "recall",
|
|
569
|
-
description: "Browse, search, or expand evicted conversation turns. " +
|
|
570
|
-
"Use when you need context from earlier in the conversation that was compacted away. " +
|
|
571
|
-
"Search is regex-based and covers both summaries and full body text. " +
|
|
572
|
-
"If search doesn't find what you expect, try broader/shorter terms or browse to scan the timeline.",
|
|
573
|
-
input_schema: {
|
|
574
|
-
type: "object",
|
|
575
|
-
properties: {
|
|
576
|
-
action: {
|
|
577
|
-
type: "string",
|
|
578
|
-
enum: ["browse", "search", "expand"],
|
|
579
|
-
description: "browse: list evicted turns, search: regex search, expand: show full turn",
|
|
580
|
-
},
|
|
581
|
-
query: {
|
|
582
|
-
type: "string",
|
|
583
|
-
description: "Search query (for action=search)",
|
|
584
|
-
},
|
|
585
|
-
turn_id: {
|
|
586
|
-
type: "number",
|
|
587
|
-
description: "Turn ID to expand (for action=expand)",
|
|
588
|
-
},
|
|
589
|
-
},
|
|
590
|
-
required: ["action"],
|
|
591
|
-
},
|
|
592
|
-
execute: async (args) => {
|
|
593
|
-
const action = args.action;
|
|
594
|
-
let content;
|
|
595
|
-
if (action === "search") {
|
|
596
|
-
content = await this.conversation.search(args.query ?? "");
|
|
597
|
-
}
|
|
598
|
-
else if (action === "expand") {
|
|
599
|
-
content = await this.conversation.expand(args.turn_id);
|
|
600
|
-
}
|
|
601
|
-
else {
|
|
602
|
-
content = await this.conversation.browse();
|
|
603
|
-
}
|
|
604
|
-
return { content, exitCode: 0, isError: false };
|
|
605
|
-
},
|
|
606
|
-
formatResult: (args, result) => {
|
|
607
|
-
const action = args.action;
|
|
608
|
-
const text = contentText(result.content);
|
|
609
|
-
if (result.isError)
|
|
610
|
-
return { summary: "error" };
|
|
611
|
-
if (action === "search") {
|
|
612
|
-
if (text.startsWith("No results"))
|
|
613
|
-
return { summary: "0 matches" };
|
|
614
|
-
const m = text.match(/^Found (\d+)/);
|
|
615
|
-
return { summary: m ? `${m[1]} matches` : "search done" };
|
|
616
|
-
}
|
|
617
|
-
if (action === "browse") {
|
|
618
|
-
if (text.startsWith("No conversation"))
|
|
619
|
-
return { summary: "empty" };
|
|
620
|
-
return { summary: "browsed" };
|
|
621
|
-
}
|
|
622
|
-
if (text.includes("no expanded content"))
|
|
623
|
-
return { summary: "not found" };
|
|
624
|
-
return { summary: "expanded" };
|
|
625
|
-
},
|
|
626
|
-
getDisplayInfo: () => ({ kind: "search", icon: "\u27F2" }),
|
|
627
|
-
});
|
|
628
|
-
this.registerInstruction("recall-guidance", "When starting a task that may have been discussed before (conventions, preferences, corrections, prior examples), " +
|
|
629
|
-
"use conversation_recall to search history for relevant prior entries. " +
|
|
630
|
-
"Treat recurring user guidance as standing preferences. " +
|
|
631
|
-
"If a search returns nothing useful, try: shorter queries, alternate terms, or browse to scan the full timeline. " +
|
|
632
|
-
"Recall only covers this and recent sessions — for older context, also search the filesystem (grep, glob).", "core");
|
|
633
|
-
}
|
|
634
543
|
/**
|
|
635
544
|
* Register named handlers that extensions can advise.
|
|
636
545
|
* Only high-power use cases where multiple extensions compose.
|
|
@@ -735,7 +644,6 @@ export class AgentLoop {
|
|
|
735
644
|
const ratio = getSettings().autoCompactThreshold ?? 0.5;
|
|
736
645
|
return {
|
|
737
646
|
count: this.compactionCount,
|
|
738
|
-
nuclearEntries: this.conversation.getNuclearEntryCount(),
|
|
739
647
|
autoCompactThreshold: ratio,
|
|
740
648
|
autoCompactThresholdTokens: Math.floor((contextWindow - RESPONSE_RESERVE) * ratio),
|
|
741
649
|
};
|
|
@@ -758,58 +666,9 @@ export class AgentLoop {
|
|
|
758
666
|
});
|
|
759
667
|
h.define("conversation:estimate-tokens", () => this.conversation.estimateTokens());
|
|
760
668
|
h.define("conversation:estimate-prompt-tokens", () => this.conversation.estimatePromptTokens());
|
|
761
|
-
|
|
762
|
-
// Turn a raw message into a one-line NuclearEntry. Advisors enrich
|
|
763
|
-
// (e.g. `[why: ...]` extraction, adaptive summary lengths).
|
|
764
|
-
h.define("conversation:nucleate-user", (text, iid, seq) => nucleate("user", text, iid, seq));
|
|
765
|
-
h.define("conversation:nucleate-agent", (text, iid, seq) => nucleate("agent", text, iid, seq));
|
|
766
|
-
h.define("conversation:nucleate-tool", (toolName, args, content, isError, iid, seq) => nucleate(isError ? "error" : "tool", toolName, args, content, isError, iid, seq));
|
|
767
|
-
h.define("conversation:allocate-seq", () => this.conversation.allocateSeq());
|
|
768
|
-
h.define("conversation:reset-for-session", (nextSeq) => this.conversation.resetForSession(nextSeq));
|
|
769
|
-
// Read-only views into the nuclear state, for compact strategies
|
|
770
|
-
// and introspect that read without replacing.
|
|
771
|
-
h.define("conversation:get-nuclear-entries", () => this.conversation.getNuclearEntries());
|
|
772
|
-
h.define("conversation:get-nuclear-summary", () => this.conversation.getNuclearSummary());
|
|
773
|
-
h.define("conversation:build-nuclear-block", () => {
|
|
774
|
-
const summary = this.conversation.getNuclearSummary();
|
|
775
|
-
if (!summary)
|
|
776
|
-
return null;
|
|
777
|
-
return {
|
|
778
|
-
role: "user",
|
|
779
|
-
content: `[Conversation history \u2014 use conversation_recall to expand any entry]\n${summary}`,
|
|
780
|
-
};
|
|
781
|
-
});
|
|
782
|
-
// ── History file I/O (advisable) ───────────────────────────────
|
|
783
|
-
// Default is the append-only JSONL at ~/.agent-sh/history; advisors
|
|
784
|
-
// swap the backend without touching nucleation.
|
|
785
|
-
h.define("history:append", (entries) => {
|
|
786
|
-
if (!entries || entries.length === 0)
|
|
787
|
-
return;
|
|
788
|
-
const writable = entries.filter((e) => !isReadOnly(e));
|
|
789
|
-
if (writable.length > 0)
|
|
790
|
-
this.history.append(writable).catch(() => { });
|
|
791
|
-
});
|
|
792
|
-
h.define("history:search", async (query) => this.history.search(query));
|
|
793
|
-
h.define("history:find-by-seq", async (seq) => this.history.findBySeq(seq));
|
|
794
|
-
h.define("history:read-recent", async (max) => this.history.readRecent(max));
|
|
795
|
-
h.define("history:get-branch", async (leafSeq) => this.history.getBranch ? this.history.getBranch(leafSeq) : this.history.readRecent());
|
|
796
|
-
h.define("history:get-tree", async () => this.history.getTree ? this.history.getTree() : this.history.readRecent());
|
|
797
|
-
h.define("history:set-leaf", (seq) => {
|
|
798
|
-
this.history.setLeaf?.(seq);
|
|
799
|
-
});
|
|
800
|
-
// Prior-session preamble renderer. Default: flat chronological list.
|
|
801
|
-
h.define("conversation:format-prior-history", (entries) => {
|
|
802
|
-
if (!entries || entries.length === 0)
|
|
803
|
-
return null;
|
|
804
|
-
const lines = entries.map(formatNuclearLine);
|
|
805
|
-
return `[Prior session history \u2014 loaded from ~/.agent-sh/history]\n${lines.join("\n")}`;
|
|
806
|
-
});
|
|
807
|
-
// Compaction strategy — default delegates to the two-tier pin
|
|
808
|
-
// strategy in ConversationState; advisors replace wholesale.
|
|
669
|
+
h.define("conversation:link", (index, entryId) => this.conversation.link(index, entryId));
|
|
809
670
|
h.define("conversation:compact", (opts) => {
|
|
810
671
|
const strategy = opts.strategy;
|
|
811
|
-
// Synthesize a CompactResult for manual edits so conversation:after-compact
|
|
812
|
-
// listeners (metrics, file-read cache, system-prompt cache) still run.
|
|
813
672
|
if (strategy?.kind === "rewind" || strategy?.kind === "replace") {
|
|
814
673
|
const before = this.conversation.estimatePromptTokens();
|
|
815
674
|
const beforeLen = this.conversation.getMessages().length;
|
|
@@ -821,10 +680,7 @@ export class AgentLoop {
|
|
|
821
680
|
const afterLen = this.conversation.getMessages().length;
|
|
822
681
|
return { before, after, evictedCount: Math.max(0, beforeLen - afterLen) };
|
|
823
682
|
}
|
|
824
|
-
|
|
825
|
-
const keep = strategy?.kind === "two-tier-pin" ? strategy.keepRecent : opts.keepRecent;
|
|
826
|
-
const force = strategy?.kind === "two-tier-pin" ? strategy.force : opts.force;
|
|
827
|
-
return this.conversation.compact(tgt, keep, force);
|
|
683
|
+
return null;
|
|
828
684
|
});
|
|
829
685
|
// Inject a system note mid-loop — used by extensions (subagents,
|
|
830
686
|
// peer messages) to deliver async results into the next iteration.
|
|
@@ -970,8 +826,9 @@ export class AgentLoop {
|
|
|
970
826
|
// Auto-compact when total context approaches the window limit.
|
|
971
827
|
const totalEstimate = this.conversation.estimatePromptTokens();
|
|
972
828
|
const contextWindow = this.currentMode.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
973
|
-
const
|
|
974
|
-
|
|
829
|
+
const s = getSettings();
|
|
830
|
+
const threshold = Math.floor((contextWindow - RESPONSE_RESERVE) * s.autoCompactThreshold);
|
|
831
|
+
if (s.autoCompact && totalEstimate > threshold) {
|
|
975
832
|
// Compact deeply — shallow targets buy only 1–2 turns of runway on
|
|
976
833
|
// tool-heavy workloads.
|
|
977
834
|
const target = Math.floor(threshold * 0.25);
|
|
@@ -1015,7 +872,6 @@ export class AgentLoop {
|
|
|
1015
872
|
break;
|
|
1016
873
|
// No tool calls → agent is done
|
|
1017
874
|
if (toolCalls.length === 0) {
|
|
1018
|
-
this.conversation.eagerNucleateAgent(fullResponseText);
|
|
1019
875
|
break;
|
|
1020
876
|
}
|
|
1021
877
|
// Emit batch info so the TUI can render group headers upfront
|
|
@@ -1300,20 +1156,6 @@ export class AgentLoop {
|
|
|
1300
1156
|
});
|
|
1301
1157
|
// Record all tool results via protocol
|
|
1302
1158
|
this.toolProtocol.recordResults(this.conversation, collectedResults);
|
|
1303
|
-
const tcMap = new Map();
|
|
1304
|
-
for (const tc of toolCalls) {
|
|
1305
|
-
if (tc.id)
|
|
1306
|
-
tcMap.set(tc.id, tc);
|
|
1307
|
-
}
|
|
1308
|
-
this.conversation.eagerNucleateTools(collectedResults.map((r) => {
|
|
1309
|
-
const tc = tcMap.get(r.callId);
|
|
1310
|
-
let args = {};
|
|
1311
|
-
try {
|
|
1312
|
-
args = tc ? JSON.parse(tc.argumentsJson) : {};
|
|
1313
|
-
}
|
|
1314
|
-
catch { }
|
|
1315
|
-
return { toolName: r.toolName, args, content: r.content, isError: !!r.isError };
|
|
1316
|
-
}));
|
|
1317
1159
|
// Emit enriched message-appended events so derived-log extensions
|
|
1318
1160
|
// can summarize each tool result without re-parsing the message
|
|
1319
1161
|
// structure.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Display line for synthetic summary blocks and conversation_recall.
|
|
2
|
+
* The leading `#${id}` is the token the LLM uses to reference an
|
|
3
|
+
* entry when calling `recall:expand`. */
|
|
4
|
+
import type { Entry } from "./store.js";
|
|
5
|
+
export declare function formatEntryLine(e: Entry): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function formatEntryLine(e) {
|
|
2
|
+
const d = new Date(e.ts);
|
|
3
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
4
|
+
const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
5
|
+
const p = e.payload;
|
|
6
|
+
const sum = p.sum ?? `(${e.kind})`;
|
|
7
|
+
const whyTag = p.why ? ` {${p.why.length > 80 ? p.why.slice(0, 77) + "..." : p.why}}` : "";
|
|
8
|
+
return `#${e.id} [${stamp}] ${sum}${whyTag}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const RECALL_CACHE_KIND = "recall-cache";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const RECALL_CACHE_KIND = "recall-cache";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ExtensionContext } from "../../../shell/host-types.js";
|
|
2
|
+
/** One-time migration: old ~/.agent-sh/history → rolling-history store. */
|
|
3
|
+
export declare function migrateFromLegacy(storeDir: string, legacyPath: string, ctx: Pick<ExtensionContext, "bus">): void;
|
|
4
|
+
export default function activate(ctx: ExtensionContext): void;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { contentText } from "../../types.js";
|
|
4
|
+
import { SharedFileStore, newEntryId } from "../../store.js";
|
|
5
|
+
import { CONFIG_DIR, getSettings } from "../../../core/settings.js";
|
|
6
|
+
import { deserializeEntry, isReadOnly } from "../../nuclear-form.js";
|
|
7
|
+
import { activate as activateSummaryStrategy, nuclearToEntry, readSummaryLines, } from "./strategy.js";
|
|
8
|
+
import { recallSearch, recallExpand, recallBrowse } from "./recall.js";
|
|
9
|
+
const TOOL_NAME = "conversation_recall";
|
|
10
|
+
const INSTRUCTION_NAME = "recall-guidance";
|
|
11
|
+
const INSTRUCTION_TEXT = "When starting a task that may have been discussed before (conventions, preferences, corrections, prior examples), " +
|
|
12
|
+
"use conversation_recall to search history for relevant prior entries. " +
|
|
13
|
+
"Treat recurring user guidance as standing preferences. " +
|
|
14
|
+
"If a search returns nothing useful, try: shorter queries, alternate terms, or browse to scan the full timeline. " +
|
|
15
|
+
"Recall only covers this and recent sessions — for older context, also search the filesystem (grep, glob).";
|
|
16
|
+
/** One-time migration: old ~/.agent-sh/history → rolling-history store. */
|
|
17
|
+
export function migrateFromLegacy(storeDir, legacyPath, ctx) {
|
|
18
|
+
const sentinel = path.join(storeDir, ".migrated");
|
|
19
|
+
if (fs.existsSync(sentinel))
|
|
20
|
+
return;
|
|
21
|
+
const newFile = path.join(storeDir, "history.jsonl");
|
|
22
|
+
if (fs.existsSync(newFile) && fs.statSync(newFile).size > 0) {
|
|
23
|
+
try {
|
|
24
|
+
fs.writeFileSync(sentinel, "");
|
|
25
|
+
}
|
|
26
|
+
catch { /* ignore */ }
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!fs.existsSync(legacyPath)) {
|
|
30
|
+
try {
|
|
31
|
+
fs.writeFileSync(sentinel, "");
|
|
32
|
+
}
|
|
33
|
+
catch { /* ignore */ }
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
let migrated = 0;
|
|
37
|
+
try {
|
|
38
|
+
const lines = fs.readFileSync(legacyPath, "utf-8").split("\n").filter(Boolean);
|
|
39
|
+
const entries = [];
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const ne = deserializeEntry(line);
|
|
42
|
+
if (!ne)
|
|
43
|
+
continue;
|
|
44
|
+
if (isReadOnly(ne))
|
|
45
|
+
continue;
|
|
46
|
+
entries.push(nuclearToEntry(ne, newEntryId()));
|
|
47
|
+
}
|
|
48
|
+
if (entries.length > 0) {
|
|
49
|
+
fs.writeFileSync(newFile, entries.map((e) => JSON.stringify(e) + "\n").join(""));
|
|
50
|
+
migrated = entries.length;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return; // retry next start
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
fs.writeFileSync(sentinel, "");
|
|
58
|
+
}
|
|
59
|
+
catch { /* ignore */ }
|
|
60
|
+
if (migrated > 0) {
|
|
61
|
+
ctx.bus.emit("ui:info", { message: `history: migrated ${migrated} entries from legacy ~/.agent-sh/history` });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export default function activate(ctx) {
|
|
65
|
+
const { maxBytes, prefetchEntries } = ctx.getExtensionSettings("rolling-history", {
|
|
66
|
+
maxBytes: undefined,
|
|
67
|
+
prefetchEntries: 50,
|
|
68
|
+
});
|
|
69
|
+
const storeDir = ctx.getStoragePath("rolling-history");
|
|
70
|
+
const settings = getSettings();
|
|
71
|
+
const legacyPath = settings.historyFilePath ?? path.join(CONFIG_DIR, "history");
|
|
72
|
+
migrateFromLegacy(storeDir, legacyPath, ctx);
|
|
73
|
+
const summaryStore = new SharedFileStore({
|
|
74
|
+
filePath: path.join(storeDir, "history.jsonl"),
|
|
75
|
+
maxBytes,
|
|
76
|
+
});
|
|
77
|
+
// `/history off` gates only writes — store.append and the linkMessage
|
|
78
|
+
// back-stamp. Everything else (meta.tool stamping, compact's reorg,
|
|
79
|
+
// recall reads) runs identically on both sides. Tool + instruction stay
|
|
80
|
+
// registered either way so toggling never perturbs the tools array or
|
|
81
|
+
// system prompt (LLM prompt cache is preserved).
|
|
82
|
+
let enabled = true;
|
|
83
|
+
const gatedStore = {
|
|
84
|
+
append: (entries, opts) => enabled ? summaryStore.append(entries, opts) : Promise.resolve(),
|
|
85
|
+
findById: (id) => summaryStore.findById(id),
|
|
86
|
+
readRecent: (n) => summaryStore.readRecent(n),
|
|
87
|
+
search: (q) => summaryStore.search(q),
|
|
88
|
+
};
|
|
89
|
+
const summaryCtx = {
|
|
90
|
+
store: gatedStore,
|
|
91
|
+
bus: { on: (e, f) => ctx.bus.on(e, f) },
|
|
92
|
+
advise: (op, f) => { ctx.advise(op, f); },
|
|
93
|
+
iid: ctx.instanceId,
|
|
94
|
+
getMessages: () => ctx.call("conversation:get-messages") ?? [],
|
|
95
|
+
replaceMessages: (msgs) => { ctx.call("conversation:replace-messages", msgs); },
|
|
96
|
+
estimateTokens: () => ctx.call("conversation:estimate-tokens") ?? 0,
|
|
97
|
+
estimatePromptTokens: () => ctx.call("conversation:estimate-prompt-tokens") ?? 0,
|
|
98
|
+
linkMessage: (index, entryId) => { if (enabled)
|
|
99
|
+
ctx.call("conversation:link", index, entryId); },
|
|
100
|
+
};
|
|
101
|
+
activateSummaryStrategy(summaryCtx);
|
|
102
|
+
const toolDef = {
|
|
103
|
+
name: TOOL_NAME,
|
|
104
|
+
displayName: "recall",
|
|
105
|
+
description: "Browse, search, or expand evicted conversation turns. " +
|
|
106
|
+
"Use when you need context from earlier in the conversation that was compacted away. " +
|
|
107
|
+
"Search is regex-based and covers both summaries and full body text. " +
|
|
108
|
+
"If search doesn't find what you expect, try broader/shorter terms or browse to scan the timeline.",
|
|
109
|
+
input_schema: {
|
|
110
|
+
type: "object",
|
|
111
|
+
properties: {
|
|
112
|
+
action: {
|
|
113
|
+
type: "string",
|
|
114
|
+
enum: ["browse", "search", "expand"],
|
|
115
|
+
description: "browse: list evicted turns, search: regex search, expand: show full turn",
|
|
116
|
+
},
|
|
117
|
+
query: { type: "string", description: "Search query (for action=search)" },
|
|
118
|
+
turn_id: { type: "string", description: "Turn ID to expand (for action=expand)" },
|
|
119
|
+
},
|
|
120
|
+
required: ["action"],
|
|
121
|
+
},
|
|
122
|
+
execute: async (args) => {
|
|
123
|
+
const action = args.action;
|
|
124
|
+
let content;
|
|
125
|
+
if (action === "search") {
|
|
126
|
+
content = await recallSearch(summaryStore, args.query ?? "");
|
|
127
|
+
}
|
|
128
|
+
else if (action === "expand") {
|
|
129
|
+
content = await recallExpand(summaryStore, args.turn_id);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
content = await recallBrowse(summaryStore);
|
|
133
|
+
}
|
|
134
|
+
return { content, exitCode: 0, isError: false };
|
|
135
|
+
},
|
|
136
|
+
formatResult: (args, result) => {
|
|
137
|
+
const action = args.action;
|
|
138
|
+
const text = contentText(result.content);
|
|
139
|
+
if (result.isError)
|
|
140
|
+
return { summary: "error" };
|
|
141
|
+
if (action === "search") {
|
|
142
|
+
if (text.startsWith("No results"))
|
|
143
|
+
return { summary: "0 matches" };
|
|
144
|
+
const m = text.match(/^Found (\d+)/);
|
|
145
|
+
return { summary: m ? `${m[1]} matches` : "search done" };
|
|
146
|
+
}
|
|
147
|
+
if (action === "browse") {
|
|
148
|
+
if (text.startsWith("No conversation"))
|
|
149
|
+
return { summary: "empty" };
|
|
150
|
+
return { summary: "browsed" };
|
|
151
|
+
}
|
|
152
|
+
if (text.includes("no expanded content"))
|
|
153
|
+
return { summary: "not found" };
|
|
154
|
+
return { summary: "expanded" };
|
|
155
|
+
},
|
|
156
|
+
getDisplayInfo: () => ({ kind: "search", icon: "⟲" }),
|
|
157
|
+
};
|
|
158
|
+
if (ctx.agent) {
|
|
159
|
+
ctx.agent.registerTool(toolDef);
|
|
160
|
+
ctx.agent.registerInstruction(INSTRUCTION_NAME, INSTRUCTION_TEXT);
|
|
161
|
+
}
|
|
162
|
+
ctx.registerCommand("history", "Toggle conversation history writes (on / off / status).", (args) => {
|
|
163
|
+
const arg = args.trim().toLowerCase();
|
|
164
|
+
if (arg === "" || arg === "status") {
|
|
165
|
+
ctx.bus.emit("ui:info", { message: `history: writes ${enabled ? "on" : "off"} — recall remains available for prior sessions` });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (arg === "on") {
|
|
169
|
+
if (enabled) {
|
|
170
|
+
ctx.bus.emit("ui:info", { message: "history: already on" });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
enabled = true;
|
|
174
|
+
ctx.bus.emit("ui:info", { message: "history: on — new turns will be summarized" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (arg === "off") {
|
|
178
|
+
if (!enabled) {
|
|
179
|
+
ctx.bus.emit("ui:info", { message: "history: already off" });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
enabled = false;
|
|
183
|
+
ctx.bus.emit("ui:info", { message: "history: off — new turns won't be summarized (recall still available)" });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
ctx.bus.emit("ui:info", { message: `history: unknown arg "${arg}" (use on / off / status)` });
|
|
187
|
+
});
|
|
188
|
+
if (prefetchEntries > 0) {
|
|
189
|
+
Promise.resolve().then(async () => {
|
|
190
|
+
const lines = await readSummaryLines(summaryStore, prefetchEntries);
|
|
191
|
+
if (lines.length === 0)
|
|
192
|
+
return;
|
|
193
|
+
const current = ctx.call("conversation:get-messages") ?? [];
|
|
194
|
+
ctx.call("conversation:replace-messages", [
|
|
195
|
+
...current,
|
|
196
|
+
{
|
|
197
|
+
role: "user",
|
|
198
|
+
content: `[Prior session history — loaded from the summary store]\n${lines.join("\n")}`,
|
|
199
|
+
},
|
|
200
|
+
]);
|
|
201
|
+
}).catch(() => { });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Store } from "../../store.js";
|
|
2
|
+
export declare function recallSearch(store: Store, query: string): Promise<string>;
|
|
3
|
+
export declare function recallExpand(store: Store, id: string): Promise<string>;
|
|
4
|
+
export declare function recallBrowse(store: Store, limit?: number): Promise<string>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { formatEntryLine } from "../../entry-format.js";
|
|
2
|
+
import { RECALL_CACHE_KIND } from "./constants.js";
|
|
3
|
+
import { readSummaryLines } from "./strategy.js";
|
|
4
|
+
function turnToText(msgs) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
for (const m of msgs) {
|
|
7
|
+
if (m.role === "user") {
|
|
8
|
+
lines.push(`[user] ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`);
|
|
9
|
+
}
|
|
10
|
+
else if (m.role === "assistant") {
|
|
11
|
+
if (typeof m.content === "string" && m.content)
|
|
12
|
+
lines.push(`[assistant] ${m.content}`);
|
|
13
|
+
if ("tool_calls" in m && m.tool_calls) {
|
|
14
|
+
for (const tc of m.tool_calls) {
|
|
15
|
+
if ("function" in tc)
|
|
16
|
+
lines.push(`[tool_call] ${tc.function.name}(${tc.function.arguments})`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
else if (m.role === "tool") {
|
|
21
|
+
lines.push(`[tool] ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
lines.push(`[${m.role}] ${typeof m.content === "string" ? m.content : JSON.stringify(m.content)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return lines.join("\n");
|
|
28
|
+
}
|
|
29
|
+
function firstMatchExcerpt(text, regex) {
|
|
30
|
+
const idx = text.search(regex);
|
|
31
|
+
if (idx === -1)
|
|
32
|
+
return null;
|
|
33
|
+
const lineStart = text.lastIndexOf("\n", idx) + 1;
|
|
34
|
+
const lineEnd = text.indexOf("\n", idx);
|
|
35
|
+
const line = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd).trim();
|
|
36
|
+
if (line.length > 120) {
|
|
37
|
+
const matchInLine = idx - lineStart;
|
|
38
|
+
const start = Math.max(0, matchInLine - 40);
|
|
39
|
+
const end = Math.min(line.length, matchInLine + 80);
|
|
40
|
+
return (start > 0 ? "…" : "") + line.slice(start, end) + (end < line.length ? "…" : "");
|
|
41
|
+
}
|
|
42
|
+
return line;
|
|
43
|
+
}
|
|
44
|
+
function buildSearchRegex(query) {
|
|
45
|
+
try {
|
|
46
|
+
return new RegExp(query, "i");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
const words = query.split(/\s+/).filter((w) => w.length > 0);
|
|
50
|
+
const escaped = words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
51
|
+
const lookaheads = escaped.map((w) => `(?=.*${w})`).join("");
|
|
52
|
+
return new RegExp(lookaheads, "i");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Cache entries are ephemeral, so this only resolves for the
|
|
56
|
+
* current process. */
|
|
57
|
+
async function findCacheChild(store, parentId) {
|
|
58
|
+
const recent = await store.readRecent();
|
|
59
|
+
for (let i = recent.length - 1; i >= 0; i--) {
|
|
60
|
+
const e = recent[i];
|
|
61
|
+
if (e.kind === RECALL_CACHE_KIND && e.parentId === parentId) {
|
|
62
|
+
return e.payload;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
export async function recallSearch(store, query) {
|
|
68
|
+
if (!query.trim())
|
|
69
|
+
return "No query provided.";
|
|
70
|
+
const regex = buildSearchRegex(query);
|
|
71
|
+
const hits = [];
|
|
72
|
+
const seenParents = new Set();
|
|
73
|
+
const matches = await store.search(query);
|
|
74
|
+
for (const m of matches) {
|
|
75
|
+
// Cache hits surface via their parent summary; summary hits are their own parent.
|
|
76
|
+
let parentEntry = null;
|
|
77
|
+
if (m.entry.kind === RECALL_CACHE_KIND) {
|
|
78
|
+
if (!m.entry.parentId)
|
|
79
|
+
continue;
|
|
80
|
+
parentEntry = await store.findById(m.entry.parentId);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
parentEntry = m.entry;
|
|
84
|
+
}
|
|
85
|
+
if (!parentEntry || seenParents.has(parentEntry.id))
|
|
86
|
+
continue;
|
|
87
|
+
seenParents.add(parentEntry.id);
|
|
88
|
+
const cache = await findCacheChild(store, parentEntry.id);
|
|
89
|
+
const excerptSource = cache
|
|
90
|
+
? turnToText([cache.fullMessage])
|
|
91
|
+
: parentEntry.payload.body ?? "";
|
|
92
|
+
const excerpt = excerptSource ? firstMatchExcerpt(excerptSource, regex) : null;
|
|
93
|
+
const header = formatEntryLine(parentEntry);
|
|
94
|
+
hits.push(excerpt ? `${header}\n ${excerpt}` : header);
|
|
95
|
+
}
|
|
96
|
+
if (hits.length === 0)
|
|
97
|
+
return `No results found for "${query}".`;
|
|
98
|
+
const total = hits.length;
|
|
99
|
+
const summary = `Found ${total} match${total === 1 ? "" : "es"} for "${query}"`;
|
|
100
|
+
return `${summary}\n\n${hits.slice(0, 30).join("\n\n")}`;
|
|
101
|
+
}
|
|
102
|
+
export async function recallExpand(store, id) {
|
|
103
|
+
const entry = await store.findById(id);
|
|
104
|
+
if (!entry)
|
|
105
|
+
return `Entry ${id}: not found.`;
|
|
106
|
+
if (entry.kind === RECALL_CACHE_KIND)
|
|
107
|
+
return `Entry ${id}: not expandable.`;
|
|
108
|
+
const header = formatEntryLine(entry);
|
|
109
|
+
const cache = await findCacheChild(store, id);
|
|
110
|
+
if (cache)
|
|
111
|
+
return `${header}\n\n${turnToText([cache.fullMessage])}`;
|
|
112
|
+
const body = entry.payload.body;
|
|
113
|
+
if (body)
|
|
114
|
+
return `${header}\n\n${body}`;
|
|
115
|
+
return `${header}\n\n(no expanded content available — recall cache may have been cleared)`;
|
|
116
|
+
}
|
|
117
|
+
export async function recallBrowse(store, limit = 25) {
|
|
118
|
+
const lines = await readSummaryLines(store, limit);
|
|
119
|
+
if (lines.length === 0)
|
|
120
|
+
return "No conversation history.";
|
|
121
|
+
return ["Recent summary entries:", ...lines.map((l) => ` ${l}`)].join("\n");
|
|
122
|
+
}
|