@nanhara/hara 0.116.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 +19 -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 +169 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ 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
|
+
|
|
8
27
|
## 0.116.0 — the desktop-grade serve protocol (models · automation · rename/archive · capabilities)
|
|
9
28
|
|
|
10
29
|
- **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
24
|
import { loadSkillIndex } from "../skills/skills.js";
|
|
18
|
-
import { loadJobs } from "../cron/store.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,9 @@ export async function startServe(opts, deps) {
|
|
|
96
104
|
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
97
105
|
});
|
|
98
106
|
try {
|
|
99
|
-
// @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works)
|
|
100
|
-
|
|
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 } : {}) });
|
|
101
110
|
await runAgent(s.history, {
|
|
102
111
|
provider: s.provider,
|
|
103
112
|
ctx: {
|
|
@@ -118,14 +127,57 @@ export async function startServe(opts, deps) {
|
|
|
118
127
|
hub.save(s);
|
|
119
128
|
const usage = { input: s.stats.input - before.input, output: s.stats.output - before.output };
|
|
120
129
|
const reply = lastAssistantText(s.history);
|
|
121
|
-
|
|
122
|
-
|
|
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 };
|
|
123
135
|
}
|
|
124
136
|
finally {
|
|
125
137
|
s.busy = false;
|
|
126
138
|
s.abort = null;
|
|
127
139
|
}
|
|
128
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
|
+
};
|
|
129
181
|
wss.on("connection", (ws) => {
|
|
130
182
|
ws.on("message", async (raw) => {
|
|
131
183
|
const parsed = parseFrame(String(raw));
|
|
@@ -145,7 +197,9 @@ export async function startServe(opts, deps) {
|
|
|
145
197
|
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
146
198
|
const methods = [
|
|
147
199
|
"session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
|
|
148
|
-
"
|
|
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",
|
|
149
203
|
];
|
|
150
204
|
return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
|
|
151
205
|
}
|
|
@@ -185,7 +239,10 @@ export async function startServe(opts, deps) {
|
|
|
185
239
|
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId} — session.create/resume first`));
|
|
186
240
|
if (s.busy)
|
|
187
241
|
return reply(rpcError(id, ERR.BUSY, "a turn is already running on this session"));
|
|
188
|
-
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);
|
|
189
246
|
return reply(rpcResult(id, r));
|
|
190
247
|
}
|
|
191
248
|
case "session.interrupt": {
|
|
@@ -259,14 +316,116 @@ export async function startServe(opts, deps) {
|
|
|
259
316
|
// The automation timeline's data: cron jobs with their last outcome, plus this machine's
|
|
260
317
|
// automated sessions (source=cron/gateway) so the desktop can render results and "continue
|
|
261
318
|
// 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 }));
|
|
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) }));
|
|
263
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 }));
|
|
264
321
|
return reply(rpcResult(id, { jobs, sessions: automated }));
|
|
265
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
|
+
}
|
|
266
361
|
case "skills.list": {
|
|
267
362
|
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
268
363
|
return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
|
|
269
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
|
+
}
|
|
270
429
|
default:
|
|
271
430
|
return reply(rpcError(id, ERR.METHOD, `unknown method ${req.method}`));
|
|
272
431
|
}
|