@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/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.toLowerCase().split(/\s+/).filter(Boolean);
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).toLowerCase();
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.slice(0, 800), score, boost: metaBoost(text, title, words) });
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.toLowerCase().split(/\s+/).filter(Boolean);
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).toLowerCase();
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.slice(0, 800), score, boost: metaBoost(text, title, words) });
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;
@@ -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
- out.push({ path: s.file, title: titleOf(s.text, s.file), snippet: s.text.slice(0, 800), score: s.score });
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)
@@ -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
- chunks.push(...chunkText(text, abs, source, statMtime(abs)));
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
- chunks.push(...chunkText(text, absolutePaths ? abs : rel, source, statMtime(abs)));
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 {
@@ -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;
@@ -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
@@ -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, replaceMemory, forgetMemory } from "../memory/store.js";
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) => (v === "global" ? "global" : "project");
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
- concurrencySafe: true,
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 hits = await searchHybrid(String(input.query ?? ""), ctx.cwd, { indexName: "memory", roots: memoryRoots(ctx.cwd), limit: Math.min(Number(input.limit) || 5, 10), signal: ctx.signal });
32
- if (!hits.length)
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 hits.map((h) => `${h.path} — ${h.title}\n${h.snippet}`).join("\n\n");
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
- return readModelContextFileSync(p, 64 * 1024).slice(0, 50_000);
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 f = input.mode === "replace"
92
- ? await replaceMemory(scope, target, content, ctx.cwd, ctx.signal)
93
- : await appendMemory(scope, target, content, ctx.cwd, ctx.signal);
94
- return `Saved to ${f}`;
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 n = await forgetMemory(asScope(input.scope), asTarget(input.target), String(input.match ?? ""), ctx.cwd, ctx.signal);
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
  });