@nanhara/hara 0.115.0 → 0.117.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 +35 -0
- package/dist/agent/compact.js +11 -0
- package/dist/cron/runner.js +3 -1
- package/dist/index.js +22 -10
- package/dist/serve/protocol.js +13 -2
- package/dist/serve/server.js +226 -9
- package/dist/serve/sessions.js +31 -0
- package/dist/session/store.js +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.117.0 — serve batch 3: context watermark · compact · rewind · fuzzy file search
|
|
9
|
+
|
|
10
|
+
Codex-desktop parity for the conversation-hygiene set — everything a client needs to keep a long
|
|
11
|
+
session healthy without dropping to the CLI:
|
|
12
|
+
|
|
13
|
+
- **Context watermark everywhere.** Every `session.send` result and `event.turn_end` now carries
|
|
14
|
+
`ctx: { lastInput, window, pct }` — how full the model's window was on the last turn. Clients
|
|
15
|
+
render a live meter with zero extra round-trips. `session.context` returns the same watermark
|
|
16
|
+
plus the spend breakdown (your messages / assistant / per-tool) on demand.
|
|
17
|
+
- **`session.compact`** — the CLI's `/compact`, serve-side: summarize-and-replace with the same
|
|
18
|
+
8-section brief, working-memory notes kept on the session, and the recently-touched-file restore
|
|
19
|
+
(limited to files under the session's own cwd — serve is multi-session; nothing leaks across
|
|
20
|
+
projects). Busy-guarded like a turn.
|
|
21
|
+
- **`session.rewind`** — fork the thread back to before the n-th-most-recent user turn (codex
|
|
22
|
+
`thread/rollback`). History-only; file edits are not reverted.
|
|
23
|
+
- **`files.search`** — fuzzy project-file lookup (git-aware listing + the CLI's fuzzy ranker),
|
|
24
|
+
powering @-mention autocomplete in the desktop composer. Resolves the search root from an
|
|
25
|
+
explicit `cwd` or a `sessionId`.
|
|
26
|
+
|
|
27
|
+
## 0.116.0 — the desktop-grade serve protocol (models · automation · rename/archive · capabilities)
|
|
28
|
+
|
|
29
|
+
- **Session source stamping.** Sessions now record WHO created them (`interactive` / `gateway` / `cron`,
|
|
30
|
+
env-derived; the cron runner passes the job name along) — and automated sessions get a
|
|
31
|
+
**"name · MM-DD HH:mm" title at creation**, so a cron prompt never becomes a session title again.
|
|
32
|
+
- **`hara serve` protocol, batch 2** (everything the Hara desktop app drives):
|
|
33
|
+
- `models.list` (endpoint model catalog + thinking-effort levels) and `session.set-model`
|
|
34
|
+
(per-session model/effort switch, applies next turn)
|
|
35
|
+
- `automation.list` — cron jobs with last outcome + automated sessions, for the desktop's
|
|
36
|
+
automation timeline
|
|
37
|
+
- `session.rename` + `session.archive` (archived sessions hidden from lists, kept on disk)
|
|
38
|
+
- `initialize` now advertises `capabilities.methods` — clients feature-detect instead of
|
|
39
|
+
probing for method-not-found
|
|
40
|
+
- `session.send` expands `@file` mentions (CLI parity)
|
|
41
|
+
- `session.list` carries `source` / `sourceName` / `archived`
|
|
42
|
+
|
|
8
43
|
## 0.115.0 — serve exposes plugins & skills (desktop plugin panel)
|
|
9
44
|
|
|
10
45
|
- **`hara serve` protocol grows a plugin surface**: `plugins.list` (installed plugins with enabled state +
|
package/dist/agent/compact.js
CHANGED
|
@@ -25,6 +25,17 @@ export const COMPACT_SYSTEM = "Summarize the conversation so far into a structur
|
|
|
25
25
|
"7. All user messages — EVERY user message so far (excluding tool results), verbatim and in order; abbreviate only huge pasted blobs with […]. These are the ground truth of intent.\n" +
|
|
26
26
|
"8. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
|
|
27
27
|
"Be specific and concrete. Drop the <analysis>; output only the headed summary.";
|
|
28
|
+
/** Working-memory notes distilled from a compaction summary — short lines that survive the history wipe
|
|
29
|
+
* (stored on SessionMeta.workingSet, injected into subsequent turns). Shared by the CLI /compact path
|
|
30
|
+
* and serve's session.compact. */
|
|
31
|
+
export function workingSetFromSummary(s) {
|
|
32
|
+
return s
|
|
33
|
+
.split("\n")
|
|
34
|
+
.map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
|
|
35
|
+
.filter((l) => l.length > 3)
|
|
36
|
+
.slice(0, 12)
|
|
37
|
+
.map((l) => l.slice(0, 140));
|
|
38
|
+
}
|
|
28
39
|
/** Post-compaction file restore (Claude Code's TW5): re-attach the CURRENT content of the files the
|
|
29
40
|
* conversation was just working with, so the summary isn't the model's only anchor — it doesn't have
|
|
30
41
|
* to re-read its own working set next turn (and can't act on a stale memory of an edited file).
|
package/dist/cron/runner.js
CHANGED
|
@@ -55,7 +55,9 @@ export function runJobOnce(job) {
|
|
|
55
55
|
const [cmd, argv] = job.mode === "command"
|
|
56
56
|
? ["bash", ["-lc", job.task]]
|
|
57
57
|
: [self[0], [...self.slice(1), ...(job.mode === "org" ? ["org", job.task] : ["-p", job.task, "--approval", "full-auto"])]];
|
|
58
|
-
|
|
58
|
+
// HARA_CRON_NAME rides along so the child session's meta gets a human title ("job name · time")
|
|
59
|
+
// instead of the raw prompt (session store's automated-title strategy).
|
|
60
|
+
const child = spawn(cmd, argv, { cwd: job.cwd, env: { ...process.env, HARA_CRON: "1", HARA_CRON_NAME: job.name } });
|
|
59
61
|
let tail = ""; // last few KB, for chat delivery (the full stream goes to the log file)
|
|
60
62
|
const append = (d) => {
|
|
61
63
|
tail = (tail + d.toString()).slice(-4_000);
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fl
|
|
|
24
24
|
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";
|
|
25
25
|
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
26
26
|
import { routingProvider } from "./agent/route.js";
|
|
27
|
-
import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore } from "./agent/compact.js";
|
|
27
|
+
import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "./agent/compact.js";
|
|
28
28
|
import { recentTouched } from "./agent/touched.js";
|
|
29
29
|
import { INTERJECT_PREFIX } from "./agent/reminders.js";
|
|
30
30
|
import { checkForUpdate } from "./update-check.js";
|
|
@@ -45,6 +45,7 @@ import { EXPLORE_SYSTEM } from "./tools/agent.js";
|
|
|
45
45
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
46
46
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
47
47
|
import { resolvePlatform } from "./providers/registry.js";
|
|
48
|
+
import { levelsFor } from "./tui/model-picker.js";
|
|
48
49
|
import { listModels } from "./providers/models.js";
|
|
49
50
|
import { listJobs, tailJob, killJob } from "./exec/jobs.js";
|
|
50
51
|
/** Render the background-job list for /jobs (user-facing view of what the agent has running in the
|
|
@@ -67,7 +68,7 @@ import { getEmbedder } from "./search/embed.js";
|
|
|
67
68
|
import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
68
69
|
import { searchHybrid } from "./search/hybrid.js";
|
|
69
70
|
import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
70
|
-
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
71
|
+
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
|
|
71
72
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
72
73
|
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
73
74
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
@@ -757,13 +758,8 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
|
|
|
757
758
|
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
758
759
|
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
759
760
|
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
760
|
-
// COMPACT_SYSTEM (the 8-section brief)
|
|
761
|
-
|
|
762
|
-
.split("\n")
|
|
763
|
-
.map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
|
|
764
|
-
.filter((l) => l.length > 3)
|
|
765
|
-
.slice(0, 12)
|
|
766
|
-
.map((l) => l.slice(0, 140));
|
|
761
|
+
// COMPACT_SYSTEM (the 8-section brief) + workingSetFromSummary live in agent/compact.ts so tests can
|
|
762
|
+
// pin their structure and serve's session.compact shares the exact same distillation.
|
|
767
763
|
/** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
|
|
768
764
|
* /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
|
|
769
765
|
async function compactConversation(provider, history, meta, stats) {
|
|
@@ -1579,6 +1575,9 @@ program
|
|
|
1579
1575
|
providerId: cfg.provider,
|
|
1580
1576
|
model: cfg.model,
|
|
1581
1577
|
buildSessionProvider: async () => withRouting(await buildProvider(cfg), cfg),
|
|
1578
|
+
buildProviderFor: async (model, effort) => withRouting(await buildProvider({ ...cfg, model, reasoningEffort: effort ?? cfg.reasoningEffort }), cfg),
|
|
1579
|
+
listModels: () => listModels(cfg.baseURL ?? providerDefaultBaseURL(cfg.provider), cfg.apiKey ?? ""),
|
|
1580
|
+
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
|
|
1582
1581
|
spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
|
|
1583
1582
|
guardian: guardianOpt,
|
|
1584
1583
|
sandbox,
|
|
@@ -2251,7 +2250,19 @@ program.action(async (opts) => {
|
|
|
2251
2250
|
const prior = rid ? loadSession(rid) : null;
|
|
2252
2251
|
if (prior?.history)
|
|
2253
2252
|
history.push(...prior.history);
|
|
2254
|
-
|
|
2253
|
+
// Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
|
|
2254
|
+
// automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
|
|
2255
|
+
const src = sessionSourceFromEnv();
|
|
2256
|
+
meta = prior?.meta ?? {
|
|
2257
|
+
id: rid ?? newSessionId(),
|
|
2258
|
+
cwd,
|
|
2259
|
+
provider: cfg.provider,
|
|
2260
|
+
model: cfg.model,
|
|
2261
|
+
title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
|
|
2262
|
+
createdAt: new Date().toISOString(),
|
|
2263
|
+
updatedAt: "",
|
|
2264
|
+
...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
|
|
2265
|
+
};
|
|
2255
2266
|
// Apply per-session pinned model on headless resume (mirrors the interactive path).
|
|
2256
2267
|
// --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
|
|
2257
2268
|
if (prior) {
|
|
@@ -2431,6 +2442,7 @@ program.action(async (opts) => {
|
|
|
2431
2442
|
title: "",
|
|
2432
2443
|
createdAt: new Date().toISOString(),
|
|
2433
2444
|
updatedAt: "",
|
|
2445
|
+
source: "interactive",
|
|
2434
2446
|
};
|
|
2435
2447
|
// Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
|
|
2436
2448
|
// corrupt it. Lock it here (a brand-new session's id is unique, so this only ever blocks a DOUBLE-resume
|
package/dist/serve/protocol.js
CHANGED
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
// Everything here is PURE (parse + frame builders + error codes) and unit-tested.
|
|
4
4
|
//
|
|
5
5
|
// Client → server requests:
|
|
6
|
-
// initialize {token}
|
|
6
|
+
// initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,
|
|
7
|
+
// capabilities:{methods:[…]}} (feature detection)
|
|
7
8
|
// session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
|
|
8
9
|
// session.create {cwd?,approval?} → {sessionId,model}
|
|
9
10
|
// session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
|
|
10
|
-
// session.send {sessionId,text}
|
|
11
|
+
// session.send {sessionId,text,images?} → (streams events, then) {reply,usage}
|
|
12
|
+
// images: [{path,mediaType?}] — pasted screenshots etc., inlined for vision models
|
|
11
13
|
// session.interrupt {sessionId} → {}
|
|
12
14
|
// approval.reply {approvalId,allow,always?} → {}
|
|
13
15
|
// plugins.list {} → {plugins:[{name,version,description,enabled,skills,agents,mcpServers}]}
|
|
14
16
|
// plugins.set {name,enabled} → {name,enabled} (applies to future sessions/turns)
|
|
15
17
|
// skills.list {cwd?} → {skills:[{id,description,source}]}
|
|
18
|
+
// automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
|
|
19
|
+
// sessions:[{id,title,source,sourceName,updatedAt,…}]}
|
|
20
|
+
// models.list {} → {models:[…], current, effortLevels:[…]}
|
|
21
|
+
// automation.add {name,schedule,task,mode?,cwd?,tz?} → {id,name,schedule}
|
|
22
|
+
// automation.toggle {id,enabled} → {id,enabled}
|
|
23
|
+
// automation.delete {id} → {id,deleted}
|
|
24
|
+
// session.rename {sessionId,title} → {sessionId,title}
|
|
25
|
+
// session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
|
|
26
|
+
// session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
|
|
16
27
|
// Server → client notifications (all carry sessionId):
|
|
17
28
|
// event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
|
|
18
29
|
// event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
|
package/dist/serve/server.js
CHANGED
|
@@ -5,15 +5,25 @@
|
|
|
5
5
|
// (no import cycle back into the CLI entry).
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
7
7
|
import { randomBytes, randomUUID, timingSafeEqual, createHash } from "node:crypto";
|
|
8
|
-
import { writeFileSync, unlinkSync, mkdirSync } from "node:fs";
|
|
8
|
+
import { writeFileSync, unlinkSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
|
|
12
12
|
import { runAgent } from "../agent/loop.js";
|
|
13
|
+
import { COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "../agent/compact.js";
|
|
14
|
+
import { rewindTo } from "../agent/rewind.js";
|
|
15
|
+
import { analyzeContext } from "../agent/context-report.js";
|
|
16
|
+
import { recentTouched } from "../agent/touched.js";
|
|
17
|
+
import { contextWindow, ctxPctFor } from "../statusbar.js";
|
|
18
|
+
import { listProjectFiles } from "../fs-walk.js";
|
|
19
|
+
import { fuzzyRank } from "../fuzzy.js";
|
|
13
20
|
import { loadAgentsMd } from "../context/agents-md.js";
|
|
21
|
+
import { expandMentions } from "../context/mentions.js";
|
|
14
22
|
import { memoryDigest } from "../memory/store.js";
|
|
15
23
|
import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
|
|
16
24
|
import { loadSkillIndex } from "../skills/skills.js";
|
|
25
|
+
import { loadJobs, saveJobs, addJob } from "../cron/store.js";
|
|
26
|
+
import { parseSchedule, describeSchedule } from "../cron/schedule.js";
|
|
17
27
|
import { SessionHub, realStore } from "./sessions.js";
|
|
18
28
|
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
19
29
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
@@ -68,7 +78,7 @@ export async function startServe(opts, deps) {
|
|
|
68
78
|
writeFileSync(discoveryPath, JSON.stringify({ host: opts.host, port, token, pid: process.pid, version: deps.version }, null, 2), { mode: 0o600 });
|
|
69
79
|
}
|
|
70
80
|
/** Run one turn on a session, streaming events to all authed clients. */
|
|
71
|
-
const runTurn = async (s, text) => {
|
|
81
|
+
const runTurn = async (s, text, images) => {
|
|
72
82
|
const sessionId = s.meta.id;
|
|
73
83
|
s.busy = true;
|
|
74
84
|
s.abort = new AbortController();
|
|
@@ -94,7 +104,9 @@ export async function startServe(opts, deps) {
|
|
|
94
104
|
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
95
105
|
});
|
|
96
106
|
try {
|
|
97
|
-
|
|
107
|
+
// @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
|
|
108
|
+
// Pasted images ride along as NeutralMsg.images — a vision-capable model sees them inline.
|
|
109
|
+
s.history.push({ role: "user", content: expandMentions(text, s.meta.cwd), ...(images && images.length ? { images } : {}) });
|
|
98
110
|
await runAgent(s.history, {
|
|
99
111
|
provider: s.provider,
|
|
100
112
|
ctx: {
|
|
@@ -115,14 +127,57 @@ export async function startServe(opts, deps) {
|
|
|
115
127
|
hub.save(s);
|
|
116
128
|
const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
|
|
117
129
|
const reply = lastAssistantText(s.history);
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
// context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern) —
|
|
131
|
+
// clients render a meter without an extra round-trip.
|
|
132
|
+
const ctx = ctxOf(s);
|
|
133
|
+
broadcast("event.turn_end", { sessionId, reply, usage, ctx });
|
|
134
|
+
return { reply, usage, ctx };
|
|
120
135
|
}
|
|
121
136
|
finally {
|
|
122
137
|
s.busy = false;
|
|
123
138
|
s.abort = null;
|
|
124
139
|
}
|
|
125
140
|
};
|
|
141
|
+
/** Context watermark for a session: how full the model's window was on the last turn. */
|
|
142
|
+
const ctxOf = (s) => {
|
|
143
|
+
const lastInput = s.stats.lastInput ?? 0;
|
|
144
|
+
return { lastInput, window: contextWindow(s.meta.model), pct: ctxPctFor(s.meta.model, lastInput) };
|
|
145
|
+
};
|
|
146
|
+
/** Summarize + replace a session's history — the CLI's /compact, serve-side (codex thread/compact).
|
|
147
|
+
* Mirrors index.ts compactConversation; the file restore is limited to files under the session's own
|
|
148
|
+
* cwd because serve is multi-session (recentTouched is process-wide and must not leak across projects). */
|
|
149
|
+
const compactSession = async (s) => {
|
|
150
|
+
const r = await s.provider.turn({
|
|
151
|
+
system: COMPACT_SYSTEM,
|
|
152
|
+
history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
153
|
+
tools: [],
|
|
154
|
+
onText: () => { },
|
|
155
|
+
});
|
|
156
|
+
if (r.stop === "error")
|
|
157
|
+
return null;
|
|
158
|
+
const summary = r.text.trim();
|
|
159
|
+
if (!summary)
|
|
160
|
+
return null;
|
|
161
|
+
s.meta.workingSet = workingSetFromSummary(summary);
|
|
162
|
+
s.history.length = 0;
|
|
163
|
+
s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
164
|
+
const touched = recentTouched(20).filter((f) => f.startsWith(s.meta.cwd)).slice(0, 5);
|
|
165
|
+
const restore = buildFileRestore(touched, (f) => {
|
|
166
|
+
try {
|
|
167
|
+
return readFileSync(f, "utf8");
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
if (restore)
|
|
174
|
+
s.history.push({ role: "user", content: restore });
|
|
175
|
+
s.stats.input += r.usage?.input ?? 0;
|
|
176
|
+
s.stats.output += r.usage?.output ?? 0;
|
|
177
|
+
s.stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary
|
|
178
|
+
hub.save(s);
|
|
179
|
+
return summary;
|
|
180
|
+
};
|
|
126
181
|
wss.on("connection", (ws) => {
|
|
127
182
|
ws.on("message", async (raw) => {
|
|
128
183
|
const parsed = parseFrame(String(raw));
|
|
@@ -137,13 +192,22 @@ export async function startServe(opts, deps) {
|
|
|
137
192
|
if (typeof p.token !== "string" || !sameToken(p.token, token))
|
|
138
193
|
return reply(rpcError(id, ERR.UNAUTHORIZED, "bad token"));
|
|
139
194
|
authed.add(ws);
|
|
140
|
-
|
|
195
|
+
// capability negotiation (codex app-server pattern): the server ADVERTISES its method set so
|
|
196
|
+
// clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
|
|
197
|
+
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
198
|
+
const methods = [
|
|
199
|
+
"session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
|
|
200
|
+
"session.rename", "session.archive", "session.compact", "session.rewind", "session.context",
|
|
201
|
+
"approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search",
|
|
202
|
+
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
203
|
+
];
|
|
204
|
+
return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
|
|
141
205
|
}
|
|
142
206
|
if (!authed.has(ws))
|
|
143
207
|
return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
|
|
144
208
|
switch (req.method) {
|
|
145
209
|
case "session.list":
|
|
146
|
-
return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt })) }));
|
|
210
|
+
return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).filter((m) => !m.archived || p.archived === true).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt, source: m.source ?? "interactive", sourceName: m.sourceName, archived: m.archived ?? false })) }));
|
|
147
211
|
case "session.create": {
|
|
148
212
|
const provider = await deps.buildSessionProvider();
|
|
149
213
|
if (!provider)
|
|
@@ -175,7 +239,10 @@ export async function startServe(opts, deps) {
|
|
|
175
239
|
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
|
|
176
240
|
if (s.busy)
|
|
177
241
|
return reply(rpcError(id, ERR.BUSY, "a turn is already running on this session"));
|
|
178
|
-
const
|
|
242
|
+
const images = Array.isArray(p.images)
|
|
243
|
+
? p.images.filter((im) => im && typeof im.path === "string").map((im) => ({ path: im.path, mediaType: typeof im.mediaType === "string" ? im.mediaType : "image/png" }))
|
|
244
|
+
: undefined;
|
|
245
|
+
const r = await runTurn(s, p.text, images);
|
|
179
246
|
return reply(rpcResult(id, r));
|
|
180
247
|
}
|
|
181
248
|
case "session.interrupt": {
|
|
@@ -195,7 +262,7 @@ export async function startServe(opts, deps) {
|
|
|
195
262
|
}
|
|
196
263
|
case "plugins.list": {
|
|
197
264
|
const on = new Set(enabledPlugins().map((pl) => pl.name));
|
|
198
|
-
return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length })) }));
|
|
265
|
+
return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length, panels: pl.manifest.panels ?? [] })) }));
|
|
199
266
|
}
|
|
200
267
|
case "plugins.set": {
|
|
201
268
|
if (typeof p.name !== "string" || typeof p.enabled !== "boolean")
|
|
@@ -205,10 +272,160 @@ export async function startServe(opts, deps) {
|
|
|
205
272
|
setPluginEnabled(p.name, p.enabled);
|
|
206
273
|
return reply(rpcResult(id, { name: p.name, enabled: p.enabled })); // takes effect on the next session/turn (loaders re-read)
|
|
207
274
|
}
|
|
275
|
+
case "session.rename": {
|
|
276
|
+
if (typeof p.sessionId !== "string" || typeof p.title !== "string")
|
|
277
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + title required"));
|
|
278
|
+
if (!hub.rename(p.sessionId, p.title.slice(0, 120)))
|
|
279
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
280
|
+
return reply(rpcResult(id, { sessionId: p.sessionId, title: p.title.slice(0, 120) }));
|
|
281
|
+
}
|
|
282
|
+
case "session.archive": {
|
|
283
|
+
if (typeof p.sessionId !== "string" || typeof p.archived !== "boolean")
|
|
284
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + archived required"));
|
|
285
|
+
if (!hub.setArchived(p.sessionId, p.archived))
|
|
286
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
287
|
+
return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
|
|
288
|
+
}
|
|
289
|
+
case "models.list": {
|
|
290
|
+
const models = deps.listModels ? await deps.listModels().catch(() => []) : [];
|
|
291
|
+
return reply(rpcResult(id, { models, current: deps.model, effortLevels: deps.effortLevels ?? [] }));
|
|
292
|
+
}
|
|
293
|
+
case "session.set-model": {
|
|
294
|
+
// per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
|
|
295
|
+
// provider; takes effect on the NEXT turn. Refused mid-turn.
|
|
296
|
+
if (typeof p.sessionId !== "string")
|
|
297
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
298
|
+
const s = hub.get(p.sessionId);
|
|
299
|
+
if (!s)
|
|
300
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
301
|
+
if (s.busy)
|
|
302
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is running — switch after it finishes"));
|
|
303
|
+
const model = typeof p.model === "string" && p.model ? p.model : s.meta.model;
|
|
304
|
+
const effort = typeof p.effort === "string" && p.effort ? p.effort : undefined;
|
|
305
|
+
if (!deps.buildProviderFor)
|
|
306
|
+
return reply(rpcError(id, ERR.METHOD, "model switching not supported by this server"));
|
|
307
|
+
const provider = await deps.buildProviderFor(model, effort);
|
|
308
|
+
if (!provider)
|
|
309
|
+
return reply(rpcError(id, ERR.INTERNAL, `could not build provider for ${model}`));
|
|
310
|
+
s.provider = provider;
|
|
311
|
+
s.meta.model = model;
|
|
312
|
+
s.effort = effort;
|
|
313
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, model, effort: effort ?? null }));
|
|
314
|
+
}
|
|
315
|
+
case "automation.list": {
|
|
316
|
+
// The automation timeline's data: cron jobs with their last outcome, plus this machine's
|
|
317
|
+
// automated sessions (source=cron/gateway) so the desktop can render results and "continue
|
|
318
|
+
// as conversation". Read-only.
|
|
319
|
+
const jobs = loadJobs().map((j) => ({ id: j.id, name: j.name, mode: j.mode, cwd: j.cwd, enabled: j.enabled, deliver: j.deliver, lastRunAt: j.lastRunAt, lastStatus: j.lastStatus, lastError: j.lastError, schedule: describeSchedule(j.schedule) }));
|
|
320
|
+
const automated = hub.list().filter((m) => m.source === "cron" || m.source === "gateway").map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, source: m.source, sourceName: m.sourceName, updatedAt: m.updatedAt }));
|
|
321
|
+
return reply(rpcResult(id, { jobs, sessions: automated }));
|
|
322
|
+
}
|
|
323
|
+
case "automation.add": {
|
|
324
|
+
if (typeof p.name !== "string" || !p.name || typeof p.schedule !== "string" || typeof p.task !== "string" || !p.task) {
|
|
325
|
+
return reply(rpcError(id, ERR.PARAMS, "name + schedule + task required"));
|
|
326
|
+
}
|
|
327
|
+
const sched = parseSchedule(p.schedule, Date.now());
|
|
328
|
+
if ("error" in sched)
|
|
329
|
+
return reply(rpcError(id, ERR.PARAMS, `bad schedule: ${sched.error}`));
|
|
330
|
+
const job = addJob({
|
|
331
|
+
name: p.name.slice(0, 60),
|
|
332
|
+
schedule: sched,
|
|
333
|
+
task: p.task,
|
|
334
|
+
mode: ["print", "org", "command"].includes(p.mode) ? p.mode : "print",
|
|
335
|
+
cwd: typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd,
|
|
336
|
+
...(typeof p.tz === "string" && p.tz ? { tz: p.tz } : {}),
|
|
337
|
+
createdAt: Date.now(),
|
|
338
|
+
});
|
|
339
|
+
return reply(rpcResult(id, { id: job.id, name: job.name, schedule: describeSchedule(job.schedule) }));
|
|
340
|
+
}
|
|
341
|
+
case "automation.toggle": {
|
|
342
|
+
if (typeof p.id !== "string" || typeof p.enabled !== "boolean")
|
|
343
|
+
return reply(rpcError(id, ERR.PARAMS, "id + enabled required"));
|
|
344
|
+
const jobs = loadJobs();
|
|
345
|
+
const job = jobs.find((j) => j.id === p.id);
|
|
346
|
+
if (!job)
|
|
347
|
+
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
348
|
+
job.enabled = p.enabled;
|
|
349
|
+
saveJobs(jobs);
|
|
350
|
+
return reply(rpcResult(id, { id: job.id, enabled: job.enabled }));
|
|
351
|
+
}
|
|
352
|
+
case "automation.delete": {
|
|
353
|
+
if (typeof p.id !== "string")
|
|
354
|
+
return reply(rpcError(id, ERR.PARAMS, "id required"));
|
|
355
|
+
const jobs = loadJobs();
|
|
356
|
+
if (!jobs.some((j) => j.id === p.id))
|
|
357
|
+
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
358
|
+
saveJobs(jobs.filter((j) => j.id !== p.id));
|
|
359
|
+
return reply(rpcResult(id, { id: p.id, deleted: true }));
|
|
360
|
+
}
|
|
208
361
|
case "skills.list": {
|
|
209
362
|
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
210
363
|
return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
|
|
211
364
|
}
|
|
365
|
+
case "files.search": {
|
|
366
|
+
// fuzzy file lookup for the composer's @-mention autocomplete (codex fuzzyFileSearch).
|
|
367
|
+
// Relative POSIX paths; empty query returns the first files as a browse list.
|
|
368
|
+
const sess = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
369
|
+
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (sess?.meta.cwd ?? opts.cwd);
|
|
370
|
+
const limit = Math.min(Math.max(Math.trunc(Number(p.limit) || 20), 1), 50);
|
|
371
|
+
const all = listProjectFiles(cwd);
|
|
372
|
+
const query = typeof p.query === "string" ? p.query : "";
|
|
373
|
+
const files = query ? fuzzyRank(query, all, (f) => f).slice(0, limit).map((r) => r.item) : all.slice(0, limit);
|
|
374
|
+
return reply(rpcResult(id, { files, cwd }));
|
|
375
|
+
}
|
|
376
|
+
case "session.context": {
|
|
377
|
+
// context-spend breakdown + watermark on demand (codex thread/tokenUsage + /context).
|
|
378
|
+
if (typeof p.sessionId !== "string")
|
|
379
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
380
|
+
const s = hub.get(p.sessionId);
|
|
381
|
+
if (!s)
|
|
382
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
383
|
+
const report = analyzeContext(s.history);
|
|
384
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, ...ctxOf(s), total: report.total, rows: report.rows.slice(0, 8) }));
|
|
385
|
+
}
|
|
386
|
+
case "session.compact": {
|
|
387
|
+
// manual compaction (codex thread/compact/start): summarize + replace history, keep working
|
|
388
|
+
// notes, restore this-cwd touched files. Busy-guarded like a turn — it IS a provider call.
|
|
389
|
+
if (typeof p.sessionId !== "string")
|
|
390
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
391
|
+
const s = hub.get(p.sessionId);
|
|
392
|
+
if (!s)
|
|
393
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
394
|
+
if (s.busy)
|
|
395
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is running — compact after it finishes"));
|
|
396
|
+
if (s.history.length < 2)
|
|
397
|
+
return reply(rpcError(id, ERR.PARAMS, "nothing to compact yet"));
|
|
398
|
+
s.busy = true;
|
|
399
|
+
try {
|
|
400
|
+
broadcast("event.notice", { sessionId: s.meta.id, text: "✻ Compacting conversation…" });
|
|
401
|
+
const summary = await compactSession(s);
|
|
402
|
+
if (!summary)
|
|
403
|
+
return reply(rpcError(id, ERR.INTERNAL, "compaction failed — try again or /clear"));
|
|
404
|
+
broadcast("event.notice", { sessionId: s.meta.id, text: `(compacted — history replaced with a summary; ${s.meta.workingSet?.length ?? 0} notes kept)` });
|
|
405
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
|
|
406
|
+
}
|
|
407
|
+
finally {
|
|
408
|
+
s.busy = false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
case "session.rewind": {
|
|
412
|
+
// fork the thread back to before the n-th-most-recent user turn (codex thread/rollback;
|
|
413
|
+
// n=1 drops the last exchange). History-only — file edits are not reverted.
|
|
414
|
+
if (typeof p.sessionId !== "string" || !Number.isInteger(p.n))
|
|
415
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + n required"));
|
|
416
|
+
const s = hub.get(p.sessionId);
|
|
417
|
+
if (!s)
|
|
418
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
419
|
+
if (s.busy)
|
|
420
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is running — rewind after it finishes"));
|
|
421
|
+
const next = rewindTo(s.history, p.n);
|
|
422
|
+
if (!next)
|
|
423
|
+
return reply(rpcError(id, ERR.PARAMS, `n out of range (1..${s.history.filter((m) => m.role === "user").length})`));
|
|
424
|
+
s.history.length = 0;
|
|
425
|
+
s.history.push(...next);
|
|
426
|
+
hub.save(s);
|
|
427
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
|
|
428
|
+
}
|
|
212
429
|
default:
|
|
213
430
|
return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
|
|
214
431
|
}
|
package/dist/serve/sessions.js
CHANGED
|
@@ -22,6 +22,7 @@ export class SessionHub {
|
|
|
22
22
|
title: "",
|
|
23
23
|
createdAt: new Date().toISOString(),
|
|
24
24
|
updatedAt: "",
|
|
25
|
+
source: "interactive", // serve sessions are user-driven (desktop/IDE clients)
|
|
25
26
|
};
|
|
26
27
|
this.store.acquire(meta.id); // fresh id — always ours; registers the single-writer claim
|
|
27
28
|
const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
|
|
@@ -58,6 +59,36 @@ export class SessionHub {
|
|
|
58
59
|
}
|
|
59
60
|
this.store.save(s.meta, s.history);
|
|
60
61
|
}
|
|
62
|
+
/** Rename a session (live or on-disk). Returns false when the id is unknown. */
|
|
63
|
+
rename(id, title) {
|
|
64
|
+
const live = this.sessions.get(id);
|
|
65
|
+
if (live) {
|
|
66
|
+
live.meta.title = title;
|
|
67
|
+
this.store.save(live.meta, live.history);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const prior = this.store.load(id);
|
|
71
|
+
if (!prior)
|
|
72
|
+
return false;
|
|
73
|
+
prior.meta.title = title;
|
|
74
|
+
this.store.save(prior.meta, prior.history);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/** Archive/unarchive (hidden from lists, kept on disk). Returns false when unknown. */
|
|
78
|
+
setArchived(id, on) {
|
|
79
|
+
const live = this.sessions.get(id);
|
|
80
|
+
if (live) {
|
|
81
|
+
live.meta.archived = on;
|
|
82
|
+
this.store.save(live.meta, live.history);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const prior = this.store.load(id);
|
|
86
|
+
if (!prior)
|
|
87
|
+
return false;
|
|
88
|
+
prior.meta.archived = on;
|
|
89
|
+
this.store.save(prior.meta, prior.history);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
61
92
|
/** Release all locks (server shutdown). In-flight turns are aborted by the caller first. */
|
|
62
93
|
releaseAll() {
|
|
63
94
|
for (const id of this.sessions.keys())
|
package/dist/session/store.js
CHANGED
|
@@ -3,6 +3,21 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
+
/** Derive the session source from the spawn environment — the gateway subprocess runs with
|
|
7
|
+
* HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
|
|
8
|
+
export function sessionSourceFromEnv() {
|
|
9
|
+
if (process.env.HARA_CRON)
|
|
10
|
+
return { source: "cron", sourceName: process.env.HARA_CRON_NAME || undefined };
|
|
11
|
+
if (process.env.HARA_GATEWAY)
|
|
12
|
+
return { source: "gateway", sourceName: process.env.HARA_GATEWAY };
|
|
13
|
+
return { source: "interactive" };
|
|
14
|
+
}
|
|
15
|
+
/** Title for a NON-interactive session: "name · MM-DD HH:mm" — the raw prompt never becomes a title. */
|
|
16
|
+
export function automatedTitle(source, sourceName, at = new Date()) {
|
|
17
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
18
|
+
const stamp = `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
19
|
+
return `${sourceName || source} · ${stamp}`;
|
|
20
|
+
}
|
|
6
21
|
function sessionsDir() {
|
|
7
22
|
const d = join(homedir(), ".hara", "sessions");
|
|
8
23
|
mkdirSync(d, { recursive: true });
|