@nanhara/hara 0.130.2 → 0.131.0

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/index.js CHANGED
@@ -29,7 +29,8 @@ import { routingProvider } from "./agent/route.js";
29
29
  import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore, compactedConversationHistory, compactedHistoryTokenEstimate, compactionSourceHistory, normalizeCompactionSummary, recentHistoryForCompaction, workingSetFromSummary, } from "./agent/compact.js";
30
30
  import { recentTouched, clearTouched } from "./agent/touched.js";
31
31
  import { INTERJECT_PREFIX, disposeReminderScope } from "./agent/reminders.js";
32
- import { checkForUpdate } from "./update-check.js";
32
+ import { checkForUpdate, fetchLatestVersion, isNewer } from "./update-check.js";
33
+ import { inspectInstallation, installationLabel, manualUpdateInstruction, upgradeNpmInstallation, } from "./update-install.js";
33
34
  import { formatContextReport } from "./agent/context-report.js";
34
35
  import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
35
36
  import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
@@ -71,7 +72,7 @@ function renderBgJobs() {
71
72
  }
72
73
  import { qwenDeviceLogin, loadQwenToken } from "./providers/qwen-oauth.js";
73
74
  import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
74
- import { homeWorkspaceActionError, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, suggestedProjectWorkspace, } from "./context/workspace-scope.js";
75
+ import { homeWorkspaceActionError, discoverProjectWorkspaces, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, suggestedProjectWorkspace, } from "./context/workspace-scope.js";
75
76
  import { getEmbedder } from "./search/embed.js";
76
77
  import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, indexExists } from "./search/semindex.js";
77
78
  import { searchHybrid } from "./search/hybrid.js";
@@ -79,6 +80,7 @@ import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath
79
80
  import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
80
81
  import { consumePendingTaskSteering, continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, hasPendingTaskSteering, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, routeTaskInteraction, requestsTaskContinuation, taskExecutionContext, } from "./session/task.js";
81
82
  import { displaySessionCwd, resolveSessionResumeTarget } from "./session/resume.js";
83
+ import { persistWorkspaceSessionFork, recentWorkspaceTransferCandidate, } from "./session/transfer.js";
82
84
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
83
85
  import { createPhysicalOperationDrain } from "./session/operation-drain.js";
84
86
  import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
@@ -102,6 +104,7 @@ import "./tools/patch.js"; // register apply_patch
102
104
  import "./tools/web.js"; // register web_fetch
103
105
  import "./tools/agent.js"; // register agent (subagent spawn)
104
106
  import "./tools/memory.js"; // register memory_search/get/write/forget/skill_create
107
+ import "./tools/session-search.js"; // register bounded cross-session transcript recall
105
108
  import "./tools/skill.js"; // register the skill loader tool
106
109
  import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
107
110
  import "./tools/todo.js"; // register todo_write (inline task checklist)
@@ -1185,6 +1188,62 @@ function roleMeta(role) {
1185
1188
  ...(role.compatibilityWarnings ?? []),
1186
1189
  ].filter(Boolean).join(" · ");
1187
1190
  }
