@nanhara/hara 0.123.1 → 0.124.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 +30 -0
- package/README.md +12 -9
- package/dist/agent/compact.js +98 -18
- package/dist/agent/context-budget.js +180 -0
- package/dist/agent/evolution.js +37 -0
- package/dist/agent/failover.js +3 -4
- package/dist/agent/loop.js +39 -4
- package/dist/config.js +27 -90
- package/dist/desk.js +8 -14
- package/dist/fs-read.js +26 -7
- package/dist/gateway/weixin.js +44 -42
- package/dist/index.js +240 -54
- package/dist/org-fleet/enroll.js +17 -22
- package/dist/plugins/plugins.js +7 -9
- package/dist/profile/profile.js +29 -56
- package/dist/providers/qwen-oauth.js +6 -19
- package/dist/security/private-state.js +285 -5
- package/dist/serve/server.js +59 -35
- package/dist/serve/sessions.js +12 -4
- package/dist/session/store.js +4 -0
- package/dist/session/task.js +70 -8
- package/dist/tools/memory.js +18 -3
- package/dist/tui/App.js +43 -17
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fl
|
|
|
26
26
|
import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
|
|
27
27
|
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
28
28
|
import { routingProvider } from "./agent/route.js";
|
|
29
|
-
import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "./agent/compact.js";
|
|
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
32
|
import { checkForUpdate } from "./update-check.js";
|
|
@@ -43,6 +43,7 @@ import { installScheduler, uninstallScheduler, isInstalled } from "./cron/instal
|
|
|
43
43
|
import { getTools } from "./tools/registry.js";
|
|
44
44
|
import { resetReachability } from "./tools/net-reachability.js";
|
|
45
45
|
import { resetRepeatGuard } from "./agent/repeat-guard.js";
|
|
46
|
+
import { allowsEvolutionTool, EVOLUTION_SYSTEM, evolutionStatus, shouldAutoEvolve } from "./agent/evolution.js";
|
|
46
47
|
import { EXPLORE_SYSTEM } from "./tools/agent.js";
|
|
47
48
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
48
49
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
@@ -67,7 +68,7 @@ function renderBgJobs() {
|
|
|
67
68
|
});
|
|
68
69
|
return `Background jobs — /jobs tail <id> · /jobs kill <id>:\n${rows.join("\n")}`;
|
|
69
70
|
}
|
|
70
|
-
import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
|
|
71
|
+
import { qwenDeviceLogin, getValidQwenAuth, loadQwenToken } from "./providers/qwen-oauth.js";
|
|
71
72
|
import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
|
|
72
73
|
import { homeWorkspaceActionError, isHomeWorkspace } from "./context/workspace-scope.js";
|
|
73
74
|
import { getEmbedder } from "./search/embed.js";
|
|
@@ -75,7 +76,7 @@ import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, i
|
|
|
75
76
|
import { searchHybrid } from "./search/hybrid.js";
|
|
76
77
|
import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
77
78
|
import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
|
|
78
|
-
import { continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, taskExecutionContext, } from "./session/task.js";
|
|
79
|
+
import { consumePendingTaskSteering, continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, hasPendingTaskSteering, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, requestsTaskContinuation, taskExecutionContext, } from "./session/task.js";
|
|
79
80
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
80
81
|
import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
81
82
|
import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
|
|
@@ -860,31 +861,51 @@ const PLAN_SYSTEM = "You are in PLAN MODE — a read-only investigation phase. E
|
|
|
860
861
|
"When (and only when) you have a complete, actionable plan, call the `exit_plan` tool with the full plan " +
|
|
861
862
|
"(concise markdown, short numbered steps), then stop and wait for the user's decision. " +
|
|
862
863
|
"If the user is asking a question, or the plan isn't ready yet, just answer normally WITHOUT calling exit_plan.";
|
|
863
|
-
const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
|
|
864
|
-
"conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
|
|
865
864
|
const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
|
|
866
865
|
"the current durable memory and recent daily logs. Extract ONLY durable, reusable facts / decisions / " +
|
|
867
866
|
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
868
867
|
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
869
868
|
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
870
|
-
|
|
871
|
-
//
|
|
869
|
+
async function curateSessionLearning(provider, history, cfg, options) {
|
|
870
|
+
// Curation gets a private transcript branch: its DONE/tool chatter must not become task conversation or
|
|
871
|
+
// create assistant→assistant role adjacency. Memory/skill writes remain the only durable side effects.
|
|
872
|
+
const curationHistory = [
|
|
873
|
+
...history,
|
|
874
|
+
{ role: "user", content: "Run the evidence-gated self-evolution curation now. If nothing qualifies, write nothing." },
|
|
875
|
+
];
|
|
876
|
+
return runAgent(curationHistory, {
|
|
877
|
+
provider,
|
|
878
|
+
ctx: { cwd: options.cwd, sandbox: options.sandbox, spawn: options.spawn, ui: options.ui },
|
|
879
|
+
approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest",
|
|
880
|
+
confirm: options.confirm,
|
|
881
|
+
toolFilter: (name) => allowsEvolutionTool(name, cfg.assetCapture),
|
|
882
|
+
systemOverride: EVOLUTION_SYSTEM,
|
|
883
|
+
memory: options.memory,
|
|
884
|
+
stats: options.stats,
|
|
885
|
+
signal: options.signal,
|
|
886
|
+
...agentRunLimits(cfg),
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
// The bounded checkpoint contract + recent-turn preservation live in agent/compact.ts so CLI and serve
|
|
890
|
+
// cannot drift into different context semantics.
|
|
872
891
|
/** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
|
|
873
892
|
* /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
|
|
874
893
|
async function compactConversation(provider, history, meta, stats, signal, task) {
|
|
875
894
|
if (history.length < 2 || signal?.aborted)
|
|
876
895
|
return null;
|
|
896
|
+
const recent = recentHistoryForCompaction(history);
|
|
877
897
|
const r = await boundedProviderTurn(provider, {
|
|
878
898
|
system: COMPACT_SYSTEM,
|
|
879
|
-
history: [...history, { role: "user", content: "
|
|
899
|
+
history: [...compactionSourceHistory(history), { role: "user", content: "Create the bounded execution checkpoint now." }],
|
|
880
900
|
tools: [],
|
|
881
901
|
onText: () => { },
|
|
882
902
|
}, { timeoutMs: 60_000, label: "conversation compaction", signal });
|
|
883
903
|
if (signal?.aborted || r.stop === "error")
|
|
884
904
|
return null;
|
|
885
|
-
const
|
|
886
|
-
if (!
|
|
905
|
+
const rawSummary = r.text.trim();
|
|
906
|
+
if (!rawSummary)
|
|
887
907
|
return null;
|
|
908
|
+
const summary = normalizeCompactionSummary(rawSummary);
|
|
888
909
|
const workingSet = workingSetFromSummary(summary);
|
|
889
910
|
// TW5-style file restore: the summary alone loses the working set's ACTUAL content — re-attach the
|
|
890
911
|
// most recently touched files (current on-disk state, byte-capped) so work continues without re-reads.
|
|
@@ -902,13 +923,12 @@ async function compactConversation(provider, history, meta, stats, signal, task)
|
|
|
902
923
|
if (signal?.aborted)
|
|
903
924
|
return null;
|
|
904
925
|
meta.workingSet = workingSet; // survives the history wipe + injects into the next turns
|
|
926
|
+
const compacted = compactedConversationHistory(summary, recent, restore);
|
|
905
927
|
history.length = 0;
|
|
906
|
-
history.push(
|
|
907
|
-
if (restore)
|
|
908
|
-
history.push({ role: "user", content: restore });
|
|
928
|
+
history.push(...compacted);
|
|
909
929
|
stats.input += r.usage?.input ?? 0;
|
|
910
930
|
stats.output += r.usage?.output ?? 0;
|
|
911
|
-
stats.lastInput =
|
|
931
|
+
stats.lastInput = compactedHistoryTokenEstimate(compacted); // reflect replacement, not the large summarizer request
|
|
912
932
|
saveSession(meta, history, task);
|
|
913
933
|
return summary;
|
|
914
934
|
}
|
|
@@ -1023,7 +1043,7 @@ function runDoctor(cfg) {
|
|
|
1023
1043
|
const dot = c.dim("·");
|
|
1024
1044
|
const nodeSupported = unsupportedNodeMessage() === null;
|
|
1025
1045
|
const hasKey = !!(cfg.apiKey || process.env[providerEnvKey(cfg.provider)] || process.env.HARA_API_KEY);
|
|
1026
|
-
const oauthOk = cfg.provider === "qwen-oauth" &&
|
|
1046
|
+
const oauthOk = cfg.provider === "qwen-oauth" && loadQwenToken() !== null;
|
|
1027
1047
|
const authed = hasKey || oauthOk;
|
|
1028
1048
|
const ad = assetsDir();
|
|
1029
1049
|
const roles = loadRoles(cfg.cwd);
|
|
@@ -2006,14 +2026,8 @@ program
|
|
|
2006
2026
|
return void out(c.yellow(`↩ ${pane} registered, but no WeChat login (run \`hara gateway --platform weixin --login\`). Your next reply to the bot still injects here.\n`));
|
|
2007
2027
|
let peer = process.env.HARA_WX_PEER;
|
|
2008
2028
|
if (!peer) {
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
const keys = Object.keys(JSON.parse(readFileSync(f, "utf8")));
|
|
2012
|
-
peer = keys.find((k) => k.endsWith("@im.wechat")) || keys[0];
|
|
2013
|
-
}
|
|
2014
|
-
catch {
|
|
2015
|
-
/* no peer file */
|
|
2016
|
-
}
|
|
2029
|
+
const peers = wx.weixinKnownPeers(creds.account_id);
|
|
2030
|
+
peer = peers.find((candidate) => candidate.endsWith("@im.wechat")) || peers[0];
|
|
2017
2031
|
}
|
|
2018
2032
|
if (peer)
|
|
2019
2033
|
await wx.weixinAdapter(creds).send(peer, text);
|
|
@@ -2416,7 +2430,7 @@ memoryCmd
|
|
|
2416
2430
|
ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
|
|
2417
2431
|
approval: "full-auto",
|
|
2418
2432
|
confirm: async () => true,
|
|
2419
|
-
toolFilter: (n) => n
|
|
2433
|
+
toolFilter: (n) => allowsEvolutionTool(n, "off"),
|
|
2420
2434
|
systemOverride: MEMORY_DISTILL_SYSTEM,
|
|
2421
2435
|
stats,
|
|
2422
2436
|
...agentRunLimits(cfg),
|
|
@@ -3061,6 +3075,16 @@ program.action(async (opts) => {
|
|
|
3061
3075
|
}
|
|
3062
3076
|
schemaObj = parsed;
|
|
3063
3077
|
}
|
|
3078
|
+
// Recover any steering that was acknowledged by serve before a crash but had not yet reached a model
|
|
3079
|
+
// round. The next session write commits its transcript copy and consumed marker atomically.
|
|
3080
|
+
const recoveredHeadlessSteering = consumePendingTaskSteering(task);
|
|
3081
|
+
if (recoveredHeadlessSteering) {
|
|
3082
|
+
task = recoveredHeadlessSteering.task;
|
|
3083
|
+
history.push(...recoveredHeadlessSteering.entries.map((entry) => ({
|
|
3084
|
+
role: "user",
|
|
3085
|
+
content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
|
|
3086
|
+
})));
|
|
3087
|
+
}
|
|
3064
3088
|
// Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
|
|
3065
3089
|
// Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
|
|
3066
3090
|
// visionModel sidecar and folded into the message (text-only models can't take image blocks).
|
|
@@ -3095,7 +3119,8 @@ program.action(async (opts) => {
|
|
|
3095
3119
|
// A resumed unfinished task keeps its objective; this prompt is a new turn that steers it. A completed
|
|
3096
3120
|
// task (or a stateless -p run) starts a fresh execution identity. Persist before provider side effects so
|
|
3097
3121
|
// a crash is recoverable as paused rather than looking like a completed turn.
|
|
3098
|
-
const headlessInteraction = task && task.status !== "completed"
|
|
3122
|
+
const headlessInteraction = task && task.status !== "completed" &&
|
|
3123
|
+
(recoveredHeadlessSteering !== null || requestsTaskContinuation(String(opts.print)))
|
|
3099
3124
|
? newSteerInteraction(task.turnId)
|
|
3100
3125
|
: newTurnInteraction();
|
|
3101
3126
|
if (headlessInteraction.kind === "steer") {
|
|
@@ -3104,6 +3129,9 @@ program.action(async (opts) => {
|
|
|
3104
3129
|
}
|
|
3105
3130
|
else {
|
|
3106
3131
|
task = createTaskExecution(String(opts.print), headlessInteraction.turnId);
|
|
3132
|
+
clearTodos();
|
|
3133
|
+
if (meta)
|
|
3134
|
+
meta.todos = [];
|
|
3107
3135
|
}
|
|
3108
3136
|
if (meta)
|
|
3109
3137
|
saveSession(meta, history, task);
|
|
@@ -3141,7 +3169,7 @@ program.action(async (opts) => {
|
|
|
3141
3169
|
projectContext,
|
|
3142
3170
|
memory: memoryDigest(cwd),
|
|
3143
3171
|
continuationSession,
|
|
3144
|
-
executionContext: taskExecutionContext(task, headlessInteraction),
|
|
3172
|
+
executionContext: taskExecutionContext(task, headlessInteraction, meta?.todos ?? []),
|
|
3145
3173
|
...(roleOverride ? { systemOverride: roleOverride } : {}),
|
|
3146
3174
|
...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
|
|
3147
3175
|
hooks: headlessHooks,
|
|
@@ -3432,6 +3460,14 @@ program.action(async (opts) => {
|
|
|
3432
3460
|
};
|
|
3433
3461
|
const commands = [
|
|
3434
3462
|
{ name: "help", desc: "show this help", run: () => void out(helpText(commands)) },
|
|
3463
|
+
{
|
|
3464
|
+
name: "continue",
|
|
3465
|
+
aliases: ["resume"],
|
|
3466
|
+
desc: "resume the unfinished task: /continue [instruction]",
|
|
3467
|
+
// Interactive loops translate this command into an ordinary model turn before slash dispatch. This
|
|
3468
|
+
// fallback only protects future callers that invoke the command table directly.
|
|
3469
|
+
run: () => void out(c.dim("(type /continue [instruction] in an interactive session)\n")),
|
|
3470
|
+
},
|
|
3435
3471
|
{
|
|
3436
3472
|
name: "init",
|
|
3437
3473
|
desc: "analyze project & regenerate AGENTS.md",
|
|
@@ -3632,6 +3668,18 @@ program.action(async (opts) => {
|
|
|
3632
3668
|
out(formatTaskExecution(task) + "\n");
|
|
3633
3669
|
},
|
|
3634
3670
|
},
|
|
3671
|
+
{
|
|
3672
|
+
name: "new",
|
|
3673
|
+
desc: "start a new task while keeping conversation context",
|
|
3674
|
+
run: () => {
|
|
3675
|
+
task = undefined;
|
|
3676
|
+
resumeTaskPending = false;
|
|
3677
|
+
clearTodos();
|
|
3678
|
+
meta.todos = [];
|
|
3679
|
+
persistSession();
|
|
3680
|
+
out(c.dim("(new task boundary; conversation kept — type the new objective)\n"));
|
|
3681
|
+
},
|
|
3682
|
+
},
|
|
3635
3683
|
{
|
|
3636
3684
|
name: "rewind",
|
|
3637
3685
|
desc: "fork the conversation back to an earlier turn: /rewind (list) · /rewind <n> (files unchanged)",
|
|
@@ -3668,6 +3716,32 @@ program.action(async (opts) => {
|
|
|
3668
3716
|
out(k == null ? c.red("(restore failed)\n") : c.green(`(restored ${k} file(s) to ${cp.sha} — '${cp.label}'; prior state snapshotted too)\n`));
|
|
3669
3717
|
},
|
|
3670
3718
|
},
|
|
3719
|
+
{
|
|
3720
|
+
name: "evolve",
|
|
3721
|
+
desc: "show or run auditable self-evolution: /evolve [status|now]",
|
|
3722
|
+
run: async (a) => {
|
|
3723
|
+
const action = (a ?? "").trim() || "status";
|
|
3724
|
+
if (action === "status")
|
|
3725
|
+
return void out(evolutionStatus(cfg) + "\n");
|
|
3726
|
+
if (action !== "now")
|
|
3727
|
+
return void out(c.dim("usage: /evolve [status|now]\n"));
|
|
3728
|
+
if (cfg.evolve === "off")
|
|
3729
|
+
return void out(c.dim("(self-evolution is off — set `hara config set evolve light|proactive` first)\n"));
|
|
3730
|
+
if (history.length < 2)
|
|
3731
|
+
return void out(c.dim("(not enough session evidence to curate)\n"));
|
|
3732
|
+
out(c.dim("Curating evidence-backed memories and reusable skills…\n"));
|
|
3733
|
+
const outcome = await curateSessionLearning(provider, history, cfg, {
|
|
3734
|
+
cwd,
|
|
3735
|
+
sandbox,
|
|
3736
|
+
spawn,
|
|
3737
|
+
confirm,
|
|
3738
|
+
memory: buildMemory(),
|
|
3739
|
+
stats,
|
|
3740
|
+
});
|
|
3741
|
+
persistSession();
|
|
3742
|
+
out(outcome.status === "completed" ? c.green("(self-evolution curation complete)\n") : c.yellow(`(self-evolution stopped: ${outcome.error ?? outcome.status})\n`));
|
|
3743
|
+
},
|
|
3744
|
+
},
|
|
3671
3745
|
{
|
|
3672
3746
|
name: "compact",
|
|
3673
3747
|
desc: "summarize the conversation so far to free up context",
|
|
@@ -3677,6 +3751,21 @@ program.action(async (opts) => {
|
|
|
3677
3751
|
currentTurn = compactTurn;
|
|
3678
3752
|
let summary = null;
|
|
3679
3753
|
try {
|
|
3754
|
+
if (cfg.evolve !== "off") {
|
|
3755
|
+
const distill = await curateSessionLearning(provider, history, cfg, {
|
|
3756
|
+
cwd,
|
|
3757
|
+
sandbox,
|
|
3758
|
+
spawn,
|
|
3759
|
+
confirm,
|
|
3760
|
+
memory: buildMemory(),
|
|
3761
|
+
stats,
|
|
3762
|
+
signal: compactTurn.signal,
|
|
3763
|
+
});
|
|
3764
|
+
if (distill.status !== "completed") {
|
|
3765
|
+
out(c.yellow(`(compact cancelled — self-evolution stopped: ${distill.error ?? distill.status})\n`));
|
|
3766
|
+
return;
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3680
3769
|
summary = await compactConversation(provider, history, meta, stats, compactTurn.signal, task);
|
|
3681
3770
|
}
|
|
3682
3771
|
finally {
|
|
@@ -3731,7 +3820,24 @@ program.action(async (opts) => {
|
|
|
3731
3820
|
out(c.dim("(context cleared)\n"));
|
|
3732
3821
|
},
|
|
3733
3822
|
},
|
|
3734
|
-
{
|
|
3823
|
+
{
|
|
3824
|
+
name: "exit",
|
|
3825
|
+
aliases: ["quit"],
|
|
3826
|
+
desc: "leave",
|
|
3827
|
+
run: async () => {
|
|
3828
|
+
if (shouldAutoEvolve(cfg.evolve, history.length)) {
|
|
3829
|
+
out(c.dim("Distilling session learnings…\n"));
|
|
3830
|
+
try {
|
|
3831
|
+
await curateSessionLearning(provider, history, cfg, { cwd, sandbox, spawn, confirm, memory: buildMemory(), stats });
|
|
3832
|
+
persistSession();
|
|
3833
|
+
}
|
|
3834
|
+
catch {
|
|
3835
|
+
/* exiting remains available if optional curation fails */
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
return "exit";
|
|
3839
|
+
},
|
|
3840
|
+
},
|
|
3735
3841
|
];
|
|
3736
3842
|
const byName = new Map();
|
|
3737
3843
|
for (const cmd of commands) {
|
|
@@ -3896,26 +4002,37 @@ program.action(async (opts) => {
|
|
|
3896
4002
|
onClipboardImage: readClipboardImage,
|
|
3897
4003
|
vim: cfg.vimMode,
|
|
3898
4004
|
onSubmit: async (line, h, images, interaction) => {
|
|
4005
|
+
// `/continue` is a task interaction, not a control-only command: turn it into a model message before
|
|
4006
|
+
// slash dispatch, preserving the submitted turn id while explicitly targeting the unfinished task.
|
|
4007
|
+
const continueCommand = /^\/(?:continue|resume)(?:\s+([\s\S]+))?$/i.exec(line.trim());
|
|
4008
|
+
if (continueCommand) {
|
|
4009
|
+
if (!task || task.status === "completed")
|
|
4010
|
+
return void h.sink.notice("(there is no unfinished task to continue)");
|
|
4011
|
+
line = continueCommand[1]?.trim() || "continue";
|
|
4012
|
+
interaction = {
|
|
4013
|
+
kind: "steer",
|
|
4014
|
+
expectedTurnId: task.turnId,
|
|
4015
|
+
turnId: interaction?.turnId ?? newTurnInteraction().turnId,
|
|
4016
|
+
};
|
|
4017
|
+
}
|
|
3899
4018
|
// A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
|
|
3900
4019
|
// is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
|
|
3901
4020
|
if (isSlashCommand(line)) {
|
|
3902
4021
|
const [nm, ...rest] = line.slice(1).split(/\s+/);
|
|
3903
4022
|
const arg = rest.join(" ").trim();
|
|
3904
4023
|
if (nm === "exit" || nm === "quit") {
|
|
3905
|
-
if (cfg.evolve
|
|
4024
|
+
if (shouldAutoEvolve(cfg.evolve, history.length)) {
|
|
3906
4025
|
h.sink.notice("✻ distilling session learnings…");
|
|
3907
4026
|
try {
|
|
3908
|
-
await
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
4027
|
+
await curateSessionLearning(provider, history, cfg, {
|
|
4028
|
+
cwd,
|
|
4029
|
+
sandbox,
|
|
4030
|
+
spawn,
|
|
4031
|
+
ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice },
|
|
3912
4032
|
confirm: h.confirm,
|
|
3913
|
-
toolFilter: (n) => n === "memory_write" || (cfg.assetCapture !== "off" && n === "skill_create") || READONLY_TOOLS.has(n),
|
|
3914
|
-
systemOverride: DISTILL_SYSTEM,
|
|
3915
4033
|
memory: buildMemory(),
|
|
3916
4034
|
stats,
|
|
3917
4035
|
signal: h.signal,
|
|
3918
|
-
...agentRunLimits(cfg),
|
|
3919
4036
|
});
|
|
3920
4037
|
persistSession();
|
|
3921
4038
|
}
|
|
@@ -3929,6 +4046,29 @@ program.action(async (opts) => {
|
|
|
3929
4046
|
return void h.sink.notice(commands.map((x) => `/${x.name} — ${x.desc}`).join("\n"));
|
|
3930
4047
|
if (nm === "tools")
|
|
3931
4048
|
return void h.sink.notice(getTools().map((t) => `${t.name}${t.kind !== "read" ? " *" : ""} — ${t.description}`).join("\n"));
|
|
4049
|
+
if (nm === "evolve") {
|
|
4050
|
+
if (!arg || arg === "status")
|
|
4051
|
+
return void h.sink.notice(evolutionStatus(cfg));
|
|
4052
|
+
if (arg !== "now")
|
|
4053
|
+
return void h.sink.notice("usage: /evolve [status|now]");
|
|
4054
|
+
if (cfg.evolve === "off")
|
|
4055
|
+
return void h.sink.notice("(self-evolution is off — set `hara config set evolve light|proactive` first)");
|
|
4056
|
+
if (history.length < 2)
|
|
4057
|
+
return void h.sink.notice("(not enough session evidence to curate)");
|
|
4058
|
+
h.sink.notice("✻ curating evidence-backed memories and reusable skills…");
|
|
4059
|
+
const outcome = await curateSessionLearning(provider, history, cfg, {
|
|
4060
|
+
cwd,
|
|
4061
|
+
sandbox,
|
|
4062
|
+
spawn,
|
|
4063
|
+
ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice },
|
|
4064
|
+
confirm: h.confirm,
|
|
4065
|
+
memory: buildMemory(),
|
|
4066
|
+
stats,
|
|
4067
|
+
signal: h.signal,
|
|
4068
|
+
});
|
|
4069
|
+
persistSession();
|
|
4070
|
+
return void h.sink.notice(outcome.status === "completed" ? "(self-evolution curation complete)" : `(self-evolution stopped: ${outcome.error ?? outcome.status})`);
|
|
4071
|
+
}
|
|
3932
4072
|
if (nm === "reset" || nm === "clear") {
|
|
3933
4073
|
history.length = 0;
|
|
3934
4074
|
task = undefined;
|
|
@@ -4016,6 +4156,14 @@ program.action(async (opts) => {
|
|
|
4016
4156
|
}
|
|
4017
4157
|
return void h.sink.notice(formatTaskExecution(task));
|
|
4018
4158
|
}
|
|
4159
|
+
if (nm === "new") {
|
|
4160
|
+
task = undefined;
|
|
4161
|
+
resumeTaskPending = false;
|
|
4162
|
+
clearTodos();
|
|
4163
|
+
meta.todos = [];
|
|
4164
|
+
persistSession();
|
|
4165
|
+
return void h.sink.notice("(new task boundary; conversation kept — type the new objective)");
|
|
4166
|
+
}
|
|
4019
4167
|
if (nm === "rewind") {
|
|
4020
4168
|
if (!arg) {
|
|
4021
4169
|
const turns = userTurnPreviews(history);
|
|
@@ -4051,17 +4199,15 @@ program.action(async (opts) => {
|
|
|
4051
4199
|
let distillOutcome;
|
|
4052
4200
|
if (cfg.evolve !== "off") {
|
|
4053
4201
|
try {
|
|
4054
|
-
distillOutcome = await
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4202
|
+
distillOutcome = await curateSessionLearning(provider, history, cfg, {
|
|
4203
|
+
cwd,
|
|
4204
|
+
sandbox,
|
|
4205
|
+
spawn,
|
|
4206
|
+
ui: cui,
|
|
4058
4207
|
confirm: h.confirm,
|
|
4059
|
-
toolFilter: (n) => n === "memory_write" || (cfg.assetCapture !== "off" && n === "skill_create") || READONLY_TOOLS.has(n),
|
|
4060
|
-
systemOverride: DISTILL_SYSTEM,
|
|
4061
4208
|
memory: buildMemory(),
|
|
4062
4209
|
stats,
|
|
4063
4210
|
signal: h.signal,
|
|
4064
|
-
...agentRunLimits(cfg),
|
|
4065
4211
|
});
|
|
4066
4212
|
}
|
|
4067
4213
|
catch {
|
|
@@ -4171,6 +4317,8 @@ program.action(async (opts) => {
|
|
|
4171
4317
|
if (sk) {
|
|
4172
4318
|
h.sink.notice(`↗ entering ${sk.id}…`);
|
|
4173
4319
|
resumeTaskPending = false; // an explicit skill entry starts its own task
|
|
4320
|
+
clearTodos();
|
|
4321
|
+
meta.todos = [];
|
|
4174
4322
|
const skillContent = `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${arg ? ` — request: ${arg}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`;
|
|
4175
4323
|
const skillInteraction = interaction ?? newTurnInteraction();
|
|
4176
4324
|
if (skillInteraction.kind === "steer") {
|
|
@@ -4218,7 +4366,8 @@ program.action(async (opts) => {
|
|
|
4218
4366
|
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
4219
4367
|
const appr = h.approval;
|
|
4220
4368
|
let submittedInteraction = interaction ?? newTurnInteraction();
|
|
4221
|
-
if (resumeTaskPending && task && submittedInteraction.kind === "turn"
|
|
4369
|
+
if (resumeTaskPending && task && submittedInteraction.kind === "turn" &&
|
|
4370
|
+
(hasPendingTaskSteering(task) || requestsTaskContinuation(line))) {
|
|
4222
4371
|
submittedInteraction = { kind: "steer", expectedTurnId: task.turnId, turnId: submittedInteraction.turnId };
|
|
4223
4372
|
}
|
|
4224
4373
|
const beginExecution = (objective) => {
|
|
@@ -4232,15 +4381,17 @@ program.action(async (opts) => {
|
|
|
4232
4381
|
}
|
|
4233
4382
|
else {
|
|
4234
4383
|
task = createTaskExecution(objective, submittedInteraction.turnId);
|
|
4384
|
+
clearTodos();
|
|
4385
|
+
meta.todos = [];
|
|
4235
4386
|
}
|
|
4236
4387
|
resumeTaskPending = false;
|
|
4237
|
-
return taskExecutionContext(task, submittedInteraction);
|
|
4388
|
+
return taskExecutionContext(task, submittedInteraction, meta.todos ?? []);
|
|
4238
4389
|
};
|
|
4239
4390
|
// Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
|
|
4240
4391
|
// clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
|
|
4241
4392
|
// Shared by every turn below (plan investigate, plan execute, and the regular turn).
|
|
4242
4393
|
const pendingInput = async () => {
|
|
4243
|
-
const
|
|
4394
|
+
const freshMessages = new Map();
|
|
4244
4395
|
for (const it of h.drainQueue()) {
|
|
4245
4396
|
const r2 = await resolveImages(it.images, h);
|
|
4246
4397
|
const body = await expandMentionsAsync(it.line, cwd, { signal: h.signal }) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
@@ -4253,13 +4404,25 @@ program.action(async (opts) => {
|
|
|
4253
4404
|
continue;
|
|
4254
4405
|
}
|
|
4255
4406
|
task = recorded.task;
|
|
4256
|
-
|
|
4407
|
+
const accepted = task.steering?.at(-1);
|
|
4408
|
+
if (accepted?.deliveryState === "pending") {
|
|
4409
|
+
freshMessages.set(accepted.id, { role: "user", content: `${INTERJECT_PREFIX}\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
4410
|
+
}
|
|
4257
4411
|
}
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4412
|
+
const consumed = consumePendingTaskSteering(task);
|
|
4413
|
+
if (!consumed)
|
|
4414
|
+
return [];
|
|
4415
|
+
const out = consumed.entries.map((entry) => freshMessages.get(entry.id) ?? ({
|
|
4416
|
+
role: "user",
|
|
4417
|
+
content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
|
|
4418
|
+
}));
|
|
4419
|
+
// Persist the projected transcript and consumed inbox state first, then update the same live
|
|
4420
|
+
// history synchronously. Returning [] prevents runAgent from appending the messages twice;
|
|
4421
|
+
// loop from appending twice; cancellation/error saves can no longer overwrite the projected input.
|
|
4422
|
+
saveSession(meta, [...history, ...out], consumed.task);
|
|
4423
|
+
task = consumed.task;
|
|
4424
|
+
history.push(...out);
|
|
4425
|
+
return [];
|
|
4263
4426
|
};
|
|
4264
4427
|
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
4265
4428
|
if (appr === "plan") {
|
|
@@ -4459,6 +4622,16 @@ program.action(async (opts) => {
|
|
|
4459
4622
|
bar.renderBottom(); // bottom border + modes/usage
|
|
4460
4623
|
if (!line)
|
|
4461
4624
|
continue;
|
|
4625
|
+
let forcedContinuation;
|
|
4626
|
+
const continueCommand = /^\/(?:continue|resume)(?:\s+([\s\S]+))?$/i.exec(line);
|
|
4627
|
+
if (continueCommand) {
|
|
4628
|
+
if (!task || task.status === "completed") {
|
|
4629
|
+
out(c.dim("(there is no unfinished task to continue)\n"));
|
|
4630
|
+
continue;
|
|
4631
|
+
}
|
|
4632
|
+
line = continueCommand[1]?.trim() || "continue";
|
|
4633
|
+
forcedContinuation = newSteerInteraction(task.turnId);
|
|
4634
|
+
}
|
|
4462
4635
|
// A dropped/pasted file path starts with '/' but isn't a command — read it, don't error (see TUI path).
|
|
4463
4636
|
if (isSlashCommand(line)) {
|
|
4464
4637
|
const [name, ...rest] = line.slice(1).split(/\s+/);
|
|
@@ -4472,6 +4645,8 @@ program.action(async (opts) => {
|
|
|
4472
4645
|
const skillContent = `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${rest.length ? ` — request: ${rest.join(" ")}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`;
|
|
4473
4646
|
const skillInteraction = newTurnInteraction();
|
|
4474
4647
|
resumeTaskPending = false;
|
|
4648
|
+
clearTodos();
|
|
4649
|
+
meta.todos = [];
|
|
4475
4650
|
task = createTaskExecution(rest.join(" ") || `enter ${sk.id}`, skillInteraction.turnId);
|
|
4476
4651
|
const skillExecutionContext = taskExecutionContext(task, skillInteraction);
|
|
4477
4652
|
history.push({ role: "user", content: skillContent });
|
|
@@ -4512,9 +4687,18 @@ program.action(async (opts) => {
|
|
|
4512
4687
|
line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
|
|
4513
4688
|
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd);
|
|
4514
4689
|
recalledContext = "";
|
|
4515
|
-
|
|
4516
|
-
if (
|
|
4690
|
+
const recoveredClassicSteering = consumePendingTaskSteering(task);
|
|
4691
|
+
if (recoveredClassicSteering) {
|
|
4692
|
+
task = recoveredClassicSteering.task;
|
|
4693
|
+
history.push(...recoveredClassicSteering.entries.map((entry) => ({
|
|
4694
|
+
role: "user",
|
|
4695
|
+
content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
|
|
4696
|
+
})));
|
|
4697
|
+
}
|
|
4698
|
+
let interaction = forcedContinuation ?? newTurnInteraction();
|
|
4699
|
+
if (resumeTaskPending && task && (recoveredClassicSteering !== null || requestsTaskContinuation(line))) {
|
|
4517
4700
|
interaction = newSteerInteraction(task.turnId);
|
|
4701
|
+
}
|
|
4518
4702
|
if (interaction.kind === "steer") {
|
|
4519
4703
|
const continued = continueTaskExecution(task, interaction);
|
|
4520
4704
|
if (!continued.ok) {
|
|
@@ -4525,9 +4709,11 @@ program.action(async (opts) => {
|
|
|
4525
4709
|
}
|
|
4526
4710
|
else {
|
|
4527
4711
|
task = createTaskExecution(line, interaction.turnId);
|
|
4712
|
+
clearTodos();
|
|
4713
|
+
meta.todos = [];
|
|
4528
4714
|
}
|
|
4529
4715
|
resumeTaskPending = false;
|
|
4530
|
-
const executionContext = taskExecutionContext(task, interaction);
|
|
4716
|
+
const executionContext = taskExecutionContext(task, interaction, meta.todos ?? []);
|
|
4531
4717
|
history.push({ role: "user", content: userContent });
|
|
4532
4718
|
persistSession();
|
|
4533
4719
|
if (cfg.fileCheckpoints)
|
package/dist/org-fleet/enroll.js
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
// POST {gateway}/v1/chat/completions (OpenAI-compatible; the normal agent traffic, Bearer <device_token>)
|
|
12
12
|
import { homedir, hostname, platform } from "node:os";
|
|
13
13
|
import { dirname, join, resolve } from "node:path";
|
|
14
|
-
import {
|
|
14
|
+
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
|
|
15
15
|
import { orgRolesDir } from "../org/roles.js";
|
|
16
16
|
import { loadActiveProfile } from "../profile/profile.js";
|
|
17
|
-
|
|
17
|
+
import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
|
|
18
18
|
const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
|
|
19
19
|
/** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
|
|
20
20
|
export function gatewayBaseURL(e) {
|
|
@@ -24,16 +24,15 @@ export function loadEnrollment() {
|
|
|
24
24
|
// 1) Legacy storage (~/.hara/org.json) for back-compat with pre-profile builds. After the
|
|
25
25
|
// profile migration runs (lazily on any profile.ts read), org.json is renamed to .legacy
|
|
26
26
|
// so this branch only fires for users who never touched the new profile layer yet.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
27
|
+
try {
|
|
28
|
+
const binding = bindPrivateHaraStateFile(homedir(), [], "org.json");
|
|
29
|
+
const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
|
|
30
|
+
const e = snapshot ? JSON.parse(snapshot.text) : null;
|
|
31
|
+
if (e && typeof e === "object" && e.gatewayUrl && e.deviceToken)
|
|
32
|
+
return e;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* fall through to profile-derived */
|
|
37
36
|
}
|
|
38
37
|
// 2) Active-profile path. profile.ts doesn't import enroll.ts so this static import is safe.
|
|
39
38
|
try {
|
|
@@ -55,19 +54,15 @@ export function loadEnrollment() {
|
|
|
55
54
|
return null;
|
|
56
55
|
}
|
|
57
56
|
function saveEnrollment(e) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
chmodSync(orgPath(), 0o600);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
/* best-effort */
|
|
65
|
-
}
|
|
57
|
+
const binding = bindPrivateHaraStateFile(homedir(), [], "org.json");
|
|
58
|
+
writePrivateStateFileSync(binding, JSON.stringify(e, null, 2) + "\n");
|
|
66
59
|
}
|
|
67
60
|
export function clearEnrollment() {
|
|
68
|
-
|
|
61
|
+
const binding = bindPrivateHaraStateFile(homedir(), [], "org.json");
|
|
62
|
+
const snapshot = readPrivateStateFileSnapshotSync(binding.path, 1024 * 1024);
|
|
63
|
+
if (!snapshot)
|
|
69
64
|
return false;
|
|
70
|
-
|
|
65
|
+
removePrivateStateFile(binding.path, snapshot, binding.directory);
|
|
71
66
|
return true;
|
|
72
67
|
}
|
|
73
68
|
/** Parse a control-plane enroll response (tolerant of snake_case / camelCase) into an Enrollment. */
|
package/dist/plugins/plugins.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
|
|
3
3
|
// index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
|
|
4
4
|
// .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
|
|
5
|
-
import { readFileSync,
|
|
5
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
|
|
6
6
|
import { join, resolve, isAbsolute } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
9
|
-
import { readRawConfig } from "../config.js";
|
|
9
|
+
import { readRawConfig, updateRawConfig } from "../config.js";
|
|
10
10
|
export function pluginsDir() {
|
|
11
11
|
return join(homedir(), ".hara", "plugins");
|
|
12
12
|
}
|
|
@@ -193,11 +193,9 @@ export function panelsForProject(cwd) {
|
|
|
193
193
|
}
|
|
194
194
|
/** Persist a plugin's enabled flag in ~/.hara/config.json (`plugins.enabled[name]`). */
|
|
195
195
|
export function setPluginEnabled(name, on) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
|
202
|
-
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
196
|
+
updateRawConfig((config) => {
|
|
197
|
+
const plugins = (config.plugins && typeof config.plugins === "object" ? config.plugins : {});
|
|
198
|
+
plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };
|
|
199
|
+
config.plugins = plugins;
|
|
200
|
+
});
|
|
203
201
|
}
|