@nghyane/arcane 0.1.16 → 0.1.17
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/CHANGELOG.md +21 -0
- package/package.json +7 -15
- package/src/config/settings-schema.ts +19 -46
- package/src/config/settings.ts +0 -1
- package/src/exa/mcp-client.ts +57 -2
- package/src/internal-urls/docs-index.generated.ts +1 -2
- package/src/internal-urls/index.ts +2 -4
- package/src/internal-urls/router.ts +2 -2
- package/src/internal-urls/types.ts +2 -2
- package/src/mcp/oauth-flow.ts +1 -1
- package/src/modes/controllers/command-controller.ts +4 -44
- package/src/patch/hashline.ts +42 -0
- package/src/prompts/system/system-prompt.md +14 -10
- package/src/prompts/thread-extract.md +16 -0
- package/src/prompts/tools/render-mermaid.md +9 -0
- package/src/sdk.ts +1 -19
- package/src/session/agent-session.ts +4 -3
- package/src/session/retry-utils.ts +1 -1
- package/src/session/session-index.ts +329 -0
- package/src/slash-commands/builtin-registry.ts +0 -16
- package/src/task/index.ts +1 -1
- package/src/tools/ask.ts +9 -6
- package/src/tools/bash-skill-urls.ts +3 -3
- package/src/tools/create-tools.ts +26 -0
- package/src/tools/find-thread.ts +120 -0
- package/src/tools/index.ts +5 -0
- package/src/tools/read-thread.ts +409 -0
- package/src/tools/read.ts +2 -2
- package/src/tools/render-mermaid.ts +68 -0
- package/src/tools/save-memory.ts +182 -0
- package/src/web/search/index.ts +2 -0
- package/src/web/search/provider.ts +3 -0
- package/src/web/search/providers/anthropic.ts +1 -0
- package/src/web/search/providers/gemini.ts +122 -37
- package/src/web/search/providers/kagi.ts +163 -0
- package/src/web/search/types.ts +1 -0
- package/src/internal-urls/memory-protocol.ts +0 -133
- package/src/memories/index.ts +0 -1099
- package/src/memories/storage.ts +0 -563
- package/src/prompts/memories/consolidation.md +0 -30
- package/src/prompts/memories/read_path.md +0 -11
- package/src/prompts/memories/stage_one_input.md +0 -6
- package/src/prompts/memories/stage_one_system.md +0 -21
package/src/patch/hashline.ts
CHANGED
|
@@ -566,6 +566,37 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
|
|
566
566
|
// Edit Application
|
|
567
567
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
568
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Detect suspicious Unicode escape placeholders in edit lines.
|
|
571
|
+
* LLMs sometimes emit literal `\uDDDD` strings instead of actual Unicode characters.
|
|
572
|
+
* Returns a warning message if detected, undefined otherwise.
|
|
573
|
+
*/
|
|
574
|
+
function detectUnicodeEscapePlaceholders(lines: string[]): string | undefined {
|
|
575
|
+
for (const line of lines) {
|
|
576
|
+
if (/\\u[0-9A-Fa-f]{4}/.test(line)) {
|
|
577
|
+
return "Warning: edit content contains literal Unicode escape sequences (\\uXXXX). These may be intended as actual Unicode characters.";
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Auto-correct escaped tab indentation in edit lines.
|
|
585
|
+
* When enabled via ARCANE_HASHLINE_AUTOCORRECT_ESCAPED_TABS=1, replaces
|
|
586
|
+
* leading `\\t` sequences (literal backslash-t from JSON) with real tab characters.
|
|
587
|
+
*/
|
|
588
|
+
function autocorrectEscapedTabs(lines: string[]): string[] {
|
|
589
|
+
if (Bun.env.ARCANE_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "1") {
|
|
590
|
+
return lines;
|
|
591
|
+
}
|
|
592
|
+
return lines.map(line => {
|
|
593
|
+
const match = line.match(/^((?:\\t)+)/);
|
|
594
|
+
if (!match) return line;
|
|
595
|
+
const tabCount = match[1].length / 2; // each \\t is 2 chars
|
|
596
|
+
return "\t".repeat(tabCount) + line.slice(match[1].length);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
569
600
|
/**
|
|
570
601
|
* Apply an array of hashline edits to file content.
|
|
571
602
|
*
|
|
@@ -599,6 +630,16 @@ export function applyHashlineEdits(
|
|
|
599
630
|
|
|
600
631
|
const autocorrect = Bun.env.ARCANE_HL_AUTOCORRECT === "1";
|
|
601
632
|
|
|
633
|
+
// Collect warnings and auto-correct edit content
|
|
634
|
+
const warnings: string[] = [];
|
|
635
|
+
for (const edit of edits) {
|
|
636
|
+
const unicodeWarning = detectUnicodeEscapePlaceholders(edit.content);
|
|
637
|
+
if (unicodeWarning && !warnings.includes(unicodeWarning)) {
|
|
638
|
+
warnings.push(unicodeWarning);
|
|
639
|
+
}
|
|
640
|
+
edit.content = autocorrectEscapedTabs(edit.content);
|
|
641
|
+
}
|
|
642
|
+
|
|
602
643
|
function collectExplicitlyTouchedLines(): Set<number> {
|
|
603
644
|
const touched = new Set<number>();
|
|
604
645
|
for (const edit of edits) {
|
|
@@ -914,6 +955,7 @@ export function applyHashlineEdits(
|
|
|
914
955
|
return {
|
|
915
956
|
content: finalContent,
|
|
916
957
|
firstChangedLine,
|
|
958
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
917
959
|
...(noopEdits.length > 0 ? { noopEdits } : {}),
|
|
918
960
|
};
|
|
919
961
|
|
|
@@ -162,6 +162,20 @@ Best practices:
|
|
|
162
162
|
- Run multiple sub-agents concurrently if tasks are independent with disjoint write targets.
|
|
163
163
|
{{/has}}
|
|
164
164
|
|
|
165
|
+
### Cross-session Knowledge
|
|
166
|
+
|
|
167
|
+
Tools: `find_thread`, `read_thread`, `save_memory`
|
|
168
|
+
**Proactive search triggers** — use `find_thread` when:
|
|
169
|
+
- User mentions past work: "we did this before", "last time", "in a previous session"
|
|
170
|
+
- User asks "what did we do about X" or "how did we solve Y"
|
|
171
|
+
- Task seems related to work that may have been done before
|
|
172
|
+
- Handoff context references a parent thread and you need more detail
|
|
173
|
+
**Do NOT search when:**
|
|
174
|
+
- Question is about current session context
|
|
175
|
+
- Generic coding question with no project-specific history
|
|
176
|
+
- User explicitly provides all needed context
|
|
177
|
+
**save_memory**: only when user says "remember this" or states a clear preference. If unsure, ask.
|
|
178
|
+
|
|
165
179
|
### Verification
|
|
166
180
|
After completing changes, verify using commands from AGENTS.md or the project's config. Format → typecheck/lint → test (if relevant) → build (if required).
|
|
167
181
|
Report evidence concisely: counts, pass/fail, error summary.
|
|
@@ -252,16 +266,6 @@ Scan descriptions vs task domain — read skill if ≥50% likely relevant.
|
|
|
252
266
|
</rules>
|
|
253
267
|
{{/if}}
|
|
254
268
|
|
|
255
|
-
{{#if memories.length}}
|
|
256
|
-
<memories>
|
|
257
|
-
{{#each memories}}
|
|
258
|
-
<memory path="{{path}}">
|
|
259
|
-
{{content}}
|
|
260
|
-
</memory>
|
|
261
|
-
{{/each}}
|
|
262
|
-
</memories>
|
|
263
|
-
{{/if}}
|
|
264
|
-
|
|
265
269
|
{{#if preloadedSkills.length}}
|
|
266
270
|
{{#each preloadedSkills}}
|
|
267
271
|
<skill name="{{name}}">
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
You are helping extract relevant information from a conversation thread based on a goal.
|
|
2
|
+
|
|
3
|
+
## Task
|
|
4
|
+
|
|
5
|
+
I am providing a conversation thread rendered as markdown, along with a goal describing what information to extract.
|
|
6
|
+
|
|
7
|
+
Your job is to:
|
|
8
|
+
1. Analyze the thread content
|
|
9
|
+
2. Identify information that is relevant to the goal
|
|
10
|
+
3. Extract and preserve those relevant parts with full fidelity
|
|
11
|
+
|
|
12
|
+
## Rules
|
|
13
|
+
- Be concise but complete — include all relevant details
|
|
14
|
+
- Preserve code snippets, file paths, commands, and decisions exactly as they appear
|
|
15
|
+
- Omit pleasantries, failed attempts, and thinking-out-loud unless the goal asks for them
|
|
16
|
+
- If nothing relevant is found, say so briefly
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Convert Mermaid graph source into ASCII diagram output.
|
|
2
|
+
|
|
3
|
+
Parameters:
|
|
4
|
+
- `mermaid` (required): Mermaid graph text to render.
|
|
5
|
+
- `config` (optional): JSON render configuration (spacing and layout options).
|
|
6
|
+
Behavior:
|
|
7
|
+
- Returns ASCII diagram text.
|
|
8
|
+
- Saves full ASCII output to an artifact URL (`artifact://<id>`) when artifact storage is available.
|
|
9
|
+
- Returns an error when the Mermaid input is invalid or rendering fails.
|
package/src/sdk.ts
CHANGED
|
@@ -42,13 +42,11 @@ import {
|
|
|
42
42
|
ArtifactProtocolHandler,
|
|
43
43
|
DocsProtocolHandler,
|
|
44
44
|
InternalUrlRouter,
|
|
45
|
-
MemoryProtocolHandler,
|
|
46
45
|
RuleProtocolHandler,
|
|
47
46
|
SkillProtocolHandler,
|
|
48
47
|
} from "./internal-urls";
|
|
49
48
|
import { disposeAllKernelSessions } from "./ipy/executor";
|
|
50
49
|
import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp";
|
|
51
|
-
import { buildMemoryToolDeveloperInstructions, getMemoryRoot, startMemoryStartupTask } from "./memories";
|
|
52
50
|
import { collectEnvSecrets, loadSecrets, obfuscateMessages, SecretObfuscator } from "./secrets";
|
|
53
51
|
import { AgentSession } from "./session/agent-session";
|
|
54
52
|
import { AuthStorage } from "./session/auth-storage";
|
|
@@ -735,7 +733,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
735
733
|
settings,
|
|
736
734
|
};
|
|
737
735
|
|
|
738
|
-
// Initialize internal URL router for internal protocols (agent://, artifact://,
|
|
736
|
+
// Initialize internal URL router for internal protocols (agent://, artifact://, skill://, rule://)
|
|
739
737
|
const internalRouter = new InternalUrlRouter();
|
|
740
738
|
const getArtifactsDir = () => {
|
|
741
739
|
const sessionFile = sessionManager.getSessionFile();
|
|
@@ -743,11 +741,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
743
741
|
};
|
|
744
742
|
internalRouter.register(new AgentProtocolHandler({ getArtifactsDir }));
|
|
745
743
|
internalRouter.register(new ArtifactProtocolHandler({ getArtifactsDir }));
|
|
746
|
-
internalRouter.register(
|
|
747
|
-
new MemoryProtocolHandler({
|
|
748
|
-
getMemoryRoot: () => getMemoryRoot(agentDir, settings.getCwd()),
|
|
749
|
-
}),
|
|
750
|
-
);
|
|
751
744
|
internalRouter.register(
|
|
752
745
|
new SkillProtocolHandler({
|
|
753
746
|
getSkills: () => skills,
|
|
@@ -1027,7 +1020,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1027
1020
|
|
|
1028
1021
|
const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
|
|
1029
1022
|
toolContextStore.setToolNames(toolNames);
|
|
1030
|
-
const memoryInstructions = await buildMemoryToolDeveloperInstructions(agentDir, settings);
|
|
1031
1023
|
const defaultPrompt = await buildSystemPromptInternal({
|
|
1032
1024
|
cwd,
|
|
1033
1025
|
skills,
|
|
@@ -1037,7 +1029,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1037
1029
|
toolNames,
|
|
1038
1030
|
rules: rulebookRules,
|
|
1039
1031
|
skillsSettings: settings.getGroup("skills") as SkillsSettings,
|
|
1040
|
-
appendSystemPrompt: memoryInstructions,
|
|
1041
1032
|
});
|
|
1042
1033
|
|
|
1043
1034
|
if (options.systemPrompt === undefined) {
|
|
@@ -1054,7 +1045,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1054
1045
|
rules: rulebookRules,
|
|
1055
1046
|
skillsSettings: settings.getGroup("skills") as SkillsSettings,
|
|
1056
1047
|
customPrompt: options.systemPrompt,
|
|
1057
|
-
appendSystemPrompt: memoryInstructions,
|
|
1058
1048
|
});
|
|
1059
1049
|
}
|
|
1060
1050
|
return options.systemPrompt(defaultPrompt);
|
|
@@ -1258,14 +1248,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1258
1248
|
}
|
|
1259
1249
|
}
|
|
1260
1250
|
|
|
1261
|
-
startMemoryStartupTask({
|
|
1262
|
-
session,
|
|
1263
|
-
settings,
|
|
1264
|
-
modelRegistry,
|
|
1265
|
-
agentDir,
|
|
1266
|
-
isSubagent,
|
|
1267
|
-
});
|
|
1268
|
-
|
|
1269
1251
|
return {
|
|
1270
1252
|
session,
|
|
1271
1253
|
extensionsResult,
|
|
@@ -2067,9 +2067,10 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
2067
2067
|
return undefined;
|
|
2068
2068
|
}
|
|
2069
2069
|
|
|
2070
|
-
// Start a new session
|
|
2070
|
+
// Start a new session with parent reference
|
|
2071
|
+
const parentThreadId = this.sessionManager.getSessionId();
|
|
2071
2072
|
await this.sessionManager.flush();
|
|
2072
|
-
await this.sessionManager.newSession();
|
|
2073
|
+
await this.sessionManager.newSession({ parentSession: parentThreadId });
|
|
2073
2074
|
this.agent.reset();
|
|
2074
2075
|
this.agent.sessionId = this.sessionManager.getSessionId();
|
|
2075
2076
|
this.#steeringMessages = [];
|
|
@@ -2078,7 +2079,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
2078
2079
|
this.#todoReminderCount = 0;
|
|
2079
2080
|
|
|
2080
2081
|
// Inject the handoff document as a custom message
|
|
2081
|
-
const handoffContent = `<handoff-context>\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from
|
|
2082
|
+
const handoffContent = `<handoff-context thread="${parentThreadId}">\n${handoffText}\n</handoff-context>\n\nThe above is a handoff document from thread \`${parentThreadId}\`. Use this context to continue the work seamlessly. If you need additional details not covered above, use \`read_thread("${parentThreadId}", "your specific question")\` to query the original session.`;
|
|
2082
2083
|
this.sessionManager.appendCustomMessageEntry("handoff", handoffContent, true);
|
|
2083
2084
|
|
|
2084
2085
|
// Rebuild agent messages from session
|
|
@@ -17,7 +17,7 @@ export function isRetryableErrorMessage(errorMessage: string): boolean {
|
|
|
17
17
|
* Check if an error message indicates a usage/billing limit (non-transient).
|
|
18
18
|
*/
|
|
19
19
|
export function isUsageLimitErrorMessage(errorMessage: string): boolean {
|
|
20
|
-
return /usage.?limit|usage_limit_reached|limit_reached/i.test(errorMessage);
|
|
20
|
+
return /usage.?limit|usage_limit_reached|limit_reached|quota.?exhaust/i.test(errorMessage);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { Database, type Statement } from "bun:sqlite";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { logger, parseJsonlLenient } from "@nghyane/arcane-utils";
|
|
5
|
+
import { getAgentDir } from "@nghyane/arcane-utils/dirs";
|
|
6
|
+
|
|
7
|
+
export interface SessionIndexEntry {
|
|
8
|
+
sessionId: string;
|
|
9
|
+
title: string;
|
|
10
|
+
firstMessage: string;
|
|
11
|
+
files: string;
|
|
12
|
+
cwd: string;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
messageCount: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SessionSearchResult {
|
|
18
|
+
threadId: string;
|
|
19
|
+
title: string;
|
|
20
|
+
date: string;
|
|
21
|
+
messageCount: number;
|
|
22
|
+
snippet: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SearchRow {
|
|
26
|
+
session_id: string;
|
|
27
|
+
title: string;
|
|
28
|
+
created_at: number;
|
|
29
|
+
message_count: number;
|
|
30
|
+
snippet: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FILE_PATH_PARAMS = new Set(["path", "file", "filePath", "glob", "pattern", "command_working_directory"]);
|
|
34
|
+
|
|
35
|
+
export class SessionIndex {
|
|
36
|
+
#db: Database;
|
|
37
|
+
static #instance?: SessionIndex;
|
|
38
|
+
|
|
39
|
+
#upsertStmt: Statement;
|
|
40
|
+
#hasStmt: Statement;
|
|
41
|
+
|
|
42
|
+
private constructor(dbPath: string) {
|
|
43
|
+
const dir = path.dirname(dbPath);
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
this.#db = new Database(dbPath);
|
|
47
|
+
|
|
48
|
+
this.#db.exec(`
|
|
49
|
+
PRAGMA journal_mode=WAL;
|
|
50
|
+
PRAGMA synchronous=NORMAL;
|
|
51
|
+
PRAGMA busy_timeout=5000;
|
|
52
|
+
|
|
53
|
+
CREATE TABLE IF NOT EXISTS session_index (
|
|
54
|
+
session_id TEXT PRIMARY KEY,
|
|
55
|
+
title TEXT,
|
|
56
|
+
first_message TEXT,
|
|
57
|
+
files TEXT,
|
|
58
|
+
cwd TEXT,
|
|
59
|
+
created_at INTEGER,
|
|
60
|
+
message_count INTEGER
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS session_fts USING fts5(
|
|
64
|
+
title, first_message, files,
|
|
65
|
+
content='session_index', content_rowid='rowid'
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE TRIGGER IF NOT EXISTS session_index_ai AFTER INSERT ON session_index BEGIN
|
|
69
|
+
INSERT INTO session_fts(rowid, title, first_message, files)
|
|
70
|
+
VALUES (new.rowid, new.title, new.first_message, new.files);
|
|
71
|
+
END;
|
|
72
|
+
|
|
73
|
+
CREATE TRIGGER IF NOT EXISTS session_index_ad AFTER DELETE ON session_index BEGIN
|
|
74
|
+
INSERT INTO session_fts(session_fts, rowid, title, first_message, files)
|
|
75
|
+
VALUES ('delete', old.rowid, old.title, old.first_message, old.files);
|
|
76
|
+
END;
|
|
77
|
+
|
|
78
|
+
CREATE TRIGGER IF NOT EXISTS session_index_au AFTER UPDATE ON session_index BEGIN
|
|
79
|
+
INSERT INTO session_fts(session_fts, rowid, title, first_message, files)
|
|
80
|
+
VALUES ('delete', old.rowid, old.title, old.first_message, old.files);
|
|
81
|
+
INSERT INTO session_fts(rowid, title, first_message, files)
|
|
82
|
+
VALUES (new.rowid, new.title, new.first_message, new.files);
|
|
83
|
+
END;
|
|
84
|
+
`);
|
|
85
|
+
|
|
86
|
+
this.#upsertStmt = this.#db.prepare(
|
|
87
|
+
"INSERT OR REPLACE INTO session_index (session_id, title, first_message, files, cwd, created_at, message_count) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
88
|
+
);
|
|
89
|
+
this.#hasStmt = this.#db.prepare("SELECT 1 FROM session_index WHERE session_id = ?");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static open(dbPath: string = path.join(getAgentDir(), "session-index.db")): SessionIndex {
|
|
93
|
+
if (!SessionIndex.#instance) {
|
|
94
|
+
SessionIndex.#instance = new SessionIndex(dbPath);
|
|
95
|
+
}
|
|
96
|
+
return SessionIndex.#instance;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
upsert(entry: SessionIndexEntry): void {
|
|
100
|
+
try {
|
|
101
|
+
this.#upsertStmt.run(
|
|
102
|
+
entry.sessionId,
|
|
103
|
+
entry.title,
|
|
104
|
+
entry.firstMessage,
|
|
105
|
+
entry.files,
|
|
106
|
+
entry.cwd,
|
|
107
|
+
entry.createdAt,
|
|
108
|
+
entry.messageCount,
|
|
109
|
+
);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
logger.error("SessionIndex upsert failed", { error: String(error) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
has(sessionId: string): boolean {
|
|
116
|
+
return this.#hasStmt.get(sessionId) != null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
search(query: string, limit: number): SessionSearchResult[] {
|
|
120
|
+
const safeLimit = Math.min(Math.max(1, Math.floor(limit)), 50);
|
|
121
|
+
const { ftsQuery, afterTs, beforeTs } = this.#parseQuery(query);
|
|
122
|
+
if (!ftsQuery) return [];
|
|
123
|
+
|
|
124
|
+
const conditions = ["session_fts MATCH ?"];
|
|
125
|
+
const params: (string | number)[] = [ftsQuery];
|
|
126
|
+
|
|
127
|
+
if (afterTs != null) {
|
|
128
|
+
conditions.push("si.created_at >= ?");
|
|
129
|
+
params.push(afterTs);
|
|
130
|
+
}
|
|
131
|
+
if (beforeTs != null) {
|
|
132
|
+
conditions.push("si.created_at <= ?");
|
|
133
|
+
params.push(beforeTs);
|
|
134
|
+
}
|
|
135
|
+
params.push(safeLimit);
|
|
136
|
+
|
|
137
|
+
const sql = `SELECT si.session_id, si.title, si.created_at, si.message_count,
|
|
138
|
+
snippet(session_fts, -1, '<match>', '</match>', '...', 32) as snippet
|
|
139
|
+
FROM session_fts f
|
|
140
|
+
JOIN session_index si ON si.rowid = f.rowid
|
|
141
|
+
WHERE ${conditions.join(" AND ")}
|
|
142
|
+
ORDER BY si.created_at DESC
|
|
143
|
+
LIMIT ?`;
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const rows = this.#db.prepare(sql).all(...params) as SearchRow[];
|
|
147
|
+
return rows.map(row => ({
|
|
148
|
+
threadId: row.session_id,
|
|
149
|
+
title: row.title || "Untitled",
|
|
150
|
+
date: new Date(row.created_at * 1000).toISOString().slice(0, 10),
|
|
151
|
+
messageCount: row.message_count,
|
|
152
|
+
snippet: row.snippet || "",
|
|
153
|
+
}));
|
|
154
|
+
} catch (error) {
|
|
155
|
+
logger.error("SessionIndex search failed", { error: String(error) });
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async indexSessionFile(filePath: string): Promise<void> {
|
|
161
|
+
try {
|
|
162
|
+
const content = await Bun.file(filePath).text();
|
|
163
|
+
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
164
|
+
if (entries.length === 0) return;
|
|
165
|
+
|
|
166
|
+
const header = entries.find(e => e.type === "session") as
|
|
167
|
+
| { type: string; id?: string; title?: string; cwd?: string; timestamp?: string }
|
|
168
|
+
| undefined;
|
|
169
|
+
if (!header?.id) return;
|
|
170
|
+
|
|
171
|
+
let firstMessage = "";
|
|
172
|
+
let messageCount = 0;
|
|
173
|
+
const fileSet = new Set<string>();
|
|
174
|
+
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
if (entry.type !== "message") continue;
|
|
177
|
+
const msg = entry.message as { role?: string; content?: unknown } | undefined;
|
|
178
|
+
if (!msg?.role) continue;
|
|
179
|
+
|
|
180
|
+
messageCount++;
|
|
181
|
+
|
|
182
|
+
if (msg.role === "user" && !firstMessage) {
|
|
183
|
+
firstMessage = extractTextContent(msg.content);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (msg.role === "assistant") {
|
|
187
|
+
extractFilePaths(msg.content, fileSet);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const title = header.title || firstMessage.slice(0, 100) || "Untitled";
|
|
192
|
+
const createdAt = header.timestamp ? Math.floor(new Date(header.timestamp).getTime() / 1000) : 0;
|
|
193
|
+
|
|
194
|
+
this.upsert({
|
|
195
|
+
sessionId: header.id,
|
|
196
|
+
title,
|
|
197
|
+
firstMessage: firstMessage.slice(0, 500),
|
|
198
|
+
files: [...fileSet].join(" "),
|
|
199
|
+
cwd: header.cwd || "",
|
|
200
|
+
createdAt,
|
|
201
|
+
messageCount,
|
|
202
|
+
});
|
|
203
|
+
} catch (error) {
|
|
204
|
+
logger.warn("SessionIndex indexSessionFile failed", { path: filePath, error: String(error) });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async indexAllSessions(sessionsDir?: string): Promise<void> {
|
|
209
|
+
const dir = sessionsDir ?? path.join(getAgentDir(), "sessions");
|
|
210
|
+
let subdirs: string[];
|
|
211
|
+
try {
|
|
212
|
+
subdirs = fs.readdirSync(dir);
|
|
213
|
+
} catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const subdir of subdirs) {
|
|
218
|
+
const subdirPath = path.join(dir, subdir);
|
|
219
|
+
let stat: fs.Stats;
|
|
220
|
+
try {
|
|
221
|
+
stat = fs.statSync(subdirPath);
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!stat.isDirectory()) continue;
|
|
226
|
+
|
|
227
|
+
let files: string[];
|
|
228
|
+
try {
|
|
229
|
+
files = fs.readdirSync(subdirPath).filter(f => f.endsWith(".jsonl"));
|
|
230
|
+
} catch {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const file of files) {
|
|
235
|
+
const filePath = path.join(subdirPath, file);
|
|
236
|
+
try {
|
|
237
|
+
const firstLine = await Bun.file(filePath).text();
|
|
238
|
+
const headerLine = firstLine.split("\n")[0];
|
|
239
|
+
if (!headerLine) continue;
|
|
240
|
+
const header = JSON.parse(headerLine) as { id?: string };
|
|
241
|
+
if (!header.id) continue;
|
|
242
|
+
if (this.has(header.id)) continue;
|
|
243
|
+
await this.indexSessionFile(filePath);
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#parseQuery(query: string): { ftsQuery: string | null; afterTs: number | null; beforeTs: number | null } {
|
|
250
|
+
let afterTs: number | null = null;
|
|
251
|
+
let beforeTs: number | null = null;
|
|
252
|
+
|
|
253
|
+
const remaining = query.replace(/\b(after|before):(\S+)/g, (_, dir: string, val: string) => {
|
|
254
|
+
const ts = this.#parseDate(val);
|
|
255
|
+
if (ts != null) {
|
|
256
|
+
if (dir === "after") afterTs = ts;
|
|
257
|
+
else beforeTs = ts;
|
|
258
|
+
}
|
|
259
|
+
return "";
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const ftsQuery = this.#buildFtsQuery(remaining);
|
|
263
|
+
return { ftsQuery, afterTs, beforeTs };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#parseDate(value: string): number | null {
|
|
267
|
+
const relMatch = value.match(/^(\d+)([dwm])$/);
|
|
268
|
+
if (relMatch) {
|
|
269
|
+
const n = Number.parseInt(relMatch[1], 10);
|
|
270
|
+
const unit = relMatch[2];
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
let ms = 0;
|
|
273
|
+
if (unit === "d") ms = n * 86400_000;
|
|
274
|
+
else if (unit === "w") ms = n * 7 * 86400_000;
|
|
275
|
+
else if (unit === "m") ms = n * 30 * 86400_000;
|
|
276
|
+
return Math.floor((now - ms) / 1000);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const d = new Date(value);
|
|
280
|
+
if (!Number.isNaN(d.getTime())) {
|
|
281
|
+
return Math.floor(d.getTime() / 1000);
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#buildFtsQuery(query: string): string | null {
|
|
287
|
+
const tokens = query
|
|
288
|
+
.trim()
|
|
289
|
+
.split(/\s+/)
|
|
290
|
+
.map(t => t.trim())
|
|
291
|
+
.filter(Boolean);
|
|
292
|
+
|
|
293
|
+
if (tokens.length === 0) return null;
|
|
294
|
+
|
|
295
|
+
return tokens
|
|
296
|
+
.map(token => {
|
|
297
|
+
const escaped = token.replace(/"/g, '""');
|
|
298
|
+
return `"${escaped}"*`;
|
|
299
|
+
})
|
|
300
|
+
.join(" ");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function extractTextContent(content: unknown): string {
|
|
305
|
+
if (typeof content === "string") return content;
|
|
306
|
+
if (Array.isArray(content)) {
|
|
307
|
+
for (const block of content) {
|
|
308
|
+
if (block && typeof block === "object" && "type" in block && block.type === "text" && "text" in block) {
|
|
309
|
+
return String(block.text);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return "";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function extractFilePaths(content: unknown, fileSet: Set<string>): void {
|
|
317
|
+
if (!Array.isArray(content)) return;
|
|
318
|
+
for (const block of content) {
|
|
319
|
+
if (!block || typeof block !== "object") continue;
|
|
320
|
+
if (!("type" in block) || block.type !== "toolCall") continue;
|
|
321
|
+
const args = "arguments" in block ? (block.arguments as Record<string, unknown>) : null;
|
|
322
|
+
if (!args) continue;
|
|
323
|
+
for (const [key, val] of Object.entries(args)) {
|
|
324
|
+
if (FILE_PATH_PARAMS.has(key) && typeof val === "string" && val.length > 0) {
|
|
325
|
+
fileSet.add(val);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -349,22 +349,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<BuiltinSlashCommandSpec> = [
|
|
|
349
349
|
runtime.ctx.editor.setText("");
|
|
350
350
|
},
|
|
351
351
|
},
|
|
352
|
-
{
|
|
353
|
-
name: "memory",
|
|
354
|
-
description: "Inspect and operate memory maintenance",
|
|
355
|
-
subcommands: [
|
|
356
|
-
{ name: "view", description: "Show current memory injection payload" },
|
|
357
|
-
{ name: "clear", description: "Clear persisted memory data and artifacts" },
|
|
358
|
-
{ name: "reset", description: "Alias for clear" },
|
|
359
|
-
{ name: "enqueue", description: "Enqueue memory consolidation maintenance" },
|
|
360
|
-
{ name: "rebuild", description: "Alias for enqueue" },
|
|
361
|
-
],
|
|
362
|
-
allowArgs: true,
|
|
363
|
-
handle: async (command, runtime) => {
|
|
364
|
-
runtime.ctx.editor.setText("");
|
|
365
|
-
await runtime.ctx.handleMemoryCommand(command.text);
|
|
366
|
-
},
|
|
367
|
-
},
|
|
368
352
|
{
|
|
369
353
|
name: "move",
|
|
370
354
|
description: "Move session to a different working directory",
|
package/src/task/index.ts
CHANGED
|
@@ -189,7 +189,7 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
|
|
|
189
189
|
modelRegistry: this.session.subagentContext?.modelRegistry,
|
|
190
190
|
settings: this.session.settings,
|
|
191
191
|
mcpManager: this.session.subagentContext?.mcpManager,
|
|
192
|
-
contextFiles: this.session.contextFiles,
|
|
192
|
+
contextFiles: this.session.contextFiles?.filter(f => !f.path.endsWith("AGENTS.md")),
|
|
193
193
|
skills: this.session.skills,
|
|
194
194
|
promptTemplates: this.session.promptTemplates,
|
|
195
195
|
});
|
package/src/tools/ask.ts
CHANGED
|
@@ -111,14 +111,15 @@ interface UIContext {
|
|
|
111
111
|
select(
|
|
112
112
|
prompt: string,
|
|
113
113
|
options: string[],
|
|
114
|
-
options_?: { initialIndex?: number; timeout?: number; outline?: boolean },
|
|
114
|
+
options_?: { initialIndex?: number; timeout?: number; outline?: boolean; signal?: AbortSignal },
|
|
115
115
|
): Promise<string | undefined>;
|
|
116
|
-
input(prompt: string): Promise<string | undefined>;
|
|
116
|
+
input(prompt: string, placeholder?: string, options_?: { signal?: AbortSignal }): Promise<string | undefined>;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
interface AskQuestionOptions {
|
|
120
120
|
/** Timeout in milliseconds, null/undefined to disable */
|
|
121
121
|
timeout?: number | null;
|
|
122
|
+
signal?: AbortSignal;
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
async function askSingleQuestion(
|
|
@@ -158,6 +159,7 @@ async function askSingleQuestion(
|
|
|
158
159
|
initialIndex: cursorIndex,
|
|
159
160
|
timeout: timeout ?? undefined,
|
|
160
161
|
outline: true,
|
|
162
|
+
signal: options?.signal,
|
|
161
163
|
});
|
|
162
164
|
const elapsed = Date.now() - selectionStart;
|
|
163
165
|
const timedOut = timeout != null && elapsed >= timeout;
|
|
@@ -166,7 +168,7 @@ async function askSingleQuestion(
|
|
|
166
168
|
|
|
167
169
|
if (choice === OTHER_OPTION) {
|
|
168
170
|
if (!timedOut) {
|
|
169
|
-
const input = await ui.input("Enter your response:");
|
|
171
|
+
const input = await ui.input("Enter your response:", undefined, { signal: options?.signal });
|
|
170
172
|
if (input) customInput = input;
|
|
171
173
|
}
|
|
172
174
|
break;
|
|
@@ -205,9 +207,10 @@ async function askSingleQuestion(
|
|
|
205
207
|
timeout: timeout ?? undefined,
|
|
206
208
|
initialIndex: recommended,
|
|
207
209
|
outline: true,
|
|
210
|
+
signal: options?.signal,
|
|
208
211
|
});
|
|
209
212
|
if (choice === OTHER_OPTION) {
|
|
210
|
-
const input = await ui.input("Enter your response:");
|
|
213
|
+
const input = await ui.input("Enter your response:", undefined, { signal: options?.signal });
|
|
211
214
|
if (input) customInput = input;
|
|
212
215
|
} else if (choice) {
|
|
213
216
|
selectedOptions = [stripRecommendedSuffix(choice)];
|
|
@@ -300,7 +303,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails, Them
|
|
|
300
303
|
optionLabels,
|
|
301
304
|
q.multi ?? false,
|
|
302
305
|
q.recommended,
|
|
303
|
-
{ timeout },
|
|
306
|
+
{ timeout, signal: _signal },
|
|
304
307
|
);
|
|
305
308
|
|
|
306
309
|
const details: AskToolDetails = {
|
|
@@ -335,7 +338,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails, Them
|
|
|
335
338
|
optionLabels,
|
|
336
339
|
q.multi ?? false,
|
|
337
340
|
q.recommended,
|
|
338
|
-
{ timeout },
|
|
341
|
+
{ timeout, signal: _signal },
|
|
339
342
|
);
|
|
340
343
|
|
|
341
344
|
results.push({
|
|
@@ -9,9 +9,9 @@ const SKILL_URL_PATTERN = /'skill:\/\/[^'\s")`\\]+'|"skill:\/\/[^"\s')`\\]+"|ski
|
|
|
9
9
|
|
|
10
10
|
/** Regex to find supported internal URL tokens in command text. */
|
|
11
11
|
const INTERNAL_URL_PATTERN =
|
|
12
|
-
/'(?:skill|agent|artifact|plan|
|
|
12
|
+
/'(?:skill|agent|artifact|plan|rule):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|rule):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|rule):\/\/[^\s'")`\\]+/g;
|
|
13
13
|
|
|
14
|
-
const SUPPORTED_INTERNAL_SCHEMES = ["skill", "agent", "artifact", "plan", "
|
|
14
|
+
const SUPPORTED_INTERNAL_SCHEMES = ["skill", "agent", "artifact", "plan", "rule"] as const;
|
|
15
15
|
|
|
16
16
|
type SupportedInternalScheme = (typeof SUPPORTED_INTERNAL_SCHEMES)[number];
|
|
17
17
|
|
|
@@ -152,7 +152,7 @@ export function expandSkillUrls(command: string, skills: readonly Skill[]): stri
|
|
|
152
152
|
|
|
153
153
|
/**
|
|
154
154
|
* Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
|
|
155
|
-
* Supported schemes: skill://, agent://, artifact://, plan://,
|
|
155
|
+
* Supported schemes: skill://, agent://, artifact://, plan://, rule://
|
|
156
156
|
*/
|
|
157
157
|
export async function expandInternalUrls(command: string, options: InternalUrlExpansionOptions): Promise<string> {
|
|
158
158
|
if (!command.includes("://")) return command;
|