1191
+ const packageRoot = resolve(here, "..");
1192
+ function activeInstallation() {
1193
+ return inspectInstallation(packageRoot, { buildVersion: process.env.HARA_BUILD_VERSION });
1194
+ }
1195
+ function shadowInstallLines(installation) {
1196
+ if (!installation.shadowCommands.length)
1197
+ return [];
1198
+ return [
1199
+ c.yellow(`⚠ ${installation.shadowCommands.length} other Hara command(s) are visible in PATH; switching Node or PATH can activate an older copy:`),
1200
+ ...installation.shadowCommands.map((path) => ` ${c.dim(path)}`),
1201
+ ];
1202
+ }
1203
+ async function runUpdateCommand(checkOnly) {
1204
+ const installation = activeInstallation();
1205
+ out(`${c.bold("hara update")}\n`);
1206
+ out(` current ${c.bold(pkg.version)} · ${c.bold(installationLabel(installation))} · ${c.dim(installation.launchPath)}\n`);
1207
+ const latest = await fetchLatestVersion();
1208
+ if (!latest) {
1209
+ process.stderr.write("hara: could not reach the npm registry; no installation was changed.\n");
1210
+ process.exitCode = 1;
1211
+ return;
1212
+ }
1213
+ out(` latest ${c.bold(latest)}\n`);
1214
+ for (const line of shadowInstallLines(installation))
1215
+ out(line + "\n");
1216
+ if (!isNewer(latest, pkg.version)) {
1217
+ out(c.green(`✓ the active ${installationLabel(installation)} is up to date\n`));
1218
+ if (installation.shadowCommands.length) {
1219
+ out(c.yellow(" The PATH copies above are separate installations; this check did not execute or modify them.\n"));
1220
+ }
1221
+ return;
1222
+ }
1223
+ if (checkOnly) {
1224
+ out(` next ${installation.kind === "npm" ? "run `hara update` without --check" : manualUpdateInstruction(installation)}\n`);
1225
+ return;
1226
+ }
1227
+ if (installation.kind !== "npm") {
1228
+ out(c.yellow("This installation cannot be replaced through npm.\n"));
1229
+ out(` ${manualUpdateInstruction(installation)}\n`);
1230
+ process.exitCode = 2;
1231
+ return;
1232
+ }
1233
+ try {
1234
+ const result = upgradeNpmInstallation(installation, latest);
1235
+ out(c.green(`✓ upgraded and verified ${result.packageRoot} at ${result.version}\n`));
1236
+ out(" Restart Hara, then confirm with: hara --version\n");
1237
+ if (installation.shadowCommands.length) {
1238
+ out(c.yellow(" Only the active npm prefix was upgraded; the PATH copies listed above remain independent.\n"));
1239
+ }
1240
+ }
1241
+ catch (error) {
1242
+ process.stderr.write(`hara: update failed: ${error instanceof Error ? error.message : String(error)}\n`);
1243
+ process.stderr.write("No other Hara installation was modified. Run `hara doctor` to inspect active and shadow commands.\n");
1244
+ process.exitCode = 1;
1245
+ }
1246
+ }
1188
1247
  function runDoctor(cfg) {
1189
1248
  const ok = (b) => (b ? c.green("✓") : c.red("✗"));
1190
1249
  const dot = c.dim("·");
@@ -1197,9 +1256,12 @@ function runDoctor(cfg) {
1197
1256
  const roles = loadRoles(cfg.cwd);
1198
1257
  const vcap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
1199
1258
  const vdesc = vcap === "vision" ? c.dim("sees images (inline)") : vcap === "text" ? c.dim("text-only") : c.yellow("capability unknown — asks on first image");
1259
+ const installation = activeInstallation();
1200
1260
  const lines = [
1201
1261
  c.bold("hara doctor"),
1202
1262
  `${ok(nodeSupported)} node ${process.versions.node} ${c.dim(`(need ≥${MIN_NODE_VERSION})`)}`,
1263
+ `${dot} install ${c.bold(installationLabel(installation))} · ${c.dim(installation.launchPath)}`,
1264
+ ...shadowInstallLines(installation),
1203
1265
  `${dot} provider ${c.bold(cfg.provider)} · model ${c.bold(cfg.model)}${cfg.baseURL ? c.dim(" · " + cfg.baseURL) : ""}`,
1204
1266
  `${ok(authed)} auth ${providerIsLocal(cfg.provider) ? c.dim("not required (local endpoint)") : authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
1205
1267
  `${ok(existsSync(configPath()))} config ${c.dim(configPath())}`,
@@ -1236,6 +1298,16 @@ function helpText(commands) {
1236
1298
  const lines = commands.map((cmd) => ` /${cmd.name.padEnd(13)} ${c.dim(cmd.desc)}`);
1237
1299
  return c.bold("Commands:\n") + lines.join("\n") + "\n" + c.dim(" @path attach a file's contents (Tab to complete)\n");
1238
1300
  }
1301
+ // Commander applies --cwd in a preAction hook. Retain the shell's launch directory so an interactive
1302
+ // cross-workspace launch can offer to carry a very recent conversation instead of silently starting blank.
1303
+ const invocationCwd = (() => {
1304
+ try {
1305
+ return realpathSync.native(process.cwd());
1306
+ }
1307
+ catch {
1308
+ return resolve(process.cwd());
1309
+ }
1310
+ })();
1239
1311
  const program = new Command();
1240
1312
  program
1241
1313
  .name("hara")
@@ -1596,8 +1668,14 @@ program
1596
1668
  });
1597
1669
  program
1598
1670
  .command("doctor")
1599
- .description("check your hara setup (provider / auth / model / node / assets / roles)")
1671
+ .description("check your hara setup (install / provider / auth / model / node / assets / roles)")
1600
1672
  .action(() => out(runDoctor(loadConfig()) + "\n"));
1673
+ program
1674
+ .command("update")
1675
+ .alias("upgrade")
1676
+ .description("update the active Hara installation and verify the resulting version")
1677
+ .option("--check", "check the latest version and installation source without changing anything")
1678
+ .action(async (opts) => runUpdateCommand(opts.check === true));
1601
1679
  program
1602
1680
  .command("setup")
1603
1681
  .description("interactive first-run setup — pick a provider, API key, and model")
@@ -2997,6 +3075,7 @@ config
2997
3075
  .action(() => out(configPath() + "\n"));
2998
3076
  // default action (interactive REPL / one-shot)
2999
3077
  program.action(async (opts) => {
3078
+ let startupWorkspaceTransferId;
3000
3079
  // Identity-profile selection (--profile flag) is now handled by the program-level preAction
3001
3080
  // hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
3002
3081
  // pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
@@ -3015,10 +3094,15 @@ program.action(async (opts) => {
3015
3094
  const recent = listSessions()
3016
3095
  .filter((session) => session.source === undefined || session.source === "interactive")
3017
3096
  .map((session) => session.cwd);
3018
- candidate = suggestedProjectWorkspace([
3019
- ...recent,
3020
- ...loadProjects().map((project) => project.path),
3021
- ]);
3097
+ candidate = suggestedProjectWorkspace(recent);
3098
+ if (!candidate) {
3099
+ // Sessions launched from Home contain no usable project signal. Prefer bounded, marker-backed
3100
+ // discovery under conventional project containers; old registrations remain a final fallback.
3101
+ candidate = suggestedProjectWorkspace([
3102
+ ...discoverProjectWorkspaces(),
3103
+ ...loadProjects().map((project) => project.path),
3104
+ ]);
3105
+ }
3022
3106
  }
3023
3107
  catch {
3024
3108
  // A damaged optional history/registry must not weaken the Home boundary or block startup.
@@ -3061,6 +3145,59 @@ program.action(async (opts) => {
3061
3145
  }
3062
3146
  }
3063
3147
  }
3148
+ // `hara --cwd …` is often the user's response to the protected-Home guidance. In an interactive
3149
+ // terminal, offer to fork a very recent source-directory thread into the selected workspace. The old
3150
+ // session stays intact, and headless/scripted launches keep their established non-interactive behavior.
3151
+ if (!opts.print
3152
+ && opts.cwd
3153
+ && !opts.resume
3154
+ && !opts.continue
3155
+ && stdin.isTTY
3156
+ && stdout.isTTY) {
3157
+ let candidate = null;
3158
+ try {
3159
+ candidate = recentWorkspaceTransferCandidate(invocationCwd, process.cwd());
3160
+ }
3161
+ catch {
3162
+ // Optional continuity discovery must never make an explicit --cwd unusable.
3163
+ }
3164
+ if (candidate) {
3165
+ const question = `Continue recent session ${shortId(candidate.meta.id)} from ${displaySessionCwd(candidate.meta.cwd)} `
3166
+ + `with its current context in ${displaySessionCwd(process.cwd())}?`;
3167
+ let transfer = false;
3168
+ if (process.env.HARA_TUI !== "0") {
3169
+ transfer = await askConfirm(question);
3170
+ }
3171
+ else {
3172
+ const prompt = createInterface({ input: stdin, output: stdout });
3173
+ try {
3174
+ const answer = (await prompt.question(c.yellow(question) + c.dim(" [Y/n] "))).trim().toLowerCase();
3175
+ transfer = answer === "" || answer === "y" || answer === "yes";
3176
+ }
3177
+ finally {
3178
+ prompt.close();
3179
+ }
3180
+ }
3181
+ if (transfer) {
3182
+ try {
3183
+ const fork = persistWorkspaceSessionFork(candidate, process.cwd());
3184
+ startupWorkspaceTransferId = fork.meta.id;
3185
+ out(c.green(`Continuing ${shortId(candidate.meta.id)} as ${shortId(fork.meta.id)} in ${displaySessionCwd(fork.meta.cwd)}; `
3186
+ + "the original session remains saved.\n"));
3187
+ }
3188
+ catch (error) {
3189
+ out(c.red(`Could not carry the recent session into ${displaySessionCwd(process.cwd())}: ${error instanceof Error ? error.message : String(error)}.\n`));
3190
+ out(c.dim(`The original session ${shortId(candidate.meta.id)} is unchanged; no blank replacement was started.\n`));
3191
+ process.exitCode = 2;
3192
+ return;
3193
+ }
3194
+ }
3195
+ else {
3196
+ out(c.dim(`Starting a fresh session in ${displaySessionCwd(process.cwd())}; `
3197
+ + `the previous session ${shortId(candidate.meta.id)} remains saved.\n`));
3198
+ }
3199
+ }
3200
+ }
3064
3201
  // Resolve addressable headless roles BEFORE loading config/provider/MCP. A qualified project role is
3065
3202
  // an execution-home selection, so every downstream route (credentials, model, AGENTS.md, tools) must be
3066
3203
  // constructed from that home rather than from the shell directory that happened to launch hara.
@@ -3470,6 +3607,7 @@ program.action(async (opts) => {
3470
3607
  ctx: {
3471
3608
  cwd,
3472
3609
  sandbox,
3610
+ ...(meta ? { sessionId: meta.id } : {}),
3473
3611
  spawn: (t, role, signal) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role, signal, {
3474
3612
  onProviderTurn: trackHeadlessOperation,
3475
3613
  onToolRun: trackHeadlessOperation,
@@ -3646,7 +3784,10 @@ program.action(async (opts) => {
3646
3784
  const spawn = (t, role, signal) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role, signal);
3647
3785
  // session: --resume <id> / --continue (latest in this cwd) / new
3648
3786
  let resumeId = null;
3649
- if (opts.resume) {
3787
+ if (startupWorkspaceTransferId) {
3788
+ resumeId = startupWorkspaceTransferId;
3789
+ }
3790
+ else if (opts.resume) {
3650
3791
  const rid = resolveSessionId(opts.resume); // accept a full UUID or a unique prefix (short id)
3651
3792
  resumeId = rid;
3652
3793
  if (!resumeId)
@@ -3748,6 +3889,20 @@ program.action(async (opts) => {
3748
3889
  : undefined;
3749
3890
  let requestedWorkspaceSwitch = null;
3750
3891
  let requestedSessionSwitch = null;
3892
+ const queueWorkspaceSwitch = (target) => {
3893
+ if (history.length === 0) {
3894
+ requestedWorkspaceSwitch = target;
3895
+ return `(switching workspace → ${target})`;
3896
+ }
3897
+ const fork = persistWorkspaceSessionFork({ meta, history, ...(task ? { task } : {}) }, target);
3898
+ requestedSessionSwitch = {
3899
+ id: fork.meta.id,
3900
+ cwd: fork.meta.cwd,
3901
+ kind: "workspace-transfer",
3902
+ historyCount: history.length,
3903
+ };
3904
+ return `(switching workspace with ${history.length} messages of current context → ${fork.meta.cwd}; original session stays saved)`;
3905
+ };
3751
3906
  const relaunchRequestedTarget = async () => {
3752
3907
  if (!requestedWorkspaceSwitch && !requestedSessionSwitch)
3753
3908
  return;
@@ -3773,9 +3928,11 @@ program.action(async (opts) => {
3773
3928
  args.push("--sandbox", String(opts.sandbox));
3774
3929
  if (sessionSwitch)
3775
3930
  args.push("--resume", sessionSwitch.id);
3776
- out(c.dim(sessionSwitch
3777
- ? `Resuming session ${shortId(sessionSwitch.id)} → ${displaySessionCwd(target)}\n`
3778
- : `Switching workspace → ${target}\n`));
3931
+ out(c.dim(sessionSwitch?.kind === "workspace-transfer"
3932
+ ? `Continuing current context as session ${shortId(sessionSwitch.id)} → ${displaySessionCwd(target)}\n`
3933
+ : sessionSwitch
3934
+ ? `Resuming session ${shortId(sessionSwitch.id)} → ${displaySessionCwd(target)}\n`
3935
+ : `Switching workspace → ${target}\n`));
3779
3936
  try {
3780
3937
  const result = await runSelfAttached(args, target);
3781
3938
  if (result.signal) {
@@ -3843,8 +4000,13 @@ program.action(async (opts) => {
3843
4000
  return void out(c.red(`(${result.error})\n`));
3844
4001
  if (result.cwd === realpathSync.native(cwd))
3845
4002
  return void out(c.dim(`(already in ${result.cwd})\n`));
3846
- requestedWorkspaceSwitch = result.cwd;
3847
- return "exit";
4003
+ try {
4004
+ out(c.dim(queueWorkspaceSwitch(result.cwd) + "\n"));
4005
+ return "exit";
4006
+ }
4007
+ catch (error) {
4008
+ return void out(c.red(`(could not switch workspace without losing context: ${error instanceof Error ? error.message : String(error)})\n`));
4009
+ }
3848
4010
  },
3849
4011
  },
3850
4012
  {
@@ -3875,7 +4037,7 @@ program.action(async (opts) => {
3875
4037
  }
3876
4038
  if (target.id === sessionId)
3877
4039
  return void out(c.dim("(this session is already open)\n"));
3878
- requestedSessionSwitch = { id: target.id, cwd: target.cwd };
4040
+ requestedSessionSwitch = { id: target.id, cwd: target.cwd, kind: "resume" };
3879
4041
  return "exit";
3880
4042
  },
3881
4043
  },
@@ -4477,9 +4639,13 @@ program.action(async (opts) => {
4477
4639
  return void h.sink.notice(`(${result.error})`);
4478
4640
  if (result.cwd === realpathSync.native(cwd))
4479
4641
  return void h.sink.notice(`(already in ${result.cwd})`);
4480
- requestedWorkspaceSwitch = result.cwd;
4481
- h.sink.notice(`(switching workspace → ${result.cwd})`);
4482
- return void h.exit();
4642
+ try {
4643
+ h.sink.notice(queueWorkspaceSwitch(result.cwd));
4644
+ return void h.exit();
4645
+ }
4646
+ catch (error) {
4647
+ return void h.sink.notice(`(could not switch workspace without losing context: ${error instanceof Error ? error.message : String(error)})`);
4648
+ }
4483
4649
  }
4484
4650
  if (nm === "resume") {
4485
4651
  if (!arg) {
@@ -4496,7 +4662,7 @@ program.action(async (opts) => {
4496
4662
  }
4497
4663
  if (target.id === sessionId)
4498
4664
  return void h.sink.notice("(this session is already open)");
4499
- requestedSessionSwitch = { id: target.id, cwd: target.cwd };
4665
+ requestedSessionSwitch = { id: target.id, cwd: target.cwd, kind: "resume" };
4500
4666
  h.sink.notice(`(resuming ${shortId(target.id)} → ${displaySessionCwd(target.cwd)})`);
4501
4667
  return void h.exit();
4502
4668
  }
@@ -4821,7 +4987,7 @@ program.action(async (opts) => {
4821
4987
  const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
4822
4988
  let skillOutcome;
4823
4989
  try {
4824
- skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, taskIntake: taskIntakeForRun(), pendingInput, stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4990
+ skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, sessionId: meta.id, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, taskIntake: taskIntakeForRun(), pendingInput, stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4825
4991
  }
4826
4992
  catch (e) {
4827
4993
  h.sink.notice(`[error] ${e?.message ?? e}`);
@@ -4902,10 +5068,10 @@ program.action(async (opts) => {
4902
5068
  };
4903
5069
  let planOutcome = await runAgent(history, {
4904
5070
  provider,
4905
- ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
5071
+ ctx: { cwd, sandbox, sessionId: meta.id, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
4906
5072
  approval: "suggest",
4907
5073
  confirm: h.confirm,
4908
- toolFilter: (n) => READONLY_TOOLS.has(n),
5074
+ toolFilter: (n) => READONLY_TOOLS.has(n) || n === "memory_search" || n === "memory_get" || n === "session_search",
4909
5075
  hooks: false,
4910
5076
  extraTools: [exitPlanTool],
4911
5077
  systemOverride: PLAN_SYSTEM,
@@ -4959,7 +5125,7 @@ program.action(async (opts) => {
4959
5125
  const xout = stats.output;
4960
5126
  planOutcome = await runAgent(history, {
4961
5127
  provider,
4962
- ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
5128
+ ctx: { cwd, sandbox, sessionId: meta.id, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
4963
5129
  approval: choice,
4964
5130
  memory: buildMemory(),
4965
5131
  confirm: h.confirm,
@@ -5004,7 +5170,7 @@ program.action(async (opts) => {
5004
5170
  const beforeOut = stats.output;
5005
5171
  const turnOutcome = await runAgent(history, {
5006
5172
  provider,
5007
- ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
5173
+ ctx: { cwd, sandbox, sessionId: meta.id, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
5008
5174
  approval: appr,
5009
5175
  memory: buildMemory(),
5010
5176
  confirm: h.confirm,
@@ -5102,7 +5268,7 @@ program.action(async (opts) => {
5102
5268
  currentTurn = skillTurn;
5103
5269
  let skillOutcome;
5104
5270
  try {
5105
- skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, taskIntake: taskIntakeForRun(), stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
5271
+ skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, sessionId: meta.id, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, taskIntake: taskIntakeForRun(), stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
5106
5272
  }
5107
5273
  catch (e) {
5108
5274
  out(c.red(`\n[error] ${e.message}\n`));
@@ -5170,7 +5336,7 @@ program.action(async (opts) => {
5170
5336
  const t0 = Date.now();
5171
5337
  let turnOutcome;
5172
5338
  try {
5173
- turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext, taskIntake: taskIntakeForRun(), stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
5339
+ turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, sessionId: meta.id, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext, taskIntake: taskIntakeForRun(), stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
5174
5340
  }
5175
5341
  catch (e) {
5176
5342
  out(c.red(`\n[error] ${e.message}\n`));
@@ -38,6 +38,27 @@ export function redactSecrets(text) {
38
38
  }
39
39
  return { text: out, redactions };
40
40
  }
41
+ /** Safe load boundary for editable/synced legacy memory. Agent writes are checked on capture, but a user,
42
+ * older Hara version, or sync process can still change Markdown later. Redact secret-shaped values, remove
43
+ * injection/exfil lines, and fail closed if a cross-line pattern remains after filtering. */
44
+ export function sanitizeMemoryForPrompt(text) {
45
+ const redacted = redactSecrets(text);
46
+ if (scanMemory(redacted.text).ok) {
47
+ return { text: redacted.text, redactions: redacted.redactions, blockedLines: 0, blocked: false };
48
+ }
49
+ let blockedLines = 0;
50
+ const safeLines = redacted.text.split("\n").filter((line) => {
51
+ if (scanMemory(line).ok)
52
+ return true;
53
+ blockedLines += 1;
54
+ return false;
55
+ });
56
+ const safe = safeLines.join("\n");
57
+ if (!scanMemory(safe).ok) {
58
+ return { text: "", redactions: redacted.redactions, blockedLines, blocked: true };
59
+ }
60
+ return { text: safe, redactions: redacted.redactions, blockedLines, blocked: blockedLines > 0 };
61
+ }
41
62
  /** Deterministically generalize local identifiers so a captured snippet isn't tied to this machine:
42
63
  * the project path → <project>, the home dir → ~, and email addresses → <email>. Light + reversible. */
43
64
  export function scrubLocal(text, cwd) {
@@ -7,6 +7,7 @@ import { existsSync, readdirSync } from "node:fs";
7
7
  import { findProjectRoot } from "../context/agents-md.js";
8
8
  import { readModelContextFileSync, readVerifiedRegularFileSnapshot } from "../fs-read.js";
9
9
  import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
10
+ import { sanitizeMemoryForPrompt } from "./guard.js";
10
11
  // Per-source budgets for the frozen-snapshot digest (chars). Each source gets its own cap so a large
11
12
  // project MEMORY can't crowd out the (smaller but high-value) USER prefs — and each is cut at a line
12
13
  // boundary, never mid-entry. Anything beyond these is still reachable via memory_search. (hermes-style
@@ -78,6 +79,35 @@ export async function appendMemory(scope, target, content, cwd, signal) {
78
79
  });
79
80
  return f;
80
81
  }
82
+ function comparableEntry(value) {
83
+ return value
84
+ .normalize("NFKC")
85
+ .toLocaleLowerCase()
86
+ .replace(/^\s*[-*]\s+/, "")
87
+ .replace(/\s+/g, " ")
88
+ .trim();
89
+ }
90
+ /** Append one durable fact/preference only when an equivalent line is not already present. The duplicate
91
+ * decision and write share the same verified snapshot/CAS boundary, so proactive curation cannot keep
92
+ * piling up an identical memory on every session exit. */
93
+ export async function appendMemoryOnce(scope, target, content, cwd, signal) {
94
+ const f = targetFile(scope, target, cwd);
95
+ const { boundary, snapshot } = await inspectMemoryWrite(f, "append memory");
96
+ const wanted = comparableEntry(content);
97
+ const exists = snapshot?.text
98
+ .split("\n")
99
+ .some((line) => comparableEntry(line) === wanted);
100
+ if (wanted && exists)
101
+ return { path: f, appended: false };
102
+ const text = (snapshot ? `${snapshot.text}\n` : "") + content.trim() + "\n";
103
+ await atomicWriteText(boundary.target, text, {
104
+ expected: snapshot?.text ?? null,
105
+ expectedIdentity: snapshot ?? undefined,
106
+ boundary,
107
+ signal,
108
+ });
109
+ return { path: f, appended: true };
110
+ }
81
111
  export async function replaceMemory(scope, target, content, cwd, signal) {
82
112
  const f = targetFile(scope, target, cwd);
83
113
  await commitMemoryText(f, content.trim() + "\n", "replace memory", signal);
@@ -109,6 +139,7 @@ export async function forgetMemory(scope, target, match, cwd, signal) {
109
139
  export function memoryDigest(cwd) {
110
140
  const sources = [
111
141
  ["project", "memory", "project MEMORY"],
142
+ ["project", "user", "project USER preferences"],
112
143
  ["global", "memory", "global MEMORY"],
113
144
  ["global", "user", "USER preferences"],
114
145
  ];
@@ -118,7 +149,7 @@ export function memoryDigest(cwd) {
118
149
  if (!existsSync(f))
119
150
  continue;
120
151
  try {
121
- const t = readModelContextFileSync(f, MAX_MEMORY_SOURCE_BYTES).trim();
152
+ const t = sanitizeMemoryForPrompt(readModelContextFileSync(f, MAX_MEMORY_SOURCE_BYTES)).text.trim();
122
153
  if (t)
123
154
  parts.push(`## ${label}\n${capAtLine(t, SOURCE_CAP[target])}`);
124
155
  }
@@ -143,7 +174,7 @@ export function readRecentLogs(scope, cwd, days) {
143
174
  if (m && new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() < cutoff)
144
175
  continue;
145
176
  try {
146
- const t = readModelContextFileSync(join(dir, f), MAX_MEMORY_SOURCE_BYTES).trim();
177
+ const t = sanitizeMemoryForPrompt(readModelContextFileSync(join(dir, f), MAX_MEMORY_SOURCE_BYTES)).text.trim();
147
178
  if (t)
148
179
  out.push(`### ${f}\n${t}`);
149
180
  }
@@ -60,9 +60,15 @@ export function toOpenAI(system, history) {
60
60
  type: "function",
61
61
  function: { name: tu.name, arguments: JSON.stringify(tu.input ?? {}) },
62
62
  }));
63
+ // Some OpenAI-compatible providers (observed with DeepSeek) reject a historical assistant
64
+ // message whose content is null unless it also contains tool_calls. Empty model turns can exist in
65
+ // older persisted sessions, so omit that no-op history entry instead of serializing an invalid row.
66
+ // Keep content:null for real tool-call turns: that is the canonical Chat Completions shape.
67
+ if (!m.text.trim() && tool_calls.length === 0)
68
+ continue;
63
69
  msgs.push({
64
70
  role: "assistant",
65
- content: m.text || null,
71
+ content: m.text.trim() ? m.text : null,
66
72
  ...(tool_calls.length ? { tool_calls } : {}),
67
73
  });
68
74
  }