@nanhara/hara 0.130.2 → 0.131.1
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 +64 -0
- package/README.md +15 -1
- package/dist/agent/loop.js +37 -6
- package/dist/agent/repeat-guard.js +40 -4
- package/dist/context/workspace-scope.js +127 -1
- package/dist/index.js +191 -25
- package/dist/memory/guard.js +21 -0
- package/dist/memory/store.js +33 -2
- package/dist/providers/openai.js +7 -1
- package/dist/recall.js +113 -6
- package/dist/search/hybrid.js +8 -1
- package/dist/search/semindex.js +11 -3
- package/dist/serve/protocol.js +2 -2
- package/dist/serve/server.js +26 -0
- package/dist/session/transfer.js +62 -0
- package/dist/tools/all.js +1 -0
- package/dist/tools/memory.js +44 -17
- package/dist/tools/session-search.js +197 -0
- package/dist/tui/App.js +10 -5
- package/dist/tui/InputBox.js +48 -13
- package/dist/update-check.js +17 -9
- package/dist/update-install.js +219 -0
- package/package.json +1 -1
package/dist/recall.js
CHANGED
|
@@ -8,6 +8,113 @@ import { walkFiles, walkFilesAsync } from "./fs-walk.js";
|
|
|
8
8
|
import { skillsDirs } from "./skills/skills.js";
|
|
9
9
|
import { readModelContextFileSync } from "./fs-read.js";
|
|
10
10
|
const MAX_RECALL_SOURCE_BYTES = 256 * 1024;
|
|
11
|
+
const SEARCH_SNIPPET_CHARS = 800;
|
|
12
|
+
const WORD_STOP = new Set("a an and are can could did do for from i in is it me my of on or our please that the this to was we what when where with you your".split(" "));
|
|
13
|
+
const CJK_STOP = new Set(["之前", "以前", "对话", "会话", "搜索", "查找", "记得", "那个", "这个", "一下", "帮我", "关于", "内容", "讨论"]);
|
|
14
|
+
function normalized(value) {
|
|
15
|
+
return value.normalize("NFKC").toLocaleLowerCase();
|
|
16
|
+
}
|
|
17
|
+
/** Whitespace-only tokenization makes an ordinary Chinese sentence one impossible giant term. Keep
|
|
18
|
+
* ordinary words, and use overlapping Han bigrams so lexical recall remains useful without a tokenizer
|
|
19
|
+
* dependency or embeddings. */
|
|
20
|
+
export function lexicalSearchTerms(query) {
|
|
21
|
+
const segments = [];
|
|
22
|
+
let kind = null;
|
|
23
|
+
let current = "";
|
|
24
|
+
const flush = () => {
|
|
25
|
+
if (kind && current)
|
|
26
|
+
segments.push({ kind, text: current });
|
|
27
|
+
current = "";
|
|
28
|
+
kind = null;
|
|
29
|
+
};
|
|
30
|
+
for (const char of normalized(query)) {
|
|
31
|
+
const nextKind = /^\p{Script=Han}$/u.test(char)
|
|
32
|
+
? "han"
|
|
33
|
+
: /^[\p{L}\p{N}_.+#@-]$/u.test(char)
|
|
34
|
+
? "word"
|
|
35
|
+
: null;
|
|
36
|
+
if (!nextKind) {
|
|
37
|
+
flush();
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (kind && kind !== nextKind)
|
|
41
|
+
flush();
|
|
42
|
+
kind = nextKind;
|
|
43
|
+
current += char;
|
|
44
|
+
}
|
|
45
|
+
flush();
|
|
46
|
+
const terms = new Set();
|
|
47
|
+
const fallbackTerms = new Set();
|
|
48
|
+
for (const segment of segments) {
|
|
49
|
+
if (segment.kind === "han") {
|
|
50
|
+
const chars = [...segment.text];
|
|
51
|
+
if (chars.length <= 2) {
|
|
52
|
+
fallbackTerms.add(segment.text);
|
|
53
|
+
if (!CJK_STOP.has(segment.text))
|
|
54
|
+
terms.add(segment.text);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
for (let index = 0; index < chars.length - 1; index += 1) {
|
|
58
|
+
const pair = `${chars[index]}${chars[index + 1]}`;
|
|
59
|
+
fallbackTerms.add(pair);
|
|
60
|
+
if (!CJK_STOP.has(pair))
|
|
61
|
+
terms.add(pair);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
fallbackTerms.add(segment.text);
|
|
67
|
+
if (!WORD_STOP.has(segment.text))
|
|
68
|
+
terms.add(segment.text);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const all = [...(terms.size ? terms : fallbackTerms)];
|
|
72
|
+
return all.length <= 48 ? all : [...all.slice(0, 24), ...all.slice(-24)];
|
|
73
|
+
}
|
|
74
|
+
/** Return the most relevant bounded window rather than the first bytes of the file. MEMORY.md and daily
|
|
75
|
+
* logs grow append-only, so a header-only snippet can report a hit while hiding the matching fact. */
|
|
76
|
+
export function matchCenteredSnippet(text, terms, max = SEARCH_SNIPPET_CHARS) {
|
|
77
|
+
if (text.length <= max)
|
|
78
|
+
return text;
|
|
79
|
+
const haystack = normalized(text);
|
|
80
|
+
const positions = [];
|
|
81
|
+
for (const term of terms) {
|
|
82
|
+
let from = 0;
|
|
83
|
+
for (let count = 0; count < 16; count += 1) {
|
|
84
|
+
const found = haystack.indexOf(term, from);
|
|
85
|
+
if (found < 0)
|
|
86
|
+
break;
|
|
87
|
+
positions.push(found);
|
|
88
|
+
from = found + Math.max(1, term.length);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!positions.length)
|
|
92
|
+
return `${text.slice(0, max).trimEnd()}…`;
|
|
93
|
+
let anchor = positions[0];
|
|
94
|
+
let bestScore = -1;
|
|
95
|
+
for (const position of positions) {
|
|
96
|
+
const start = Math.max(0, position - Math.floor(max * 0.35));
|
|
97
|
+
const end = start + max;
|
|
98
|
+
const score = terms.reduce((sum, term) => {
|
|
99
|
+
const found = haystack.indexOf(term, start);
|
|
100
|
+
return sum + (found >= start && found < end ? 1 : 0);
|
|
101
|
+
}, 0);
|
|
102
|
+
if (score > bestScore) {
|
|
103
|
+
bestScore = score;
|
|
104
|
+
anchor = position;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let start = Math.max(0, anchor - Math.floor(max * 0.35));
|
|
108
|
+
const priorLine = text.lastIndexOf("\n", start);
|
|
109
|
+
if (priorLine >= 0 && start - priorLine <= 160)
|
|
110
|
+
start = priorLine + 1;
|
|
111
|
+
let end = Math.min(text.length, start + max);
|
|
112
|
+
const nextLine = text.indexOf("\n", end);
|
|
113
|
+
if (nextLine >= 0 && nextLine - end <= 160)
|
|
114
|
+
end = nextLine;
|
|
115
|
+
const excerpt = text.slice(start, end).trim();
|
|
116
|
+
return `${start > 0 ? "…\n" : ""}${excerpt}${end < text.length ? "\n…" : ""}`;
|
|
117
|
+
}
|
|
11
118
|
export function assetsDir() {
|
|
12
119
|
return process.env.HARA_ASSETS || join(homedir(), ".hara", "code-assets");
|
|
13
120
|
}
|
|
@@ -48,7 +155,7 @@ export function metaBoost(text, title, words) {
|
|
|
48
155
|
export function searchAssets(query, limit = 5, roots) {
|
|
49
156
|
const dirs = roots ?? [assetsDir()];
|
|
50
157
|
const abs = roots !== undefined;
|
|
51
|
-
const words = query
|
|
158
|
+
const words = lexicalSearchTerms(query);
|
|
52
159
|
if (!words.length)
|
|
53
160
|
return [];
|
|
54
161
|
const hits = [];
|
|
@@ -63,12 +170,12 @@ export function searchAssets(query, limit = 5, roots) {
|
|
|
63
170
|
catch {
|
|
64
171
|
continue;
|
|
65
172
|
}
|
|
66
|
-
const hay = (rel + "\n" + text)
|
|
173
|
+
const hay = normalized(rel + "\n" + text);
|
|
67
174
|
const score = words.filter((w) => hay.includes(w)).length; // distinct query words present (dedup threshold uses this)
|
|
68
175
|
if (!score)
|
|
69
176
|
continue;
|
|
70
177
|
const title = titleOf(text, rel);
|
|
71
|
-
hits.push({ path: abs ? join(dir, rel) : rel, title, snippet: text
|
|
178
|
+
hits.push({ path: abs ? join(dir, rel) : rel, title, snippet: matchCenteredSnippet(text, words), score, boost: metaBoost(text, title, words) });
|
|
72
179
|
}
|
|
73
180
|
}
|
|
74
181
|
// rank by relevance, then by the declared-dimension boost (title/tags/lang), then prefer the shorter path
|
|
@@ -79,7 +186,7 @@ export function searchAssets(query, limit = 5, roots) {
|
|
|
79
186
|
export async function searchAssetsAsync(query, limit = 5, roots, options = {}) {
|
|
80
187
|
const dirs = roots ?? [assetsDir()];
|
|
81
188
|
const abs = roots !== undefined;
|
|
82
|
-
const words = query
|
|
189
|
+
const words = lexicalSearchTerms(query);
|
|
83
190
|
if (!words.length)
|
|
84
191
|
return [];
|
|
85
192
|
const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 2_000;
|
|
@@ -130,12 +237,12 @@ export async function searchAssetsAsync(query, limit = 5, roots, options = {}) {
|
|
|
130
237
|
catch {
|
|
131
238
|
continue;
|
|
132
239
|
}
|
|
133
|
-
const hay = (rel + "\n" + text)
|
|
240
|
+
const hay = normalized(rel + "\n" + text);
|
|
134
241
|
const score = words.filter((word) => hay.includes(word)).length;
|
|
135
242
|
if (!score)
|
|
136
243
|
continue;
|
|
137
244
|
const title = titleOf(text, rel);
|
|
138
|
-
hits.push({ path: abs ? join(dir, rel) : rel, title, snippet: text
|
|
245
|
+
hits.push({ path: abs ? join(dir, rel) : rel, title, snippet: matchCenteredSnippet(text, words), score, boost: metaBoost(text, title, words) });
|
|
139
246
|
}
|
|
140
247
|
if (Date.now() - startedAt >= timeoutMs || inventory.truncated)
|
|
141
248
|
break;
|
package/dist/search/hybrid.js
CHANGED
|
@@ -23,11 +23,18 @@ export async function searchHybrid(query, cwd, opts) {
|
|
|
23
23
|
}
|
|
24
24
|
const out = [];
|
|
25
25
|
const seen = new Set();
|
|
26
|
+
const lexicalByPath = new Map(lex.map((hit) => [hit.path, hit]));
|
|
26
27
|
for (const s of sem) {
|
|
27
28
|
if (s.score < 0.2 || seen.has(s.file))
|
|
28
29
|
continue;
|
|
29
30
|
seen.add(s.file);
|
|
30
|
-
|
|
31
|
+
const exactLexical = lexicalByPath.get(s.file);
|
|
32
|
+
// If the same file has a literal hit, keep its match-centered live snippet. The semantic cache may
|
|
33
|
+
// correctly choose MEMORY.md yet point at a different heading/chunk, hiding the exact fact the user
|
|
34
|
+
// searched for. Semantic ranking still chooses the file; lexical evidence chooses the excerpt.
|
|
35
|
+
out.push(exactLexical
|
|
36
|
+
? { ...exactLexical, score: Math.max(exactLexical.score, s.score) }
|
|
37
|
+
: { path: s.file, title: titleOf(s.text, s.file), snippet: s.text.slice(0, 800), score: s.score });
|
|
31
38
|
}
|
|
32
39
|
for (const h of lex) {
|
|
33
40
|
if (out.length >= limit)
|
package/dist/search/semindex.js
CHANGED
|
@@ -12,6 +12,7 @@ import { zvecBuild, zvecQueryIds, zvecRemove } from "./zvec-store.js";
|
|
|
12
12
|
import { isSensitiveFilePath } from "../security/sensitive-files.js";
|
|
13
13
|
import { readModelContextFileSync } from "../fs-read.js";
|
|
14
14
|
import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
|
|
15
|
+
import { sanitizeMemoryForPrompt } from "../memory/guard.js";
|
|
15
16
|
import { ensurePrivateStateSubdirectory, readPrivateStateFileSnapshot, removePrivateStateFile, } from "../security/private-state.js";
|
|
16
17
|
import { atomicWriteText, bindHaraPrivateStateWritePath, } from "../fs-write.js";
|
|
17
18
|
// Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
|
|
@@ -79,6 +80,9 @@ function statMtime(p) {
|
|
|
79
80
|
return 0;
|
|
80
81
|
}
|
|
81
82
|
}
|
|
83
|
+
function indexableText(text, source) {
|
|
84
|
+
return source === "memory" ? sanitizeMemoryForPrompt(text).text : text;
|
|
85
|
+
}
|
|
82
86
|
/** Split a file into chunks: Markdown by `#` headings, code by ~40-line windows. Heuristic, zero-dep —
|
|
83
87
|
* also the substrate embeddings reuse. `mtime` (when given) is stamped on every chunk for incremental reuse. */
|
|
84
88
|
export function chunkText(text, file, source, mtime) {
|
|
@@ -248,7 +252,9 @@ export function collectDirChunks(dir, source) {
|
|
|
248
252
|
catch {
|
|
249
253
|
continue;
|
|
250
254
|
}
|
|
251
|
-
|
|
255
|
+
const safeText = indexableText(text, source);
|
|
256
|
+
if (safeText.trim())
|
|
257
|
+
chunks.push(...chunkText(safeText, abs, source, statMtime(abs)));
|
|
252
258
|
}
|
|
253
259
|
return chunks;
|
|
254
260
|
}
|
|
@@ -295,7 +301,9 @@ async function chunksFromInventory(root, files, source, absolutePaths, startedAt
|
|
|
295
301
|
catch {
|
|
296
302
|
continue;
|
|
297
303
|
}
|
|
298
|
-
|
|
304
|
+
const safeText = indexableText(text, source);
|
|
305
|
+
if (safeText.trim())
|
|
306
|
+
chunks.push(...chunkText(safeText, absolutePaths ? abs : rel, source, statMtime(abs)));
|
|
299
307
|
}
|
|
300
308
|
return { chunks, truncated: false };
|
|
301
309
|
}
|
|
@@ -382,7 +390,7 @@ export async function queryIndex(name, query, embed, cwd, k = 6, signal) {
|
|
|
382
390
|
return cached;
|
|
383
391
|
let fresh = false;
|
|
384
392
|
try {
|
|
385
|
-
const text = readModelContextFileSync(path, 200_000);
|
|
393
|
+
const text = indexableText(readModelContextFileSync(path, 200_000), item.source);
|
|
386
394
|
fresh = contentHash(chunkText(text, item.file, item.source)) === item.contentHash;
|
|
387
395
|
}
|
|
388
396
|
catch {
|
package/dist/serve/protocol.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
|
|
11
11
|
// session.create {cwd?,approval?} → {sessionId,model}
|
|
12
12
|
// session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
|
|
13
|
-
// session.send {sessionId,text,images?,newTask?} → (streams events, then) {reply,usage,taskId,turnId}
|
|
13
|
+
// session.send {sessionId,text,images?,newTask?} → (streams events, then) {reply,usage,taskId,turnId,status?,stopReason?}
|
|
14
14
|
// session.steer {sessionId,text,expectedTurnId} → {accepted,taskId,turnId}
|
|
15
15
|
// images: [{path,mediaType?}] — pasted screenshots etc., inlined for vision models
|
|
16
16
|
// session.interrupt {sessionId} → {}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
|
|
42
42
|
// Server → client notifications (all carry sessionId):
|
|
43
43
|
// event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
|
|
44
|
-
// event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
|
|
44
|
+
// event.notice {text} · event.turn_end {reply,usage,error?,status?,stopReason?} · approval.request {approvalId,question}
|
|
45
45
|
// event.task_state {version,streamId,sequence,taskId,turnId,objective,state,taskStatus,phase,checkpoint,…}
|
|
46
46
|
// authoritative execution plane; clients feature-detect it via capabilities.events
|
|
47
47
|
export const PROTOCOL_VERSION = 1;
|
package/dist/serve/server.js
CHANGED
|
@@ -567,6 +567,7 @@ export async function startServe(opts, deps) {
|
|
|
567
567
|
cwd: s.meta.cwd,
|
|
568
568
|
sandbox: deps.sandbox,
|
|
569
569
|
todoScope: sessionId,
|
|
570
|
+
sessionId,
|
|
570
571
|
spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal, {
|
|
571
572
|
onProviderTurn: (turn) => observeProviderTurn(s, turn),
|
|
572
573
|
onToolRun: (toolRun) => observeToolRun(s, toolRun),
|
|
@@ -624,6 +625,31 @@ export async function startServe(opts, deps) {
|
|
|
624
625
|
: outcome.status === "halted"
|
|
625
626
|
? "agent turn halted by a safety control"
|
|
626
627
|
: "agent turn failed");
|
|
628
|
+
if (outcome.status === "halted" && outcome.stopReason === "deadline") {
|
|
629
|
+
// An active-execution deadline is a successful, recoverable checkpoint transition. The typed
|
|
630
|
+
// task event already says `paused`; returning a normal RPC result keeps Desktop and other Serve
|
|
631
|
+
// clients from rendering the same state as `error:` while still exposing the focused /continue
|
|
632
|
+
// guidance to request/response-only clients. Other safety halts remain explicit failures.
|
|
633
|
+
broadcast("event.turn_end", {
|
|
634
|
+
sessionId,
|
|
635
|
+
taskId: s.task.id,
|
|
636
|
+
turnId: s.task.turnId,
|
|
637
|
+
reply: "",
|
|
638
|
+
status: "paused",
|
|
639
|
+
stopReason: "deadline",
|
|
640
|
+
usage,
|
|
641
|
+
ctx,
|
|
642
|
+
});
|
|
643
|
+
return {
|
|
644
|
+
reply: failure,
|
|
645
|
+
usage,
|
|
646
|
+
ctx,
|
|
647
|
+
taskId: s.task.id,
|
|
648
|
+
turnId: s.task.turnId,
|
|
649
|
+
status: "paused",
|
|
650
|
+
stopReason: "deadline",
|
|
651
|
+
};
|
|
652
|
+
}
|
|
627
653
|
broadcast("event.turn_end", { sessionId, taskId: s.task.id, turnId: s.task.turnId, reply: "", error: failure, status: outcome.status, usage, ctx });
|
|
628
654
|
throw new Error(failure);
|
|
629
655
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { canonicalProjectPath } from "../org/projects.js";
|
|
2
|
+
import { forkTaskExecution } from "./task.js";
|
|
3
|
+
import { acquireSessionLock, latestForCwd, newSessionId, releaseSessionLock, saveSession, } from "./store.js";
|
|
4
|
+
export const RECENT_WORKSPACE_TRANSFER_MS = 30 * 60 * 1000;
|
|
5
|
+
/** Find the recent interactive thread a direct `hara --cwd …` launch would otherwise leave behind.
|
|
6
|
+
* This is deliberately narrow: automated/archived/empty/old sessions never trigger an interactive prompt. */
|
|
7
|
+
export function recentWorkspaceTransferCandidate(launchCwd, targetCwd, at = new Date()) {
|
|
8
|
+
const source = canonicalProjectPath(launchCwd, true);
|
|
9
|
+
const target = canonicalProjectPath(targetCwd, true);
|
|
10
|
+
if (!source || !target || source === target)
|
|
11
|
+
return null;
|
|
12
|
+
const session = latestForCwd(source);
|
|
13
|
+
if (!session || session.meta.archived || (session.meta.source && session.meta.source !== "interactive"))
|
|
14
|
+
return null;
|
|
15
|
+
if (session.history.length === 0)
|
|
16
|
+
return null;
|
|
17
|
+
const now = typeof at === "string" ? Date.parse(at) : at.getTime();
|
|
18
|
+
const updated = Date.parse(session.meta.updatedAt);
|
|
19
|
+
const age = now - updated;
|
|
20
|
+
if (!Number.isFinite(now) || !Number.isFinite(updated) || age < 0 || age > RECENT_WORKSPACE_TRANSFER_MS)
|
|
21
|
+
return null;
|
|
22
|
+
return session;
|
|
23
|
+
}
|
|
24
|
+
/** Copy a conversation into another workspace without mutating or re-routing its source session.
|
|
25
|
+
* A new identity is required because one session id is permanently bound to one canonical project root. */
|
|
26
|
+
export function workspaceSessionFork(source, targetCwd, at = new Date()) {
|
|
27
|
+
const target = canonicalProjectPath(targetCwd, true);
|
|
28
|
+
if (!target)
|
|
29
|
+
throw new Error(`target workspace is unavailable: ${targetCwd}`);
|
|
30
|
+
const now = typeof at === "string" ? at : at.toISOString();
|
|
31
|
+
if (!Number.isFinite(Date.parse(now)))
|
|
32
|
+
throw new Error("workspace transfer timestamp is invalid");
|
|
33
|
+
const meta = structuredClone(source.meta);
|
|
34
|
+
meta.id = newSessionId();
|
|
35
|
+
meta.cwd = target;
|
|
36
|
+
meta.createdAt = now;
|
|
37
|
+
meta.updatedAt = "";
|
|
38
|
+
meta.source = "interactive";
|
|
39
|
+
delete meta.sourceName;
|
|
40
|
+
delete meta.archived;
|
|
41
|
+
delete meta.gatewayOwner;
|
|
42
|
+
const task = forkTaskExecution(source.task, now);
|
|
43
|
+
return {
|
|
44
|
+
meta,
|
|
45
|
+
history: structuredClone(source.history),
|
|
46
|
+
...(task ? { task } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** Persist a workspace fork under its own short-lived lock so the relaunched child can safely resume it. */
|
|
50
|
+
export function persistWorkspaceSessionFork(source, targetCwd, at = new Date()) {
|
|
51
|
+
const fork = workspaceSessionFork(source, targetCwd, at);
|
|
52
|
+
const lock = acquireSessionLock(fork.meta.id);
|
|
53
|
+
if (!lock.ok)
|
|
54
|
+
throw new Error("could not reserve the transferred session id");
|
|
55
|
+
try {
|
|
56
|
+
saveSession(fork.meta, fork.history, fork.task);
|
|
57
|
+
return fork;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
releaseSessionLock(fork.meta.id);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/dist/tools/all.js
CHANGED
|
@@ -10,6 +10,7 @@ import "./patch.js"; // apply_patch
|
|
|
10
10
|
import "./web.js"; // web_search / web_fetch
|
|
11
11
|
import "./agent.js"; // agent (subagent spawn)
|
|
12
12
|
import "./memory.js"; // memory_search/get/write/forget + skill_create
|
|
13
|
+
import "./session-search.js"; // bounded cross-session transcript recall
|
|
13
14
|
import "./skill.js"; // skill loader
|
|
14
15
|
import "./codebase.js"; // codebase_search
|
|
15
16
|
import "./todo.js"; // todo_write
|
package/dist/tools/memory.js
CHANGED
|
@@ -5,8 +5,8 @@ import { isAbsolute, resolve, join, relative, sep } from "node:path";
|
|
|
5
5
|
import { registerTool } from "./registry.js";
|
|
6
6
|
import { searchAssetsAsync, assetSearchRoots } from "../recall.js";
|
|
7
7
|
import { searchHybrid } from "../search/hybrid.js";
|
|
8
|
-
import { memoryRoots, appendMemory,
|
|
9
|
-
import { scanMemory, redactSecrets, scrubLocal } from "../memory/guard.js";
|
|
8
|
+
import { memoryRoots, appendMemory, appendMemoryOnce, forgetMemory } from "../memory/store.js";
|
|
9
|
+
import { scanMemory, redactSecrets, sanitizeMemoryForPrompt, scrubLocal } from "../memory/guard.js";
|
|
10
10
|
import { globalSkillsDir, skillsDir, invalidateSkillsCache } from "../skills/skills.js";
|
|
11
11
|
import { sensitiveFileError } from "../security/sensitive-files.js";
|
|
12
12
|
import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
|
|
@@ -15,7 +15,7 @@ export const MAX_MEMORY_ENTRY_CHARS = 4_000;
|
|
|
15
15
|
export const MAX_SKILL_DESCRIPTION_CHARS = 500;
|
|
16
16
|
export const MAX_SKILL_BODY_CHARS = 24_000;
|
|
17
17
|
const asTarget = (v) => (["memory", "user", "log"].includes(v) ? v : "memory");
|
|
18
|
-
const asScope = (v) =>
|
|
18
|
+
const asScope = (v, target) => v === "global" ? "global" : v === "project" ? "project" : target === "user" ? "global" : "project";
|
|
19
19
|
registerTool({
|
|
20
20
|
name: "memory_search",
|
|
21
21
|
description: "Search your durable memory (facts, decisions, user preferences, daily notes) by keywords. " +
|
|
@@ -26,12 +26,29 @@ registerTool({
|
|
|
26
26
|
required: ["query"],
|
|
27
27
|
},
|
|
28
28
|
kind: "read",
|
|
29
|
-
|
|
29
|
+
// Keep recall attempts serial so the empty-result breaker can stop after the third call even if a model
|
|
30
|
+
// emits a large batch of paraphrased searches in one response.
|
|
31
|
+
concurrencySafe: false,
|
|
30
32
|
async run(input, ctx) {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
33
|
+
const query = String(input.query ?? "").trim();
|
|
34
|
+
if (!query)
|
|
35
|
+
return "Error: memory_search requires a non-empty query.";
|
|
36
|
+
if (query.length > 512)
|
|
37
|
+
return "Error: memory_search query is too long (maximum 512 characters).";
|
|
38
|
+
const limit = Math.max(1, Math.min(10, Math.floor(Number(input.limit) || 5)));
|
|
39
|
+
const hits = await searchHybrid(query, ctx.cwd, { indexName: "memory", roots: memoryRoots(ctx.cwd), limit, signal: ctx.signal });
|
|
40
|
+
const safeHits = hits
|
|
41
|
+
.map((hit) => ({
|
|
42
|
+
...hit,
|
|
43
|
+
// Lexical search derives both fields from editable Markdown. Sanitize the heading as well as the
|
|
44
|
+
// excerpt so an unsafe first heading cannot bypass the memory load boundary as result metadata.
|
|
45
|
+
title: sanitizeMemoryForPrompt(hit.title).text.trim() || "memory",
|
|
46
|
+
snippet: sanitizeMemoryForPrompt(hit.snippet).text.trim(),
|
|
47
|
+
}))
|
|
48
|
+
.filter((hit) => hit.snippet);
|
|
49
|
+
if (!safeHits.length)
|
|
33
50
|
return "(no memory matches)";
|
|
34
|
-
return
|
|
51
|
+
return safeHits.map((h) => `${h.path} — ${h.title}\n${h.snippet}`).join("\n\n");
|
|
35
52
|
},
|
|
36
53
|
});
|
|
37
54
|
registerTool({
|
|
@@ -53,7 +70,11 @@ registerTool({
|
|
|
53
70
|
if (!existsSync(p))
|
|
54
71
|
return `Error: no memory file at ${p}.`;
|
|
55
72
|
try {
|
|
56
|
-
|
|
73
|
+
const raw = readModelContextFileSync(p, 64 * 1024).slice(0, 50_000);
|
|
74
|
+
const safe = sanitizeMemoryForPrompt(raw);
|
|
75
|
+
return safe.text.trim()
|
|
76
|
+
? safe.text
|
|
77
|
+
: "Blocked: this memory file contains no content that is safe to load.";
|
|
57
78
|
}
|
|
58
79
|
catch (e) {
|
|
59
80
|
return `Error: ${e.message}`;
|
|
@@ -69,9 +90,8 @@ registerTool({
|
|
|
69
90
|
type: "object",
|
|
70
91
|
properties: {
|
|
71
92
|
content: { type: "string" },
|
|
72
|
-
target: { type: "string", enum: ["memory", "user", "log"], description: "memory=durable facts, user=user prefs (global), log=today's note. default memory" },
|
|
73
|
-
scope: { type: "string", enum: ["project", "global"], description: "default project" },
|
|
74
|
-
mode: { type: "string", enum: ["append", "replace"], description: "default append" },
|
|
93
|
+
target: { type: "string", enum: ["memory", "user", "log"], description: "memory=durable facts, user=user prefs (global by default), log=today's note. default memory" },
|
|
94
|
+
scope: { type: "string", enum: ["project", "global"], description: "default global for target=user; project otherwise" },
|
|
75
95
|
},
|
|
76
96
|
required: ["content"],
|
|
77
97
|
},
|
|
@@ -86,12 +106,18 @@ registerTool({
|
|
|
86
106
|
const scan = scanMemory(content);
|
|
87
107
|
if (!scan.ok)
|
|
88
108
|
return `Blocked: this looks unsafe to store (${scan.hits.join(", ")}). Rephrase without secrets/injection text.`;
|
|
89
|
-
const scope = asScope(input.scope);
|
|
90
109
|
const target = asTarget(input.target);
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
:
|
|
94
|
-
|
|
110
|
+
const scope = asScope(input.scope, target);
|
|
111
|
+
if (input.mode === "replace") {
|
|
112
|
+
return "Blocked: memory_write cannot replace an entire memory file. Use memory_forget for the stale entry, then append the corrected fact.";
|
|
113
|
+
}
|
|
114
|
+
if (target === "log") {
|
|
115
|
+
const f = await appendMemory(scope, target, content, ctx.cwd, ctx.signal);
|
|
116
|
+
return `Saved to ${f}`;
|
|
117
|
+
}
|
|
118
|
+
const entry = content.replace(/\s+/g, " ").trim();
|
|
119
|
+
const result = await appendMemoryOnce(scope, target, entry, ctx.cwd, ctx.signal);
|
|
120
|
+
return result.appended ? `Saved to ${result.path}` : `Already remembered in ${result.path}`;
|
|
95
121
|
},
|
|
96
122
|
});
|
|
97
123
|
registerTool({
|
|
@@ -206,7 +232,8 @@ registerTool({
|
|
|
206
232
|
},
|
|
207
233
|
kind: "edit",
|
|
208
234
|
async run(input, ctx) {
|
|
209
|
-
const
|
|
235
|
+
const target = asTarget(input.target);
|
|
236
|
+
const n = await forgetMemory(asScope(input.scope, target), target, String(input.match ?? ""), ctx.cwd, ctx.signal);
|
|
210
237
|
return n ? `Removed ${n} line(s).` : "(no matching lines)";
|
|
211
238
|
},
|
|
212
239
|
});
|