@nanhara/hara 0.116.0 → 0.118.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/dist/agent/compact.js +11 -0
- package/dist/index.js +3 -8
- package/dist/serve/protocol.js +5 -1
- package/dist/serve/server.js +207 -11
- package/dist/serve/sessions.js +49 -1
- package/dist/session/store.js +19 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,36 @@ 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.118.0 — session delete & fork · slash skills over serve
|
|
9
|
+
|
|
10
|
+
- **`session.delete`** — permanent removal (codex thread/delete; archive stays the soft path).
|
|
11
|
+
Lock-aware: refuses when a live other process holds the session; busy-guarded on live turns.
|
|
12
|
+
- **`session.fork`** — duplicate a session's history into a new live session (codex thread/fork),
|
|
13
|
+
rewind's non-destructive sibling: explore a different direction without losing the original.
|
|
14
|
+
Forks persist immediately.
|
|
15
|
+
- **Slash skills over the wire** — `session.send` with a leading `/skill-id request…` expands into
|
|
16
|
+
the CLI's skill-entering flow, so a desktop composer's "/" popup triggers exactly what the
|
|
17
|
+
terminal gets. Unknown ids fall through as plain text.
|
|
18
|
+
|
|
19
|
+
## 0.117.0 — serve batch 3: context watermark · compact · rewind · fuzzy file search
|
|
20
|
+
|
|
21
|
+
Codex-desktop parity for the conversation-hygiene set — everything a client needs to keep a long
|
|
22
|
+
session healthy without dropping to the CLI:
|
|
23
|
+
|
|
24
|
+
- **Context watermark everywhere.** Every `session.send` result and `event.turn_end` now carries
|
|
25
|
+
`ctx: { lastInput, window, pct }` — how full the model's window was on the last turn. Clients
|
|
26
|
+
render a live meter with zero extra round-trips. `session.context` returns the same watermark
|
|
27
|
+
plus the spend breakdown (your messages / assistant / per-tool) on demand.
|
|
28
|
+
- **`session.compact`** — the CLI's `/compact`, serve-side: summarize-and-replace with the same
|
|
29
|
+
8-section brief, working-memory notes kept on the session, and the recently-touched-file restore
|
|
30
|
+
(limited to files under the session's own cwd — serve is multi-session; nothing leaks across
|
|
31
|
+
projects). Busy-guarded like a turn.
|
|
32
|
+
- **`session.rewind`** — fork the thread back to before the n-th-most-recent user turn (codex
|
|
33
|
+
`thread/rollback`). History-only; file edits are not reverted.
|
|
34
|
+
- **`files.search`** — fuzzy project-file lookup (git-aware listing + the CLI's fuzzy ranker),
|
|
35
|
+
powering @-mention autocomplete in the desktop composer. Resolves the search root from an
|
|
36
|
+
explicit `cwd` or a `sessionId`.
|
|
37
|
+
|
|
8
38
|
## 0.116.0 — the desktop-grade serve protocol (models · automation · rename/archive · capabilities)
|
|
9
39
|
|
|
10
40
|
- **Session source stamping.** Sessions now record WHO created them (`interactive` / `gateway` / `cron`,
|
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/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";
|
|
@@ -758,13 +758,8 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
|
|
|
758
758
|
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
759
759
|
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
760
760
|
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
761
|
-
// COMPACT_SYSTEM (the 8-section brief)
|
|
762
|
-
|
|
763
|
-
.split("\n")
|
|
764
|
-
.map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
|
|
765
|
-
.filter((l) => l.length > 3)
|
|
766
|
-
.slice(0, 12)
|
|
767
|
-
.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.
|
|
768
763
|
/** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
|
|
769
764
|
* /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
|
|
770
765
|
async function compactConversation(provider, history, meta, stats) {
|
package/dist/serve/protocol.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
|
|
9
9
|
// session.create {cwd?,approval?} → {sessionId,model}
|
|
10
10
|
// session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
|
|
11
|
-
// 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
|
|
12
13
|
// session.interrupt {sessionId} → {}
|
|
13
14
|
// approval.reply {approvalId,allow,always?} → {}
|
|
14
15
|
// plugins.list {} → {plugins:[{name,version,description,enabled,skills,agents,mcpServers}]}
|
|
@@ -17,6 +18,9 @@
|
|
|
17
18
|
// automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
|
|
18
19
|
// sessions:[{id,title,source,sourceName,updatedAt,…}]}
|
|
19
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}
|
|
20
24
|
// session.rename {sessionId,title} → {sessionId,title}
|
|
21
25
|
// session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
|
|
22
26
|
// session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
|
package/dist/serve/server.js
CHANGED
|
@@ -5,17 +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";
|
|
14
21
|
import { expandMentions } from "../context/mentions.js";
|
|
15
22
|
import { memoryDigest } from "../memory/store.js";
|
|
16
23
|
import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
|
|
17
|
-
import { loadSkillIndex } from "../skills/skills.js";
|
|
18
|
-
import { loadJobs } from "../cron/store.js";
|
|
24
|
+
import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
|
|
25
|
+
import { loadJobs, saveJobs, addJob } from "../cron/store.js";
|
|
26
|
+
import { parseSchedule, describeSchedule } from "../cron/schedule.js";
|
|
19
27
|
import { SessionHub, realStore } from "./sessions.js";
|
|
20
28
|
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
21
29
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
@@ -70,7 +78,7 @@ export async function startServe(opts, deps) {
|
|
|
70
78
|
writeFileSync(discoveryPath, JSON.stringify({ host: opts.host, port, token, pid: process.pid, version: deps.version }, null, 2), { mode: 0o600 });
|
|
71
79
|
}
|
|
72
80
|
/** Run one turn on a session, streaming events to all authed clients. */
|
|
73
|
-
const runTurn = async (s, text) => {
|
|
81
|
+
const runTurn = async (s, text, images) => {
|
|
74
82
|
const sessionId = s.meta.id;
|
|
75
83
|
s.busy = true;
|
|
76
84
|
s.abort = new AbortController();
|
|
@@ -96,8 +104,21 @@ export async function startServe(opts, deps) {
|
|
|
96
104
|
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
97
105
|
});
|
|
98
106
|
try {
|
|
99
|
-
//
|
|
100
|
-
s
|
|
107
|
+
// Slash skills, CLI parity: "/skill-id request…" expands into the skill-entering message, so a
|
|
108
|
+
// desktop composer's "/" popup triggers the exact behavior the terminal gets. Unknown ids fall
|
|
109
|
+
// through as plain text (the model sees what the user typed).
|
|
110
|
+
let content = text;
|
|
111
|
+
const slash = /^\/([a-z0-9][\w-]*)(?:\s+([\s\S]*))?$/.exec(text.trim());
|
|
112
|
+
if (slash) {
|
|
113
|
+
const sk = loadSkillIndex(s.meta.cwd).find((k) => k.id === slash[1]);
|
|
114
|
+
if (sk) {
|
|
115
|
+
const rest = slash[2]?.trim();
|
|
116
|
+
content = `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${rest ? ` — request: ${rest}` : ""}. 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.`;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
|
|
120
|
+
// Pasted images ride along as NeutralMsg.images — a vision-capable model sees them inline.
|
|
121
|
+
s.history.push({ role: "user", content: expandMentions(content, s.meta.cwd), ...(images && images.length ? { images } : {}) });
|
|
101
122
|
await runAgent(s.history, {
|
|
102
123
|
provider: s.provider,
|
|
103
124
|
ctx: {
|
|
@@ -118,14 +139,57 @@ export async function startServe(opts, deps) {
|
|
|
118
139
|
hub.save(s);
|
|
119
140
|
const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
|
|
120
141
|
const reply = lastAssistantText(s.history);
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
// context watermark rides along with every turn end (codex thread/tokenUsage/updated pattern) —
|
|
143
|
+
// clients render a meter without an extra round-trip.
|
|
144
|
+
const ctx = ctxOf(s);
|
|
145
|
+
broadcast("event.turn_end", { sessionId, reply, usage, ctx });
|
|
146
|
+
return { reply, usage, ctx };
|
|
123
147
|
}
|
|
124
148
|
finally {
|
|
125
149
|
s.busy = false;
|
|
126
150
|
s.abort = null;
|
|
127
151
|
}
|
|
128
152
|
};
|
|
153
|
+
/** Context watermark for a session: how full the model's window was on the last turn. */
|
|
154
|
+
const ctxOf = (s) => {
|
|
155
|
+
const lastInput = s.stats.lastInput ?? 0;
|
|
156
|
+
return { lastInput, window: contextWindow(s.meta.model), pct: ctxPctFor(s.meta.model, lastInput) };
|
|
157
|
+
};
|
|
158
|
+
/** Summarize + replace a session's history — the CLI's /compact, serve-side (codex thread/compact).
|
|
159
|
+
* Mirrors index.ts compactConversation; the file restore is limited to files under the session's own
|
|
160
|
+
* cwd because serve is multi-session (recentTouched is process-wide and must not leak across projects). */
|
|
161
|
+
const compactSession = async (s) => {
|
|
162
|
+
const r = await s.provider.turn({
|
|
163
|
+
system: COMPACT_SYSTEM,
|
|
164
|
+
history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
165
|
+
tools: [],
|
|
166
|
+
onText: () => { },
|
|
167
|
+
});
|
|
168
|
+
if (r.stop === "error")
|
|
169
|
+
return null;
|
|
170
|
+
const summary = r.text.trim();
|
|
171
|
+
if (!summary)
|
|
172
|
+
return null;
|
|
173
|
+
s.meta.workingSet = workingSetFromSummary(summary);
|
|
174
|
+
s.history.length = 0;
|
|
175
|
+
s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
176
|
+
const touched = recentTouched(20).filter((f) => f.startsWith(s.meta.cwd)).slice(0, 5);
|
|
177
|
+
const restore = buildFileRestore(touched, (f) => {
|
|
178
|
+
try {
|
|
179
|
+
return readFileSync(f, "utf8");
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (restore)
|
|
186
|
+
s.history.push({ role: "user", content: restore });
|
|
187
|
+
s.stats.input += r.usage?.input ?? 0;
|
|
188
|
+
s.stats.output += r.usage?.output ?? 0;
|
|
189
|
+
s.stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary
|
|
190
|
+
hub.save(s);
|
|
191
|
+
return summary;
|
|
192
|
+
};
|
|
129
193
|
wss.on("connection", (ws) => {
|
|
130
194
|
ws.on("message", async (raw) => {
|
|
131
195
|
const parsed = parseFrame(String(raw));
|
|
@@ -145,7 +209,9 @@ export async function startServe(opts, deps) {
|
|
|
145
209
|
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
146
210
|
const methods = [
|
|
147
211
|
"session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
|
|
148
|
-
"
|
|
212
|
+
"session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
|
|
213
|
+
"approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search",
|
|
214
|
+
"automation.list", "automation.add", "automation.toggle", "automation.delete",
|
|
149
215
|
];
|
|
150
216
|
return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
|
|
151
217
|
}
|
|
@@ -185,7 +251,10 @@ export async function startServe(opts, deps) {
|
|
|
185
251
|
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
|
|
186
252
|
if (s.busy)
|
|
187
253
|
return reply(rpcError(id, ERR.BUSY, "a turn is already running on this session"));
|
|
188
|
-
const
|
|
254
|
+
const images = Array.isArray(p.images)
|
|
255
|
+
? p.images.filter((im) => im && typeof im.path === "string").map((im) => ({ path: im.path, mediaType: typeof im.mediaType === "string" ? im.mediaType : "image/png" }))
|
|
256
|
+
: undefined;
|
|
257
|
+
const r = await runTurn(s, p.text, images);
|
|
189
258
|
return reply(rpcResult(id, r));
|
|
190
259
|
}
|
|
191
260
|
case "session.interrupt": {
|
|
@@ -229,6 +298,31 @@ export async function startServe(opts, deps) {
|
|
|
229
298
|
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
230
299
|
return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
|
|
231
300
|
}
|
|
301
|
+
case "session.fork": {
|
|
302
|
+
// duplicate the conversation into a new session (codex thread/fork) — rewind's
|
|
303
|
+
// non-destructive sibling: explore a different direction without losing the original
|
|
304
|
+
if (typeof p.sessionId !== "string")
|
|
305
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
306
|
+
const provider = await deps.buildSessionProvider();
|
|
307
|
+
if (!provider)
|
|
308
|
+
return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
|
|
309
|
+
const r = hub.fork(p.sessionId, { provider, providerId: deps.providerId, approval: deps.approval, projectContext: undefined });
|
|
310
|
+
if ("missing" in r)
|
|
311
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
312
|
+
r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
|
|
313
|
+
return reply(rpcResult(id, { sessionId: r.session.meta.id, title: r.session.meta.title, model: r.session.meta.model, history: historyForClient(r.session.history) }));
|
|
314
|
+
}
|
|
315
|
+
case "session.delete": {
|
|
316
|
+
// permanent removal (codex thread/delete) — archive is the soft path; this one is forever
|
|
317
|
+
if (typeof p.sessionId !== "string")
|
|
318
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
319
|
+
const r = hub.delete(p.sessionId);
|
|
320
|
+
if (r === "busy")
|
|
321
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is running — delete after it finishes"));
|
|
322
|
+
if (r === "missing")
|
|
323
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId} (or held by another process)`));
|
|
324
|
+
return reply(rpcResult(id, { sessionId: p.sessionId, deleted: true }));
|
|
325
|
+
}
|
|
232
326
|
case "models.list": {
|
|
233
327
|
const models = deps.listModels ? await deps.listModels().catch(() => []) : [];
|
|
234
328
|
return reply(rpcResult(id, { models, current: deps.model, effortLevels: deps.effortLevels ?? [] }));
|
|
@@ -259,14 +353,116 @@ export async function startServe(opts, deps) {
|
|
|
259
353
|
// The automation timeline's data: cron jobs with their last outcome, plus this machine's
|
|
260
354
|
// automated sessions (source=cron/gateway) so the desktop can render results and "continue
|
|
261
355
|
// as conversation". Read-only.
|
|
262
|
-
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 }));
|
|
356
|
+
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) }));
|
|
263
357
|
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 }));
|
|
264
358
|
return reply(rpcResult(id, { jobs, sessions: automated }));
|
|
265
359
|
}
|
|
360
|
+
case "automation.add": {
|
|
361
|
+
if (typeof p.name !== "string" || !p.name || typeof p.schedule !== "string" || typeof p.task !== "string" || !p.task) {
|
|
362
|
+
return reply(rpcError(id, ERR.PARAMS, "name + schedule + task required"));
|
|
363
|
+
}
|
|
364
|
+
const sched = parseSchedule(p.schedule, Date.now());
|
|
365
|
+
if ("error" in sched)
|
|
366
|
+
return reply(rpcError(id, ERR.PARAMS, `bad schedule: ${sched.error}`));
|
|
367
|
+
const job = addJob({
|
|
368
|
+
name: p.name.slice(0, 60),
|
|
369
|
+
schedule: sched,
|
|
370
|
+
task: p.task,
|
|
371
|
+
mode: ["print", "org", "command"].includes(p.mode) ? p.mode : "print",
|
|
372
|
+
cwd: typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd,
|
|
373
|
+
...(typeof p.tz === "string" && p.tz ? { tz: p.tz } : {}),
|
|
374
|
+
createdAt: Date.now(),
|
|
375
|
+
});
|
|
376
|
+
return reply(rpcResult(id, { id: job.id, name: job.name, schedule: describeSchedule(job.schedule) }));
|
|
377
|
+
}
|
|
378
|
+
case "automation.toggle": {
|
|
379
|
+
if (typeof p.id !== "string" || typeof p.enabled !== "boolean")
|
|
380
|
+
return reply(rpcError(id, ERR.PARAMS, "id + enabled required"));
|
|
381
|
+
const jobs = loadJobs();
|
|
382
|
+
const job = jobs.find((j) => j.id === p.id);
|
|
383
|
+
if (!job)
|
|
384
|
+
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
385
|
+
job.enabled = p.enabled;
|
|
386
|
+
saveJobs(jobs);
|
|
387
|
+
return reply(rpcResult(id, { id: job.id, enabled: job.enabled }));
|
|
388
|
+
}
|
|
389
|
+
case "automation.delete": {
|
|
390
|
+
if (typeof p.id !== "string")
|
|
391
|
+
return reply(rpcError(id, ERR.PARAMS, "id required"));
|
|
392
|
+
const jobs = loadJobs();
|
|
393
|
+
if (!jobs.some((j) => j.id === p.id))
|
|
394
|
+
return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
|
|
395
|
+
saveJobs(jobs.filter((j) => j.id !== p.id));
|
|
396
|
+
return reply(rpcResult(id, { id: p.id, deleted: true }));
|
|
397
|
+
}
|
|
266
398
|
case "skills.list": {
|
|
267
399
|
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
268
400
|
return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
|
|
269
401
|
}
|
|
402
|
+
case "files.search": {
|
|
403
|
+
// fuzzy file lookup for the composer's @-mention autocomplete (codex fuzzyFileSearch).
|
|
404
|
+
// Relative POSIX paths; empty query returns the first files as a browse list.
|
|
405
|
+
const sess = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
406
|
+
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (sess?.meta.cwd ?? opts.cwd);
|
|
407
|
+
const limit = Math.min(Math.max(Math.trunc(Number(p.limit) || 20), 1), 50);
|
|
408
|
+
const all = listProjectFiles(cwd);
|
|
409
|
+
const query = typeof p.query === "string" ? p.query : "";
|
|
410
|
+
const files = query ? fuzzyRank(query, all, (f) => f).slice(0, limit).map((r) => r.item) : all.slice(0, limit);
|
|
411
|
+
return reply(rpcResult(id, { files, cwd }));
|
|
412
|
+
}
|
|
413
|
+
case "session.context": {
|
|
414
|
+
// context-spend breakdown + watermark on demand (codex thread/tokenUsage + /context).
|
|
415
|
+
if (typeof p.sessionId !== "string")
|
|
416
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
417
|
+
const s = hub.get(p.sessionId);
|
|
418
|
+
if (!s)
|
|
419
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
420
|
+
const report = analyzeContext(s.history);
|
|
421
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, ...ctxOf(s), total: report.total, rows: report.rows.slice(0, 8) }));
|
|
422
|
+
}
|
|
423
|
+
case "session.compact": {
|
|
424
|
+
// manual compaction (codex thread/compact/start): summarize + replace history, keep working
|
|
425
|
+
// notes, restore this-cwd touched files. Busy-guarded like a turn — it IS a provider call.
|
|
426
|
+
if (typeof p.sessionId !== "string")
|
|
427
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
428
|
+
const s = hub.get(p.sessionId);
|
|
429
|
+
if (!s)
|
|
430
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
431
|
+
if (s.busy)
|
|
432
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is running — compact after it finishes"));
|
|
433
|
+
if (s.history.length < 2)
|
|
434
|
+
return reply(rpcError(id, ERR.PARAMS, "nothing to compact yet"));
|
|
435
|
+
s.busy = true;
|
|
436
|
+
try {
|
|
437
|
+
broadcast("event.notice", { sessionId: s.meta.id, text: "✻ Compacting conversation…" });
|
|
438
|
+
const summary = await compactSession(s);
|
|
439
|
+
if (!summary)
|
|
440
|
+
return reply(rpcError(id, ERR.INTERNAL, "compaction failed — try again or /clear"));
|
|
441
|
+
broadcast("event.notice", { sessionId: s.meta.id, text: `(compacted — history replaced with a summary; ${s.meta.workingSet?.length ?? 0} notes kept)` });
|
|
442
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
s.busy = false;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
case "session.rewind": {
|
|
449
|
+
// fork the thread back to before the n-th-most-recent user turn (codex thread/rollback;
|
|
450
|
+
// n=1 drops the last exchange). History-only — file edits are not reverted.
|
|
451
|
+
if (typeof p.sessionId !== "string" || !Number.isInteger(p.n))
|
|
452
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + n required"));
|
|
453
|
+
const s = hub.get(p.sessionId);
|
|
454
|
+
if (!s)
|
|
455
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
456
|
+
if (s.busy)
|
|
457
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is running — rewind after it finishes"));
|
|
458
|
+
const next = rewindTo(s.history, p.n);
|
|
459
|
+
if (!next)
|
|
460
|
+
return reply(rpcError(id, ERR.PARAMS, `n out of range (1..${s.history.filter((m) => m.role === "user").length})`));
|
|
461
|
+
s.history.length = 0;
|
|
462
|
+
s.history.push(...next);
|
|
463
|
+
hub.save(s);
|
|
464
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, history: historyForClient(s.history) }));
|
|
465
|
+
}
|
|
270
466
|
default:
|
|
271
467
|
return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
|
|
272
468
|
}
|
package/dist/serve/sessions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { newSessionId, saveSession, loadSession, listSessions, acquireSessionLock, releaseSessionLock, deriveTitle, } from "../session/store.js";
|
|
1
|
+
import { newSessionId, saveSession, loadSession, listSessions, acquireSessionLock, releaseSessionLock, deleteSession, deriveTitle, } from "../session/store.js";
|
|
2
2
|
/** The real ~/.hara/sessions store (default). */
|
|
3
3
|
export const realStore = {
|
|
4
4
|
load: loadSession,
|
|
@@ -6,6 +6,7 @@ export const realStore = {
|
|
|
6
6
|
list: listSessions,
|
|
7
7
|
acquire: acquireSessionLock,
|
|
8
8
|
release: releaseSessionLock,
|
|
9
|
+
delete: deleteSession,
|
|
9
10
|
};
|
|
10
11
|
export class SessionHub {
|
|
11
12
|
store;
|
|
@@ -89,6 +90,53 @@ export class SessionHub {
|
|
|
89
90
|
this.store.save(prior.meta, prior.history);
|
|
90
91
|
return true;
|
|
91
92
|
}
|
|
93
|
+
/** Fork: duplicate a session's history into a NEW session (codex thread/fork) — the non-destructive
|
|
94
|
+
* sibling of rewind. Source may be live or on-disk; the fork is always a fresh live session. */
|
|
95
|
+
fork(id, o) {
|
|
96
|
+
const live = this.sessions.get(id);
|
|
97
|
+
const src = live ?? this.store.load(id);
|
|
98
|
+
if (!src)
|
|
99
|
+
return { missing: true };
|
|
100
|
+
const meta = {
|
|
101
|
+
id: newSessionId(),
|
|
102
|
+
cwd: src.meta.cwd,
|
|
103
|
+
provider: o.providerId,
|
|
104
|
+
model: src.meta.model,
|
|
105
|
+
title: src.meta.title ? `${src.meta.title} ⑂` : "",
|
|
106
|
+
createdAt: new Date().toISOString(),
|
|
107
|
+
updatedAt: "",
|
|
108
|
+
source: "interactive",
|
|
109
|
+
};
|
|
110
|
+
this.store.acquire(meta.id);
|
|
111
|
+
const s = {
|
|
112
|
+
meta,
|
|
113
|
+
history: [...src.history],
|
|
114
|
+
provider: o.provider,
|
|
115
|
+
approval: o.approval,
|
|
116
|
+
autoApprove: new Set(),
|
|
117
|
+
stats: { input: 0, output: 0 },
|
|
118
|
+
projectContext: o.projectContext,
|
|
119
|
+
busy: false,
|
|
120
|
+
abort: null,
|
|
121
|
+
};
|
|
122
|
+
this.sessions.set(meta.id, s);
|
|
123
|
+
this.store.save(meta, s.history); // persist immediately — a fork should survive a crash unsent
|
|
124
|
+
return { session: s };
|
|
125
|
+
}
|
|
126
|
+
/** Permanently delete (live or on-disk). Refuses a busy live session. Returns:
|
|
127
|
+
* "gone" on success, "busy" when a turn is running, "missing" when unknown/held elsewhere. */
|
|
128
|
+
delete(id) {
|
|
129
|
+
const live = this.sessions.get(id);
|
|
130
|
+
if (live?.busy)
|
|
131
|
+
return "busy";
|
|
132
|
+
const ok = this.store.delete(id);
|
|
133
|
+
if (!ok && !live)
|
|
134
|
+
return "missing";
|
|
135
|
+
if (live)
|
|
136
|
+
this.sessions.delete(id);
|
|
137
|
+
this.store.release(id);
|
|
138
|
+
return "gone";
|
|
139
|
+
}
|
|
92
140
|
/** Release all locks (server shutdown). In-flight turns are aborted by the caller first. */
|
|
93
141
|
releaseAll() {
|
|
94
142
|
for (const id of this.sessions.keys())
|
package/dist/session/store.js
CHANGED
|
@@ -72,6 +72,25 @@ export function releaseSessionLock(id) {
|
|
|
72
72
|
/* best-effort */
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
/** Permanently delete a session from disk (codex thread/delete — unlike archive, irreversible).
|
|
76
|
+
* Refuses when a LIVE other process holds the lock; removes the session file and any lock we may hold.
|
|
77
|
+
* Returns false when the session doesn't exist or is held elsewhere. */
|
|
78
|
+
export function deleteSession(id) {
|
|
79
|
+
const f = sessionFile(id);
|
|
80
|
+
if (!existsSync(f))
|
|
81
|
+
return false;
|
|
82
|
+
const lock = acquireSessionLock(id);
|
|
83
|
+
if (!lock.ok)
|
|
84
|
+
return false;
|
|
85
|
+
try {
|
|
86
|
+
rmSync(f);
|
|
87
|
+
rmSync(lockFile(id), { force: true });
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
75
94
|
/** A full UUID per session (the stable identity). */
|
|
76
95
|
export const newSessionId = () => randomUUID();
|
|
77
96
|
/** First segment of the UUID — a compact label for the status bar / `/sessions`. */
|