@nanhara/hara 0.53.0 → 0.62.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 +181 -0
- package/README.md +25 -7
- package/SECURITY.md +54 -0
- package/dist/agent/loop.js +5 -3
- package/dist/completions.js +49 -0
- package/dist/config.js +17 -7
- package/dist/cron/install.js +112 -0
- package/dist/cron/runner.js +109 -0
- package/dist/cron/schedule.js +147 -0
- package/dist/cron/store.js +87 -0
- package/dist/index.js +381 -11
- package/dist/mcp/server.js +56 -0
- package/dist/memory/store.js +44 -6
- package/dist/org/review-chain.js +91 -0
- package/dist/org/roles.js +11 -0
- package/dist/providers/qwen-oauth.js +9 -2
- package/dist/sandbox.js +25 -3
- package/dist/search/semindex.js +9 -2
- package/dist/session/store.js +12 -2
- package/dist/tools/computer.js +9 -4
- package/dist/tools/patch.js +31 -12
- package/dist/tools/web.js +81 -8
- package/dist/tui/App.js +2 -2
- package/dist/tui/InputBox.js +37 -3
- package/dist/tui/vim.js +115 -0
- package/package.json +6 -2
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { runTui } from "./tui/run.js";
|
|
|
6
6
|
import { readClipboardImage } from "./images.js";
|
|
7
7
|
import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
|
|
8
8
|
import { setTheme } from "./tui/theme.js";
|
|
9
|
-
import { memoryDigest, memoryDir } from "./memory/store.js";
|
|
9
|
+
import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
|
|
10
10
|
import { nextMode as cycleMode } from "./tui/InputBox.js";
|
|
11
11
|
import { stdin, stdout } from "node:process";
|
|
12
12
|
import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
|
@@ -16,6 +16,13 @@ import { dirname, join } from "node:path";
|
|
|
16
16
|
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
|
|
17
17
|
import { runAgent } from "./agent/loop.js";
|
|
18
18
|
import { notifyDone } from "./notify.js";
|
|
19
|
+
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
20
|
+
import { completionScript } from "./completions.js";
|
|
21
|
+
import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
|
|
22
|
+
import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
|
|
23
|
+
import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
|
|
24
|
+
import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
|
|
25
|
+
import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
|
|
19
26
|
import { getTools } from "./tools/registry.js";
|
|
20
27
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
21
28
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
@@ -26,7 +33,7 @@ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists
|
|
|
26
33
|
import { searchHybrid } from "./search/hybrid.js";
|
|
27
34
|
import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
28
35
|
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
29
|
-
import { loadRoles, scaffoldRoles } from "./org/roles.js";
|
|
36
|
+
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
30
37
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
31
38
|
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
|
|
32
39
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
@@ -50,7 +57,19 @@ import "./tools/codebase.js"; // register codebase_search (repo as a knowledge b
|
|
|
50
57
|
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
51
58
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
52
59
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
-
|
|
60
|
+
// Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
|
|
61
|
+
// else read package.json (npm install / `node dist`). The read is wrapped so the binary never hits it.
|
|
62
|
+
const pkg = {
|
|
63
|
+
version: process.env.HARA_BUILD_VERSION ??
|
|
64
|
+
((() => {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return "0.0.0";
|
|
70
|
+
}
|
|
71
|
+
})()),
|
|
72
|
+
};
|
|
54
73
|
const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
|
|
55
74
|
async function buildProvider(cfg) {
|
|
56
75
|
if (cfg.provider === "qwen-oauth") {
|
|
@@ -75,6 +94,61 @@ async function runInit(provider, cwd, sandbox = "off") {
|
|
|
75
94
|
const history = [{ role: "user", content: INIT_PROMPT }];
|
|
76
95
|
await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
|
|
77
96
|
}
|
|
97
|
+
/** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
|
|
98
|
+
* Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
|
|
99
|
+
async function autoCommit(provider, cwd) {
|
|
100
|
+
try {
|
|
101
|
+
await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* fall through — empty diff is reported below */
|
|
105
|
+
}
|
|
106
|
+
let diff = "";
|
|
107
|
+
try {
|
|
108
|
+
diff = (await runShell("git diff --staged", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
return `error: git diff failed (${e instanceof Error ? e.message : String(e)})`;
|
|
112
|
+
}
|
|
113
|
+
if (!diff.trim())
|
|
114
|
+
return "nothing to commit";
|
|
115
|
+
const r = await provider.turn({
|
|
116
|
+
system: COMMIT_SYSTEM,
|
|
117
|
+
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }],
|
|
118
|
+
tools: [],
|
|
119
|
+
onText: () => { },
|
|
120
|
+
});
|
|
121
|
+
const msg = stripCommitFence(r.text);
|
|
122
|
+
if (!msg)
|
|
123
|
+
return "error: no commit message produced";
|
|
124
|
+
const tmp = join(tmpdir(), `hara-org-commit-${process.pid}.txt`);
|
|
125
|
+
writeFileSync(tmp, msg + "\n", "utf8");
|
|
126
|
+
try {
|
|
127
|
+
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
128
|
+
return (res.stdout || "").trim().split("\n")[0] || "committed";
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
return `error: git commit failed (${e instanceof Error ? e.message : String(e)})`;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
try {
|
|
135
|
+
rmSync(tmp);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
/* best-effort cleanup */
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Format an autoCommit result + emit it. */
|
|
143
|
+
async function commitStep(provider, cwd) {
|
|
144
|
+
const r = await autoCommit(provider, cwd);
|
|
145
|
+
if (r.startsWith("error:"))
|
|
146
|
+
out(c.red(`✗ ${r}\n`));
|
|
147
|
+
else if (r === "nothing to commit")
|
|
148
|
+
out(c.dim("(nothing to commit)\n"));
|
|
149
|
+
else
|
|
150
|
+
out(c.green(`✓ committed · ${r.slice(0, 100)}\n`));
|
|
151
|
+
}
|
|
78
152
|
/** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
|
|
79
153
|
async function runOrg(task, o) {
|
|
80
154
|
const roles = loadRoles(o.cwd);
|
|
@@ -115,7 +189,7 @@ async function runOrg(task, o) {
|
|
|
115
189
|
? (n) => !role.denyTools.includes(n)
|
|
116
190
|
: undefined;
|
|
117
191
|
const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
|
|
118
|
-
|
|
192
|
+
const runImplementer = () => runAgent(history, {
|
|
119
193
|
provider: roleProvider,
|
|
120
194
|
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
121
195
|
approval: o.approval,
|
|
@@ -126,6 +200,61 @@ async function runOrg(task, o) {
|
|
|
126
200
|
systemOverride: role.system,
|
|
127
201
|
toolFilter,
|
|
128
202
|
});
|
|
203
|
+
const wasClean = o.commit ? isTreeClean(o.cwd) : false; // capture BEFORE the implementer edits anything
|
|
204
|
+
const doCommit = async (ok) => {
|
|
205
|
+
if (!o.commit)
|
|
206
|
+
return;
|
|
207
|
+
if (!ok)
|
|
208
|
+
return void out(c.yellow("(not committing — review didn't approve; changes left in your working tree)\n"));
|
|
209
|
+
if (!wasClean)
|
|
210
|
+
return void out(c.yellow("(not auto-committing — the tree wasn't clean before this run; commit manually)\n"));
|
|
211
|
+
await commitStep(o.baseProvider, o.cwd);
|
|
212
|
+
};
|
|
213
|
+
await runImplementer();
|
|
214
|
+
if (!o.review) {
|
|
215
|
+
await doCommit(true);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
// Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
|
|
219
|
+
const reviewer = roles.find((r) => r.id === "reviewer");
|
|
220
|
+
const revProvider = reviewer?.model && reviewer.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: reviewer.model })) ?? o.baseProvider) : o.baseProvider;
|
|
221
|
+
const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
|
|
222
|
+
const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
|
|
223
|
+
const maxRounds = Math.max(1, o.rounds ?? 3);
|
|
224
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
225
|
+
const changes = captureChanges(o.cwd);
|
|
226
|
+
if (!changes.diff && !changes.newFiles.length) {
|
|
227
|
+
out(c.dim("(no changes to review)\n"));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
out(c.dim(`🔍 reviewer · round ${round}/${maxRounds}\n`));
|
|
231
|
+
const rHist = [{ role: "user", content: reviewPrompt(task, changes) }];
|
|
232
|
+
await runAgent(rHist, {
|
|
233
|
+
provider: revProvider,
|
|
234
|
+
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
235
|
+
approval: "full-auto", // reviewer is read-only via revTools, so nothing to confirm
|
|
236
|
+
confirm: o.confirm,
|
|
237
|
+
projectContext: o.projectContext,
|
|
238
|
+
memory: memoryDigest(o.cwd),
|
|
239
|
+
stats: o.stats,
|
|
240
|
+
systemOverride: revSystem,
|
|
241
|
+
toolFilter: revTools,
|
|
242
|
+
});
|
|
243
|
+
const verdict = parseVerdict(lastAssistantText(rHist));
|
|
244
|
+
if (verdict.approved) {
|
|
245
|
+
out(c.green(`✓ reviewer approved after ${round} round(s)\n`));
|
|
246
|
+
await doCommit(true);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (round === maxRounds) {
|
|
250
|
+
out(c.yellow(`⚠ stopped after ${maxRounds} round(s) — reviewer still wants changes.\n`));
|
|
251
|
+
await doCommit(false);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
out(c.yellow(`✗ changes requested — back to ${role.id} (round ${round})\n`));
|
|
255
|
+
history.push({ role: "user", content: fixPrompt(verdict.issues) });
|
|
256
|
+
await runImplementer();
|
|
257
|
+
}
|
|
129
258
|
}
|
|
130
259
|
function lastAssistantText(history) {
|
|
131
260
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
@@ -312,6 +441,11 @@ const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / gr
|
|
|
312
441
|
"End your message with the plan as a short numbered list.";
|
|
313
442
|
const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
|
|
314
443
|
"conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
|
|
444
|
+
const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memory logs into its durable long-term memory. You're given " +
|
|
445
|
+
"the current durable memory and recent daily logs. Extract ONLY durable, reusable facts / decisions / " +
|
|
446
|
+
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
447
|
+
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
448
|
+
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
315
449
|
const COMPACT_SYSTEM = "Summarize the conversation so far into a concise but complete brief so the assistant can " +
|
|
316
450
|
"continue seamlessly: the user's goal, key decisions, files changed, current state, and open next steps. " +
|
|
317
451
|
"Be specific. Output only the summary.";
|
|
@@ -326,11 +460,10 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
326
460
|
const roles = loadRoles(cwd);
|
|
327
461
|
const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
|
|
328
462
|
const provider = role?.model && role.model !== cfg.model ? ((await buildProvider({ ...cfg, model: role.model })) ?? baseProvider) : baseProvider;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
: (n) => READONLY_TOOLS.has(n); // default sub-agent = read-only (safe to parallelize)
|
|
463
|
+
// A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
|
|
464
|
+
// further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
|
|
465
|
+
// Write-capable roles run in the main loop via `hara org`, behind the user's gate.
|
|
466
|
+
const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
|
|
334
467
|
const subHistory = [{ role: "user", content: task }];
|
|
335
468
|
await runAgent(subHistory, {
|
|
336
469
|
provider,
|
|
@@ -377,9 +510,11 @@ function runDoctor(cfg) {
|
|
|
377
510
|
`${dot} vision · ${c.bold(cfg.model)} ${vdesc}${cfg.visionModel ? c.dim(" · describer ") + c.bold(cfg.visionModel) : vcap === "text" ? c.yellow(" · set /vision <model>") : ""}`,
|
|
378
511
|
`${dot} screen ${cfg.computerUse === "off" ? c.dim("off (hara config set computerUse read|click|full)") : c.bold(cfg.computerUse) + c.dim(` · ${computerBackends()}${cfg.computerApps.length ? " · apps: " + cfg.computerApps.join(", ") : " · no app allowlist"}`)}`,
|
|
379
512
|
`${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
|
|
380
|
-
`${dot} mcp
|
|
513
|
+
`${dot} mcp ${c.dim(`client: ${Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length} server(s) · serve: ${mcpServeToolNames().length} read tools via \`hara mcp\``)}`,
|
|
381
514
|
`${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
|
|
382
515
|
`${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
|
|
516
|
+
`${dot} cron ${(() => { const n = loadJobs().length; return n ? `${n} job(s) · ${isInstalled() ? c.green("scheduler installed") : c.yellow("scheduler off — hara cron install")}` : c.dim("no jobs — hara cron add"); })()}`,
|
|
517
|
+
`${dot} input ${cfg.vimMode ? c.bold("vim") + c.dim(" (modal)") : c.dim("default — hara config set vimMode true for vim keys")}`,
|
|
383
518
|
];
|
|
384
519
|
return lines.join("\n");
|
|
385
520
|
}
|
|
@@ -434,8 +569,11 @@ program
|
|
|
434
569
|
});
|
|
435
570
|
program
|
|
436
571
|
.command("org <task...>")
|
|
437
|
-
.description("dispatch a task to the owning role and run it")
|
|
572
|
+
.description("dispatch a task to the owning role and run it (--review loops a reviewer until it approves)")
|
|
438
573
|
.option("--role <id>", "force a specific role")
|
|
574
|
+
.option("--review", "after implementing, loop a reviewer role until it approves (implement → review → fix)")
|
|
575
|
+
.option("--rounds <n>", "max review rounds with --review (default 3)", (v) => parseInt(v, 10))
|
|
576
|
+
.option("--commit", "commit the result with an AI message (with --review: only after approval; needs a clean start tree)")
|
|
439
577
|
.action(async (taskParts, opts2) => {
|
|
440
578
|
const cfg = loadConfig();
|
|
441
579
|
const provider = await buildProvider(cfg);
|
|
@@ -454,6 +592,9 @@ program
|
|
|
454
592
|
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
455
593
|
stats,
|
|
456
594
|
forceRole: opts2.role,
|
|
595
|
+
review: opts2.review,
|
|
596
|
+
rounds: opts2.rounds,
|
|
597
|
+
commit: opts2.commit,
|
|
457
598
|
});
|
|
458
599
|
if (stats.input || stats.output)
|
|
459
600
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
@@ -555,6 +696,31 @@ program
|
|
|
555
696
|
.command("doctor")
|
|
556
697
|
.description("check your hara setup (provider / auth / model / node / assets / roles)")
|
|
557
698
|
.action(() => out(runDoctor(loadConfig()) + "\n"));
|
|
699
|
+
program
|
|
700
|
+
.command("completions <shell>")
|
|
701
|
+
.description("print a shell completion script: bash | zsh | fish (eval it in your shell rc)")
|
|
702
|
+
.action((shell) => {
|
|
703
|
+
const top = program.commands.map((cmd) => cmd.name()).filter((n) => n && n !== "completions").sort();
|
|
704
|
+
const subs = {};
|
|
705
|
+
for (const cmd of program.commands) {
|
|
706
|
+
const sub = cmd.commands.map((s) => s.name()).filter(Boolean);
|
|
707
|
+
if (sub.length)
|
|
708
|
+
subs[cmd.name()] = sub;
|
|
709
|
+
}
|
|
710
|
+
const script = completionScript(shell, { top, subs });
|
|
711
|
+
if (!script)
|
|
712
|
+
return void out(c.red(`Unsupported shell '${shell}'. Use: bash | zsh | fish\n`));
|
|
713
|
+
out(script);
|
|
714
|
+
});
|
|
715
|
+
program
|
|
716
|
+
.command("mcp")
|
|
717
|
+
.description("run hara as an MCP server (stdio) — expose its read/search tools (incl. codebase_search) to other MCP clients")
|
|
718
|
+
.action(async () => {
|
|
719
|
+
const cfg = loadConfig();
|
|
720
|
+
// stdout is the JSON-RPC transport — diagnostics MUST go to stderr only.
|
|
721
|
+
process.stderr.write(c.dim(`hara mcp · serving over stdio · cwd ${cfg.cwd}\n tools: ${mcpServeToolNames().join(", ") || "(none)"}\n (read-only by default; set HARA_MCP_TOOLS to override)\n`));
|
|
722
|
+
await startMcpServer(pkg.version, { cwd: cfg.cwd, sandbox: "read-only" });
|
|
723
|
+
});
|
|
558
724
|
program
|
|
559
725
|
.command("review")
|
|
560
726
|
.description("review your uncommitted changes (git diff) for bugs, security, and missing tests")
|
|
@@ -660,6 +826,154 @@ program
|
|
|
660
826
|
}
|
|
661
827
|
}
|
|
662
828
|
});
|
|
829
|
+
function renderCronJobs() {
|
|
830
|
+
const jobs = loadJobs();
|
|
831
|
+
const head = isInstalled() ? c.green("scheduler: installed") : c.yellow("scheduler: NOT installed — run `hara cron install`");
|
|
832
|
+
if (!jobs.length)
|
|
833
|
+
return head + "\n" + c.dim('No jobs. Add one: hara cron add "every 1h" "<task>"\n');
|
|
834
|
+
const now = Date.now();
|
|
835
|
+
const lines = jobs.map((j) => {
|
|
836
|
+
const nxt = nextRun(j, now);
|
|
837
|
+
const status = j.lastStatus ? (j.lastStatus === "ok" ? c.green("ok") : c.red("err")) : c.dim("—");
|
|
838
|
+
return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nxt ? new Date(nxt).toLocaleString() : "—"} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
|
|
839
|
+
});
|
|
840
|
+
return head + "\n" + lines.join("\n") + "\n";
|
|
841
|
+
}
|
|
842
|
+
const cronCmd = program.command("cron").description("scheduled tasks — run a prompt/org task on a schedule (fired by your OS via `hara cron install`)");
|
|
843
|
+
cronCmd
|
|
844
|
+
.command("add <schedule> <task...>")
|
|
845
|
+
.description('schedule a task — schedule = cron expr ("0 9 * * *"), "every 30m", "in 2h", or an ISO timestamp')
|
|
846
|
+
.option("--name <name>", "a label for the job")
|
|
847
|
+
.option("--org", "run via `hara org` (role routing + review) instead of a plain `hara -p` prompt")
|
|
848
|
+
.action((schedule, taskParts, opts) => {
|
|
849
|
+
const task = taskParts.join(" ");
|
|
850
|
+
const sched = parseSchedule(schedule, Date.now());
|
|
851
|
+
if ("error" in sched)
|
|
852
|
+
return void out(c.red(sched.error + "\n"));
|
|
853
|
+
const job = addJob({ name: opts.name || task.slice(0, 48), schedule: sched, task, mode: opts.org ? "org" : "print", cwd: process.cwd(), createdAt: Date.now() });
|
|
854
|
+
out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)} · ${job.mode} · cwd ${job.cwd}\n`));
|
|
855
|
+
if (!isInstalled())
|
|
856
|
+
out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
|
|
857
|
+
});
|
|
858
|
+
// Resolve an id/prefix to one job, printing a clear error for none / ambiguous (never act on a guess).
|
|
859
|
+
const cronResolve = (id) => {
|
|
860
|
+
const r = resolveJob(id);
|
|
861
|
+
if (r === "ambiguous")
|
|
862
|
+
return void out(c.red(`ambiguous id "${id}" — matches multiple jobs; type more characters\n`)), null;
|
|
863
|
+
if (!r)
|
|
864
|
+
return void out(c.red(`no such job: ${id}\n`)), null;
|
|
865
|
+
return r;
|
|
866
|
+
};
|
|
867
|
+
cronCmd.command("list").alias("ls").description("list scheduled jobs").action(() => out(renderCronJobs()));
|
|
868
|
+
cronCmd
|
|
869
|
+
.command("remove <id>")
|
|
870
|
+
.alias("rm")
|
|
871
|
+
.description("delete a job (by id or unique prefix)")
|
|
872
|
+
.action((id) => {
|
|
873
|
+
const j = cronResolve(id);
|
|
874
|
+
if (j)
|
|
875
|
+
out(removeJob(j.id) ? c.green(`✓ removed ${j.id}\n`) : c.red("no such job\n"));
|
|
876
|
+
});
|
|
877
|
+
cronCmd.command("enable <id>").description("enable a job").action((id) => {
|
|
878
|
+
const j = cronResolve(id);
|
|
879
|
+
if (j) {
|
|
880
|
+
setEnabled(j.id, true);
|
|
881
|
+
out(c.green(`✓ enabled ${j.id}\n`));
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
cronCmd.command("disable <id>").description("disable a job (keeps it, stops firing)").action((id) => {
|
|
885
|
+
const j = cronResolve(id);
|
|
886
|
+
if (j) {
|
|
887
|
+
setEnabled(j.id, false);
|
|
888
|
+
out(c.green(`✓ disabled ${j.id}\n`));
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
cronCmd
|
|
892
|
+
.command("run <id>")
|
|
893
|
+
.description("run a job right now, ignoring its schedule")
|
|
894
|
+
.action(async (id) => {
|
|
895
|
+
const job = cronResolve(id);
|
|
896
|
+
if (!job)
|
|
897
|
+
return;
|
|
898
|
+
out(c.dim(`running ${job.id} (${job.name})…\n`));
|
|
899
|
+
const r = await runJobOnce(job);
|
|
900
|
+
recordRun(job.id, Date.now(), r.ok ? "ok" : "error", r.error);
|
|
901
|
+
out((r.ok ? c.green("✓ done") : c.red(`✗ ${r.error}`)) + c.dim(` · log: ${logPath(job.id)}\n`));
|
|
902
|
+
});
|
|
903
|
+
cronCmd
|
|
904
|
+
.command("tick")
|
|
905
|
+
.description("run all due jobs now (your OS scheduler calls this every minute)")
|
|
906
|
+
.action(async () => {
|
|
907
|
+
const r = await runTick(Date.now());
|
|
908
|
+
if (r.skipped)
|
|
909
|
+
return void out(c.dim(`(skipped — ${r.skipped})\n`));
|
|
910
|
+
out(c.dim(r.ran.length ? `ran ${r.ran.length} job(s): ${r.ran.join(", ")}\n` : "(no jobs due)\n"));
|
|
911
|
+
});
|
|
912
|
+
cronCmd
|
|
913
|
+
.command("install")
|
|
914
|
+
.description("register the per-minute tick with your OS scheduler (launchd on macOS, crontab on Linux)")
|
|
915
|
+
.action(() => {
|
|
916
|
+
const r = installScheduler(selfArgv());
|
|
917
|
+
out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
|
|
918
|
+
});
|
|
919
|
+
cronCmd.command("uninstall").description("remove the OS scheduler entry").action(() => {
|
|
920
|
+
const r = uninstallScheduler();
|
|
921
|
+
out((r.ok ? c.green("✓ ") : c.red("✗ ")) + r.msg + "\n");
|
|
922
|
+
});
|
|
923
|
+
cronCmd
|
|
924
|
+
.command("logs <id>")
|
|
925
|
+
.description("show a job's recent run output")
|
|
926
|
+
.action((id) => {
|
|
927
|
+
const job = cronResolve(id);
|
|
928
|
+
if (!job)
|
|
929
|
+
return;
|
|
930
|
+
const p = logPath(job.id);
|
|
931
|
+
out(existsSync(p) ? readFileSync(p, "utf8").slice(-4000) + "\n" : c.dim("(no runs yet)\n"));
|
|
932
|
+
});
|
|
933
|
+
const memoryCmd = program.command("memory").description("inspect + consolidate hara's durable memory (~/.hara/memory + project .hara/memory)");
|
|
934
|
+
memoryCmd.command("show").description("print the memory digest injected at session start").action(() => {
|
|
935
|
+
const d = memoryDigest(process.cwd());
|
|
936
|
+
out(d ? d + "\n" : c.dim("(memory is empty — `hara memory init`, or let the agent write via memory_write)\n"));
|
|
937
|
+
});
|
|
938
|
+
memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(() => {
|
|
939
|
+
const w = scaffoldMemory(process.cwd());
|
|
940
|
+
out(w.length ? c.green(`Scaffolded: ${w.join(", ")}\n`) : c.dim("Memory already scaffolded.\n"));
|
|
941
|
+
});
|
|
942
|
+
memoryCmd
|
|
943
|
+
.command("distill")
|
|
944
|
+
.description("consolidate recent daily logs into durable MEMORY (promote short-term → long-term)")
|
|
945
|
+
.option("--days <n>", "days of logs to consider (default 14)", (v) => parseInt(v, 10))
|
|
946
|
+
.option("--scope <s>", "global | project | all (default all)")
|
|
947
|
+
.action(async (opts) => {
|
|
948
|
+
const cfg = loadConfig();
|
|
949
|
+
const provider = await buildProvider(cfg);
|
|
950
|
+
if (!provider) {
|
|
951
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
952
|
+
process.exit(1);
|
|
953
|
+
}
|
|
954
|
+
const days = opts.days && opts.days > 0 ? opts.days : 14;
|
|
955
|
+
const scopes = opts.scope === "global" ? ["global"] : opts.scope === "project" ? ["project"] : ["project", "global"];
|
|
956
|
+
const logs = scopes
|
|
957
|
+
.map((s) => readRecentLogs(s, cfg.cwd, days))
|
|
958
|
+
.filter(Boolean)
|
|
959
|
+
.join("\n\n");
|
|
960
|
+
if (!logs.trim())
|
|
961
|
+
return void out(c.dim(`No daily logs in the last ${days} day(s) to distill. (The agent jots them via memory_write target=log.)\n`));
|
|
962
|
+
out(c.dim(`Distilling ${days}-day logs → durable memory…\n`));
|
|
963
|
+
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
964
|
+
const history = [{ role: "user", content: `Current durable memory:\n\n${memoryDigest(cfg.cwd) || "(empty)"}\n\n---\n\nRecent daily logs (last ${days} days):\n\n${logs.slice(0, 80_000)}` }];
|
|
965
|
+
await runAgent(history, {
|
|
966
|
+
provider,
|
|
967
|
+
ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
|
|
968
|
+
approval: "full-auto",
|
|
969
|
+
confirm: async () => true,
|
|
970
|
+
toolFilter: (n) => n === "memory_write" || READONLY_TOOLS.has(n),
|
|
971
|
+
systemOverride: MEMORY_DISTILL_SYSTEM,
|
|
972
|
+
stats,
|
|
973
|
+
});
|
|
974
|
+
if (stats.input || stats.output)
|
|
975
|
+
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
976
|
+
});
|
|
663
977
|
const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
|
|
664
978
|
rolesCmd
|
|
665
979
|
.command("init")
|
|
@@ -715,6 +1029,18 @@ pluginCmd
|
|
|
715
1029
|
m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
|
|
716
1030
|
].filter(Boolean);
|
|
717
1031
|
out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
|
|
1032
|
+
// Surface the code-execution surface: a plugin's MCP servers + hooks run shell commands on every
|
|
1033
|
+
// hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
|
|
1034
|
+
const execs = [];
|
|
1035
|
+
for (const [name, s] of Object.entries(m.mcpServers ?? {}))
|
|
1036
|
+
execs.push(`mcp ${name}: ${[s.command, ...(s.args ?? [])].join(" ")}`);
|
|
1037
|
+
for (const h of [...(m.hooks?.PreToolUse ?? []), ...(m.hooks?.PostToolUse ?? [])])
|
|
1038
|
+
execs.push(`hook: ${h.command}`);
|
|
1039
|
+
if (execs.length) {
|
|
1040
|
+
out(c.yellow(`⚠ ${p.name} will run these commands on every hara launch (a plugin is code you run — review them):\n`) +
|
|
1041
|
+
execs.map((e) => c.dim(` ${e}`)).join("\n") +
|
|
1042
|
+
c.dim(`\n disable: hara plugin disable ${p.name}\n`));
|
|
1043
|
+
}
|
|
718
1044
|
}
|
|
719
1045
|
catch (e) {
|
|
720
1046
|
out(c.red(`Install failed: ${e.message}\n`));
|
|
@@ -1272,6 +1598,7 @@ program.action(async (opts) => {
|
|
|
1272
1598
|
header: { version: pkg.version, model: `${cfg.provider}:${cfg.model}`, cwd, vision: visionLine, session: meta.id, tip: `/help · @file attaches · shift+tab cycles modes · esc interrupts${projectContext ? " · AGENTS.md loaded" : ""}` },
|
|
1273
1599
|
cycleApproval: (m) => cycleMode(m),
|
|
1274
1600
|
onClipboardImage: readClipboardImage,
|
|
1601
|
+
vim: cfg.vimMode,
|
|
1275
1602
|
onSubmit: async (line, h, images) => {
|
|
1276
1603
|
if (line.startsWith("/")) {
|
|
1277
1604
|
const [nm, ...rest] = line.slice(1).split(/\s+/);
|
|
@@ -1420,6 +1747,49 @@ program.action(async (opts) => {
|
|
|
1420
1747
|
h.setApproval(m);
|
|
1421
1748
|
return void h.sink.notice(`(approval → ${m})`);
|
|
1422
1749
|
}
|
|
1750
|
+
if (nm === "diff") {
|
|
1751
|
+
try {
|
|
1752
|
+
const d = (await runShell(arg === "staged" ? "git diff --staged" : "git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout.trim();
|
|
1753
|
+
if (!d)
|
|
1754
|
+
return void h.sink.notice(arg === "staged" ? "(nothing staged)" : "(no changes vs HEAD — /diff staged for the index)");
|
|
1755
|
+
return void h.sink.diff(d.length > 12_000 ? d.slice(0, 12_000) + "\n…[truncated]" : d);
|
|
1756
|
+
}
|
|
1757
|
+
catch {
|
|
1758
|
+
return void h.sink.notice("(git diff failed — is this a git repo?)");
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (nm === "commit") {
|
|
1762
|
+
h.sink.notice("✻ writing a commit message…");
|
|
1763
|
+
const r = await autoCommit(provider, cwd); // stages all + commits with an AI message
|
|
1764
|
+
return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
|
|
1765
|
+
}
|
|
1766
|
+
if (nm === "review") {
|
|
1767
|
+
let diff = "";
|
|
1768
|
+
try {
|
|
1769
|
+
diff = (await runShell("git diff HEAD", cwd, "off", { timeout: 30_000, maxBuffer: 8_000_000 })).stdout;
|
|
1770
|
+
}
|
|
1771
|
+
catch {
|
|
1772
|
+
/* not a git repo → empty */
|
|
1773
|
+
}
|
|
1774
|
+
if (!diff.trim())
|
|
1775
|
+
return void h.sink.notice("(nothing to review — no changes vs HEAD)");
|
|
1776
|
+
const rui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
1777
|
+
const xin = stats.input;
|
|
1778
|
+
const xout = stats.output;
|
|
1779
|
+
await runAgent([{ role: "user", content: `Review this diff:\n\n\`\`\`diff\n${diff.slice(0, 120_000)}\n\`\`\`` }], {
|
|
1780
|
+
provider,
|
|
1781
|
+
ctx: { cwd, sandbox, ui: rui },
|
|
1782
|
+
approval: "full-auto", // read-only via the tool filter, so nothing prompts
|
|
1783
|
+
confirm: h.confirm,
|
|
1784
|
+
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
1785
|
+
systemOverride: REVIEW_SYSTEM,
|
|
1786
|
+
memory: buildMemory(),
|
|
1787
|
+
stats,
|
|
1788
|
+
signal: h.signal,
|
|
1789
|
+
});
|
|
1790
|
+
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1423
1793
|
if (byName.has(nm))
|
|
1424
1794
|
return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
|
|
1425
1795
|
const near = nearest(nm, [...byName.keys()]);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// MCP server — expose hara's safe read/search tools over stdio so other MCP clients (Claude Desktop,
|
|
2
|
+
// Cursor, another hara, …) can use them. The high-value one is `codebase_search` over the current repo
|
|
3
|
+
// (semantic if you've built an index, lexical otherwise). `hara mcp` runs this.
|
|
4
|
+
//
|
|
5
|
+
// Read-only by DEFAULT: no edit/write/bash/computer — an external client must not be able to mutate your
|
|
6
|
+
// machine through hara. Override the exposed set with HARA_MCP_TOOLS (comma list) at your own risk.
|
|
7
|
+
//
|
|
8
|
+
// IMPORTANT: stdio is the JSON-RPC transport — this module must never write to stdout. Diagnostics go to
|
|
9
|
+
// stderr (the `hara mcp` command handles that); tool output flows back through the protocol.
|
|
10
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import { getTool, getTools } from "../tools/registry.js";
|
|
14
|
+
/** Safe default: navigate + search the repo + the web. No state, no mutation, no privacy-sensitive memory. */
|
|
15
|
+
const SAFE_TOOLS = ["read_file", "grep", "glob", "ls", "codebase_search", "web_fetch", "web_search"];
|
|
16
|
+
/** Names to expose: HARA_MCP_TOOLS (comma list) if set, else the safe default — intersected with what's
|
|
17
|
+
* actually registered. An unknown name in the override is silently dropped. */
|
|
18
|
+
export function mcpServeToolNames() {
|
|
19
|
+
const env = process.env.HARA_MCP_TOOLS;
|
|
20
|
+
const want = env ? env.split(",").map((s) => s.trim()).filter(Boolean) : SAFE_TOOLS;
|
|
21
|
+
const have = new Set(getTools().map((t) => t.name));
|
|
22
|
+
return want.filter((n) => have.has(n));
|
|
23
|
+
}
|
|
24
|
+
/** The exposed tools in MCP `tools/list` shape. */
|
|
25
|
+
export function mcpToolList() {
|
|
26
|
+
return mcpServeToolNames().map((n) => {
|
|
27
|
+
const t = getTool(n);
|
|
28
|
+
return { name: t.name, description: t.description, inputSchema: t.input_schema };
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Run one exposed tool. Refuses anything outside the exposed set (defense in depth, even if a client
|
|
32
|
+
* asks for a name it shouldn't know). Never throws — errors come back as `isError` results. */
|
|
33
|
+
export async function mcpCallTool(name, args, ctx) {
|
|
34
|
+
if (!mcpServeToolNames().includes(name)) {
|
|
35
|
+
return { content: [{ type: "text", text: `Tool not exposed by \`hara mcp\`: ${name}` }], isError: true };
|
|
36
|
+
}
|
|
37
|
+
const tool = getTool(name);
|
|
38
|
+
if (!tool)
|
|
39
|
+
return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
40
|
+
try {
|
|
41
|
+
const out = await tool.run(args ?? {}, ctx);
|
|
42
|
+
return { content: [{ type: "text", text: String(out) }] };
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return { content: [{ type: "text", text: `Error: ${e instanceof Error ? e.message : String(e)}` }], isError: true };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Start the stdio MCP server and block (the transport keeps the process alive). */
|
|
49
|
+
export async function startMcpServer(version, ctx) {
|
|
50
|
+
const server = new Server({ name: "hara", version }, { capabilities: { tools: {} } });
|
|
51
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: mcpToolList() }));
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK's result union has a task-augmented
|
|
53
|
+
// member TS can't narrow to; our {content,isError} shape is a valid CallToolResult (validated at runtime).
|
|
54
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => (await mcpCallTool(req.params.name, (req.params.arguments ?? {}), ctx)));
|
|
55
|
+
await server.connect(new StdioServerTransport());
|
|
56
|
+
}
|
package/dist/memory/store.js
CHANGED
|
@@ -3,9 +3,21 @@
|
|
|
3
3
|
// project <root>/.hara/memory. Lexical search reuses recall.ts; no embeddings (local-first).
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync } from "node:fs";
|
|
7
7
|
import { findProjectRoot } from "../context/agents-md.js";
|
|
8
|
-
|
|
8
|
+
// Per-source budgets for the frozen-snapshot digest (chars). Each source gets its own cap so a large
|
|
9
|
+
// project MEMORY can't crowd out the (smaller but high-value) USER prefs — and each is cut at a line
|
|
10
|
+
// boundary, never mid-entry. Anything beyond these is still reachable via memory_search. (hermes-style
|
|
11
|
+
// per-file budgets; both PAI and hermes confirm lexical injection + capped snapshot beats a vector store.)
|
|
12
|
+
const SOURCE_CAP = { memory: 2000, user: 1200, log: 0 };
|
|
13
|
+
/** Truncate at a line boundary at/under `cap` (never mid-entry), with a pointer to search for the rest. */
|
|
14
|
+
function capAtLine(text, cap) {
|
|
15
|
+
if (text.length <= cap)
|
|
16
|
+
return text;
|
|
17
|
+
const cut = text.slice(0, cap);
|
|
18
|
+
const nl = cut.lastIndexOf("\n");
|
|
19
|
+
return (nl > cap * 0.5 ? cut.slice(0, nl) : cut).trimEnd() + "\n…[truncated — memory_search for the rest]";
|
|
20
|
+
}
|
|
9
21
|
export function memoryDir(scope, cwd) {
|
|
10
22
|
if (scope === "global")
|
|
11
23
|
return process.env.HARA_MEMORY || join(homedir(), ".hara", "memory");
|
|
@@ -49,7 +61,9 @@ export function forgetMemory(scope, target, match, cwd) {
|
|
|
49
61
|
writeFileSync(f, kept.join("\n"), "utf8");
|
|
50
62
|
return lines.length - kept.length;
|
|
51
63
|
}
|
|
52
|
-
/**
|
|
64
|
+
/** MEMORY + USER digest (project + global) for frozen-snapshot injection at session start. Each source is
|
|
65
|
+
* capped independently (SOURCE_CAP) at a line boundary, so every source is represented (project memory
|
|
66
|
+
* never starves USER prefs) and no entry is cut mid-line. Daily logs are reached via memory_search. */
|
|
53
67
|
export function memoryDigest(cwd) {
|
|
54
68
|
const sources = [
|
|
55
69
|
["project", "memory", "project MEMORY"],
|
|
@@ -64,14 +78,38 @@ export function memoryDigest(cwd) {
|
|
|
64
78
|
try {
|
|
65
79
|
const t = readFileSync(f, "utf8").trim();
|
|
66
80
|
if (t)
|
|
67
|
-
parts.push(`## ${label}\n${t}`);
|
|
81
|
+
parts.push(`## ${label}\n${capAtLine(t, SOURCE_CAP[target])}`);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* skip unreadable */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return parts.join("\n\n");
|
|
88
|
+
}
|
|
89
|
+
/** Concatenate the daily logs (`log/YYYY-MM-DD.md`) from the last `days` for one scope — the short-term
|
|
90
|
+
* tier `hara memory distill` consolidates into evergreen MEMORY. Empty if there's no log dir. */
|
|
91
|
+
export function readRecentLogs(scope, cwd, days) {
|
|
92
|
+
const dir = join(memoryDir(scope, cwd), "log");
|
|
93
|
+
if (!existsSync(dir))
|
|
94
|
+
return "";
|
|
95
|
+
const cutoff = Date.now() - days * 86_400_000;
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const f of readdirSync(dir).sort()) {
|
|
98
|
+
if (!f.endsWith(".md"))
|
|
99
|
+
continue;
|
|
100
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})\.md$/.exec(f);
|
|
101
|
+
if (m && new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() < cutoff)
|
|
102
|
+
continue;
|
|
103
|
+
try {
|
|
104
|
+
const t = readFileSync(join(dir, f), "utf8").trim();
|
|
105
|
+
if (t)
|
|
106
|
+
out.push(`### ${f}\n${t}`);
|
|
68
107
|
}
|
|
69
108
|
catch {
|
|
70
109
|
/* skip unreadable */
|
|
71
110
|
}
|
|
72
111
|
}
|
|
73
|
-
|
|
74
|
-
return out.length > DIGEST_CAP ? out.slice(0, DIGEST_CAP) + "\n…[memory truncated — use memory_search]" : out;
|
|
112
|
+
return out.join("\n\n");
|
|
75
113
|
}
|
|
76
114
|
/** Create memory dirs + seed files (global + project). Returns files written. */
|
|
77
115
|
export function scaffoldMemory(cwd) {
|