@nanhara/hara 0.130.1 → 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/CHANGELOG.md +61 -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/gateway/weixin.js +26 -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 +3 -2
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
|
-
|
|
3020
|
-
|
|
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 (
|
|
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
|
-
? `
|
|
3778
|
-
:
|
|
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
|
-
|
|
3847
|
-
|
|
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
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
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`));
|
package/dist/memory/guard.js
CHANGED
|
@@ -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) {
|
package/dist/memory/store.js
CHANGED
|
@@ -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
|
}
|
package/dist/providers/openai.js
CHANGED
|
@@ -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
|
|
71
|
+
content: m.text.trim() ? m.text : null,
|
|
66
72
|
...(tool_calls.length ? { tool_calls } : {}),
|
|
67
73
|
});
|
|
68
74
|
}
|