@nanhara/hara 0.121.1 → 0.122.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 +66 -0
- package/README.md +40 -6
- package/dist/agent/loop.js +136 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +33 -23
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +631 -222
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +84 -9
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +30 -28
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/gateway/sessions.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// resumes the right one. /sessions then lists the current dir's threads (codex-style), while the chat itself
|
|
5
5
|
// stays a single conversation front-end (hermes-style). The same model backs a future desktop app.
|
|
6
6
|
// Sessions are stored by id in ~/.hara/sessions, so these are real sessions resumable via `hara -p … --resume`.
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
7
9
|
import { homedir } from "node:os";
|
|
8
10
|
import { join } from "node:path";
|
|
9
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
10
|
-
import { createHash } from "node:crypto";
|
|
11
11
|
/** Idle window before a chat auto-rotates to a FRESH session (hours). A WeChat/Feishu chat is one
|
|
12
12
|
* endless surface — without this, days-old context piles onto every new ask and the agent answers
|
|
13
13
|
* from stale state (the "gateway answers with old context" report). Default 8h: overnight gaps start
|
|
@@ -18,92 +18,489 @@ export function idleRotationMs() {
|
|
|
18
18
|
return 0; // 0/garbage → disabled
|
|
19
19
|
return raw * 3_600_000;
|
|
20
20
|
}
|
|
21
|
-
const
|
|
21
|
+
const haraDir = () => join(homedir(), ".hara");
|
|
22
|
+
const dir = () => join(haraDir(), "gateway");
|
|
22
23
|
const file = () => join(dir(), "chats.json");
|
|
23
|
-
const
|
|
24
|
+
const lockFile = () => `${file()}.lock`;
|
|
25
|
+
const reclaimFile = () => `${lockFile()}.reclaim`;
|
|
26
|
+
const waitCell = new Int32Array(new SharedArrayBuffer(4));
|
|
27
|
+
const LOCK_WAIT_MS = 5_000;
|
|
28
|
+
function errorCode(error) {
|
|
29
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
30
|
+
? String(error.code)
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
function ensurePrivateDir() {
|
|
34
|
+
mkdirSync(dir(), { recursive: true, mode: 0o700 });
|
|
35
|
+
// Tighten legacy directories as well. A private leaf is insufficient when ~/.hara itself is traversable.
|
|
36
|
+
try {
|
|
37
|
+
chmodSync(haraDir(), 0o700);
|
|
38
|
+
chmodSync(dir(), 0o700);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* best effort on filesystems without POSIX modes */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function removeIfPresent(path) {
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(path);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (errorCode(error) !== "ENOENT")
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function readLock(path) {
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
56
|
+
return Number.isInteger(parsed?.pid) && parsed.pid > 0 && typeof parsed?.token === "string" && parsed.token
|
|
57
|
+
? { pid: parsed.pid, token: parsed.token }
|
|
58
|
+
: null;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function pidAlive(pid) {
|
|
65
|
+
try {
|
|
66
|
+
process.kill(pid, 0);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return errorCode(error) === "EPERM";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function writeLock(path, record) {
|
|
74
|
+
let fd;
|
|
75
|
+
try {
|
|
76
|
+
fd = openSync(path, "wx", 0o600);
|
|
77
|
+
writeFileSync(fd, JSON.stringify(record), "utf8");
|
|
78
|
+
fsyncSync(fd);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (fd !== undefined) {
|
|
82
|
+
try {
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
catch { /* preserve the original error */ }
|
|
86
|
+
fd = undefined;
|
|
87
|
+
try {
|
|
88
|
+
removeIfPresent(path);
|
|
89
|
+
}
|
|
90
|
+
catch { /* preserve the original error */ }
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (fd !== undefined)
|
|
96
|
+
closeSync(fd);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Serialize read-modify-write cycles across gateway processes. A tokenized owner may only be reclaimed
|
|
100
|
+
* after its PID is proven dead; malformed locks fail closed instead of being unlinked by age. */
|
|
101
|
+
function acquireLock() {
|
|
102
|
+
ensurePrivateDir();
|
|
103
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
104
|
+
const claim = { pid: process.pid, token: randomBytes(16).toString("hex") };
|
|
105
|
+
for (;;) {
|
|
106
|
+
if (existsSync(reclaimFile())) {
|
|
107
|
+
const stale = readLock(reclaimFile());
|
|
108
|
+
if (stale && !pidAlive(stale.pid)) {
|
|
109
|
+
const current = readLock(reclaimFile());
|
|
110
|
+
if (current?.pid === stale.pid && current.token === stale.token && !pidAlive(current.pid)) {
|
|
111
|
+
removeIfPresent(reclaimFile());
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (Date.now() >= deadline)
|
|
116
|
+
throw new Error(`Timed out waiting for gateway session store lock: ${lockFile()}`);
|
|
117
|
+
Atomics.wait(waitCell, 0, 0, 10);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
writeLock(lockFile(), claim);
|
|
122
|
+
let released = false;
|
|
123
|
+
return () => {
|
|
124
|
+
if (released)
|
|
125
|
+
return;
|
|
126
|
+
released = true;
|
|
127
|
+
const current = readLock(lockFile());
|
|
128
|
+
if (current?.pid === claim.pid && current.token === claim.token) {
|
|
129
|
+
removeIfPresent(lockFile());
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (errorCode(error) !== "EEXIST")
|
|
135
|
+
throw error;
|
|
136
|
+
const held = readLock(lockFile());
|
|
137
|
+
if (held && !pidAlive(held.pid)) {
|
|
138
|
+
const guard = { pid: process.pid, token: randomBytes(16).toString("hex") };
|
|
139
|
+
try {
|
|
140
|
+
writeLock(reclaimFile(), guard);
|
|
141
|
+
const current = readLock(lockFile());
|
|
142
|
+
if (current?.pid === held.pid && current.token === held.token && !pidAlive(current.pid)) {
|
|
143
|
+
removeIfPresent(lockFile());
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
catch (reclaimError) {
|
|
147
|
+
if (errorCode(reclaimError) !== "EEXIST")
|
|
148
|
+
throw reclaimError;
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
const currentGuard = readLock(reclaimFile());
|
|
152
|
+
if (currentGuard?.pid === guard.pid && currentGuard.token === guard.token) {
|
|
153
|
+
removeIfPresent(reclaimFile());
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (Date.now() >= deadline) {
|
|
158
|
+
throw new Error(`Timed out waiting for gateway session store lock: ${lockFile()}`);
|
|
159
|
+
}
|
|
160
|
+
Atomics.wait(waitCell, 0, 0, 10);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function emptyThreads() {
|
|
165
|
+
return Object.create(null);
|
|
166
|
+
}
|
|
167
|
+
function isRecord(value) {
|
|
168
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
169
|
+
}
|
|
170
|
+
function normalizeFork(value, label) {
|
|
171
|
+
if (value === undefined)
|
|
172
|
+
return 0;
|
|
173
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
174
|
+
throw new Error(`Invalid fork counter in gateway session store (${label})`);
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
function normalizeThread(value, label) {
|
|
179
|
+
if (!isRecord(value) || typeof value.sessionId !== "string") {
|
|
180
|
+
throw new Error(`Invalid thread in gateway session store (${label})`);
|
|
181
|
+
}
|
|
182
|
+
return { sessionId: value.sessionId, fork: normalizeFork(value.fork, label) };
|
|
183
|
+
}
|
|
184
|
+
function normalizeEntry(value, key) {
|
|
185
|
+
if (!isRecord(value) || typeof value.sessionId !== "string") {
|
|
186
|
+
throw new Error(`Invalid entry in gateway session store (${key})`);
|
|
187
|
+
}
|
|
188
|
+
if (value.cwd !== undefined && typeof value.cwd !== "string") {
|
|
189
|
+
throw new Error(`Invalid cwd in gateway session store (${key})`);
|
|
190
|
+
}
|
|
191
|
+
const threads = emptyThreads();
|
|
192
|
+
if (value.threads !== undefined) {
|
|
193
|
+
if (!isRecord(value.threads))
|
|
194
|
+
throw new Error(`Invalid thread map in gateway session store (${key})`);
|
|
195
|
+
for (const [cwd, thread] of Object.entries(value.threads)) {
|
|
196
|
+
threads[cwd] = normalizeThread(thread, `${key}:${cwd}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const entry = {
|
|
200
|
+
cwd: typeof value.cwd === "string" ? value.cwd : "", // pre-cwd stores are migrated by chatContext
|
|
201
|
+
sessionId: value.sessionId,
|
|
202
|
+
fork: normalizeFork(value.fork, key),
|
|
203
|
+
threads,
|
|
204
|
+
};
|
|
205
|
+
if (typeof value.voice === "boolean")
|
|
206
|
+
entry.voice = value.voice;
|
|
207
|
+
if (typeof value.lastUsed === "number" && Number.isFinite(value.lastUsed))
|
|
208
|
+
entry.lastUsed = value.lastUsed;
|
|
209
|
+
if (typeof value.agent === "string" && value.agent)
|
|
210
|
+
entry.agent = value.agent;
|
|
211
|
+
if (typeof value.agentReturnCwd === "string" && value.agentReturnCwd)
|
|
212
|
+
entry.agentReturnCwd = value.agentReturnCwd;
|
|
213
|
+
return entry;
|
|
214
|
+
}
|
|
24
215
|
function load() {
|
|
216
|
+
let raw;
|
|
25
217
|
try {
|
|
26
|
-
|
|
218
|
+
raw = readFileSync(file(), "utf8");
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (errorCode(error) === "ENOENT")
|
|
222
|
+
return Object.create(null);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
let parsed;
|
|
226
|
+
try {
|
|
227
|
+
parsed = JSON.parse(raw);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
throw new Error(`Invalid JSON in gateway session store: ${file()}`, { cause: error });
|
|
231
|
+
}
|
|
232
|
+
if (!isRecord(parsed))
|
|
233
|
+
throw new Error(`Invalid gateway session store: ${file()}`);
|
|
234
|
+
const result = Object.create(null);
|
|
235
|
+
for (const [key, value] of Object.entries(parsed))
|
|
236
|
+
result[key] = normalizeEntry(value, key);
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
function fsyncDirectory() {
|
|
240
|
+
let fd;
|
|
241
|
+
try {
|
|
242
|
+
fd = openSync(dir(), "r");
|
|
243
|
+
fsyncSync(fd);
|
|
27
244
|
}
|
|
28
245
|
catch {
|
|
29
|
-
|
|
246
|
+
/* best-effort durability on platforms that cannot fsync directory handles */
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
if (fd !== undefined)
|
|
250
|
+
closeSync(fd);
|
|
30
251
|
}
|
|
31
252
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
253
|
+
/** Write a complete replacement privately and atomically; a crash leaves the previous JSON intact. */
|
|
254
|
+
function save(map) {
|
|
255
|
+
ensurePrivateDir();
|
|
256
|
+
const tmp = join(dir(), `.chats.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
257
|
+
let fd;
|
|
258
|
+
try {
|
|
259
|
+
fd = openSync(tmp, "wx", 0o600);
|
|
260
|
+
writeFileSync(fd, JSON.stringify(map, null, 2), "utf8");
|
|
261
|
+
fsyncSync(fd);
|
|
262
|
+
closeSync(fd);
|
|
263
|
+
fd = undefined;
|
|
264
|
+
renameSync(tmp, file());
|
|
265
|
+
try {
|
|
266
|
+
chmodSync(file(), 0o600);
|
|
267
|
+
}
|
|
268
|
+
catch { /* best effort */ }
|
|
269
|
+
fsyncDirectory();
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
if (fd !== undefined)
|
|
273
|
+
closeSync(fd);
|
|
274
|
+
removeIfPresent(tmp);
|
|
275
|
+
throw error;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function mutate(fn) {
|
|
279
|
+
const release = acquireLock();
|
|
280
|
+
try {
|
|
281
|
+
const map = load();
|
|
282
|
+
const result = fn(map);
|
|
283
|
+
save(map);
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
release();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** Thread identity is (platform, chat) for DMs — chatId IS the user — but (platform, chat, USER) in group
|
|
291
|
+
* chats. A missing group user fails closed instead of silently sharing another member's context. */
|
|
292
|
+
function scopedUser(who) {
|
|
293
|
+
if (who?.chatType !== "group")
|
|
294
|
+
return undefined;
|
|
295
|
+
if (who.userId === undefined || who.userId === null || who.userId === "") {
|
|
296
|
+
throw new Error("A group gateway session requires a userId");
|
|
297
|
+
}
|
|
298
|
+
return who.userId;
|
|
299
|
+
}
|
|
300
|
+
function mapKey(platform, chatId, userId) {
|
|
301
|
+
if (userId === undefined)
|
|
302
|
+
return `${platform}:${chatId}`;
|
|
303
|
+
const userTag = createHash("sha256").update(String(userId)).digest("hex").slice(0, 24);
|
|
304
|
+
return `${platform}:${chatId}:u${userTag}`;
|
|
35
305
|
}
|
|
36
306
|
/** A stable, short, dir-specific suffix so each (chat, cwd) pair gets its own session thread. */
|
|
37
307
|
export function cwdTag(cwd) {
|
|
38
308
|
return createHash("sha256").update(cwd).digest("hex").slice(0, 6);
|
|
39
309
|
}
|
|
40
|
-
/** Derived session id for a (chat, cwd, fork): `<platform>-<chatId
|
|
41
|
-
function deriveId(platform, chatId, cwd, fork) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
310
|
+
/** Derived session id for a (chat[, user], cwd, fork): `<platform>-<chatId>[-u<userTag>]-<cwdTag>[-fork]`. */
|
|
311
|
+
function deriveId(platform, chatId, cwd, fork, userId) {
|
|
312
|
+
const u = userId === undefined
|
|
313
|
+
? ""
|
|
314
|
+
: `-u${createHash("sha256").update(String(userId)).digest("hex").slice(0, 24)}`;
|
|
315
|
+
return `${platform}-${chatId}${u}-${cwdTag(cwd)}${fork ? `-${fork}` : ""}`;
|
|
316
|
+
}
|
|
317
|
+
/** A gateway thread may resume/list only ids derived for its own chat identity. This blocks an allowlisted
|
|
318
|
+
* operator from using `/sessions` or `/resume` to cross into another chat/user's persisted transcript. */
|
|
319
|
+
export function ownsChatSession(platform, chatId, sessionId, who) {
|
|
320
|
+
try {
|
|
321
|
+
const userId = scopedUser(who);
|
|
322
|
+
const user = userId === undefined
|
|
323
|
+
? ""
|
|
324
|
+
: `-u${createHash("sha256").update(String(userId)).digest("hex").slice(0, 24)}`;
|
|
325
|
+
const namespace = `${platform}-${chatId}${user}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
326
|
+
// Anchor the complete derived-id grammar. A raw startsWith check lets chat "room" accidentally own
|
|
327
|
+
// chat "room-extra"; the cwd hash + optional numeric fork make the namespace boundary unambiguous.
|
|
328
|
+
return new RegExp(`^${namespace}-[a-f0-9]{6}(?:-[1-9]\\d*)?$`).test(sessionId);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/** Resolve the compact ids shown by the chat UI without ever searching another user's sessions. Exact ids
|
|
335
|
+
* win; otherwise a displayed prefix OR suffix is accepted only when it identifies one owned session. */
|
|
336
|
+
export function resolveOwnedSessionId(platform, chatId, idOrFragment, candidates, who) {
|
|
337
|
+
const fragment = idOrFragment.trim();
|
|
338
|
+
if (!fragment)
|
|
339
|
+
return null;
|
|
340
|
+
const owned = [...new Set(candidates)].filter((id) => ownsChatSession(platform, chatId, id, who));
|
|
341
|
+
if (owned.includes(fragment))
|
|
342
|
+
return { id: fragment };
|
|
343
|
+
const matches = owned.filter((id) => id.startsWith(fragment) || id.endsWith(fragment));
|
|
344
|
+
if (matches.length === 1)
|
|
345
|
+
return { id: matches[0] };
|
|
346
|
+
return matches.length > 1 ? { ambiguous: matches.sort() } : null;
|
|
347
|
+
}
|
|
348
|
+
function rememberCurrent(entry) {
|
|
349
|
+
if (!entry.cwd || !entry.sessionId)
|
|
350
|
+
return;
|
|
351
|
+
entry.threads[entry.cwd] = { sessionId: entry.sessionId, fork: entry.fork };
|
|
352
|
+
}
|
|
353
|
+
function freshEntry(platform, chatId, cwd, userId) {
|
|
354
|
+
const sessionId = deriveId(platform, chatId, cwd, 0, userId);
|
|
355
|
+
const entry = { cwd, sessionId, fork: 0, threads: emptyThreads() };
|
|
356
|
+
rememberCurrent(entry);
|
|
357
|
+
return entry;
|
|
358
|
+
}
|
|
359
|
+
function migrateEntry(entry, platform, chatId, defaultCwd, userId) {
|
|
360
|
+
if (!entry.cwd)
|
|
361
|
+
entry.cwd = defaultCwd;
|
|
362
|
+
if (!entry.sessionId)
|
|
363
|
+
entry.sessionId = deriveId(platform, chatId, entry.cwd, entry.fork, userId);
|
|
364
|
+
rememberCurrent(entry);
|
|
365
|
+
}
|
|
366
|
+
function switchCwd(entry, platform, chatId, cwd, userId) {
|
|
367
|
+
rememberCurrent(entry);
|
|
368
|
+
const prior = entry.threads[cwd];
|
|
369
|
+
entry.cwd = cwd;
|
|
370
|
+
entry.sessionId = prior?.sessionId ?? deriveId(platform, chatId, cwd, 0, userId);
|
|
371
|
+
entry.fork = prior?.fork ?? 0;
|
|
372
|
+
rememberCurrent(entry);
|
|
373
|
+
}
|
|
374
|
+
/** Get (or initialize) the chat's context. `who` adds the user dimension for group chats. */
|
|
375
|
+
export function chatContext(platform, chatId, defaultCwd, who) {
|
|
376
|
+
const uid = scopedUser(who);
|
|
377
|
+
const key = mapKey(platform, chatId, uid);
|
|
378
|
+
return mutate((map) => {
|
|
379
|
+
const now = Date.now();
|
|
380
|
+
let entry = map[key];
|
|
381
|
+
if (!entry) {
|
|
382
|
+
entry = freshEntry(platform, chatId, defaultCwd, uid);
|
|
383
|
+
entry.lastUsed = now;
|
|
384
|
+
map[key] = entry;
|
|
385
|
+
return { cwd: entry.cwd, sessionId: entry.sessionId, voice: false };
|
|
386
|
+
}
|
|
387
|
+
migrateEntry(entry, platform, chatId, defaultCwd, uid);
|
|
388
|
+
// Session hygiene: a chat idle past the window rotates to a fresh thread (same mechanics as /new).
|
|
389
|
+
// The old id is returned so the gateway can offer `/resume <id>`; persisted sessions are not deleted.
|
|
390
|
+
const idleMs = idleRotationMs();
|
|
391
|
+
let rotatedFrom;
|
|
392
|
+
if (idleMs > 0 && typeof entry.lastUsed === "number" && now - entry.lastUsed > idleMs && entry.sessionId) {
|
|
393
|
+
rotatedFrom = entry.sessionId;
|
|
394
|
+
entry.fork += 1;
|
|
395
|
+
entry.sessionId = deriveId(platform, chatId, entry.cwd, entry.fork, uid);
|
|
396
|
+
rememberCurrent(entry);
|
|
397
|
+
}
|
|
398
|
+
entry.lastUsed = now;
|
|
399
|
+
return {
|
|
400
|
+
cwd: entry.cwd,
|
|
401
|
+
sessionId: entry.sessionId,
|
|
402
|
+
voice: !!entry.voice,
|
|
403
|
+
...(entry.agent ? { agent: entry.agent } : {}),
|
|
404
|
+
...(rotatedFrom ? { rotatedFrom } : {}),
|
|
405
|
+
};
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
/** `/agent <ref|main>` — pin a role and optionally enter its home, or restore the pre-role cwd on clear.
|
|
409
|
+
* Callers must pass `home` here instead of calling chatCd first, otherwise the previous cwd is unknowable. */
|
|
410
|
+
export function setChatAgent(platform, chatId, agent, who, home) {
|
|
411
|
+
const uid = scopedUser(who);
|
|
412
|
+
const key = mapKey(platform, chatId, uid);
|
|
413
|
+
return mutate((map) => {
|
|
414
|
+
const entry = map[key];
|
|
415
|
+
if (!entry)
|
|
416
|
+
return undefined; // chatContext normally initializes the entry
|
|
417
|
+
if (agent) {
|
|
418
|
+
if (home && home !== entry.cwd) {
|
|
419
|
+
if (!entry.agentReturnCwd)
|
|
420
|
+
entry.agentReturnCwd = entry.cwd;
|
|
421
|
+
switchCwd(entry, platform, chatId, home, uid);
|
|
422
|
+
}
|
|
423
|
+
else if (agent.startsWith("global:")) {
|
|
424
|
+
// Moving from a project-pinned role to a portable global role makes the current directory the
|
|
425
|
+
// user's real working directory again. Drop the old return point so a later `/agent main` does
|
|
426
|
+
// not unexpectedly jump back past subsequent `/cd` changes.
|
|
427
|
+
delete entry.agentReturnCwd;
|
|
428
|
+
}
|
|
429
|
+
entry.agent = agent;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
delete entry.agent;
|
|
433
|
+
const restore = entry.agentReturnCwd;
|
|
434
|
+
delete entry.agentReturnCwd;
|
|
435
|
+
if (restore && restore !== entry.cwd)
|
|
436
|
+
switchCwd(entry, platform, chatId, restore, uid);
|
|
437
|
+
}
|
|
438
|
+
return entry.agent;
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/** `/voice` — toggle whether this chat member's replies are spoken (TTS audio). */
|
|
442
|
+
export function toggleVoice(platform, chatId, who) {
|
|
443
|
+
const uid = scopedUser(who);
|
|
444
|
+
const key = mapKey(platform, chatId, uid);
|
|
445
|
+
return mutate((map) => {
|
|
446
|
+
const entry = map[key];
|
|
447
|
+
if (!entry)
|
|
448
|
+
return false; // chatContext normally initializes the entry
|
|
449
|
+
entry.voice = !entry.voice;
|
|
450
|
+
return !!entry.voice;
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/** `/cd <dir>` — switch cwd and restore that cwd's last selected/forked session. */
|
|
454
|
+
export function chatCd(platform, chatId, cwd, who) {
|
|
455
|
+
const uid = scopedUser(who);
|
|
456
|
+
const key = mapKey(platform, chatId, uid);
|
|
457
|
+
return mutate((map) => {
|
|
458
|
+
let entry = map[key];
|
|
459
|
+
if (!entry) {
|
|
460
|
+
entry = freshEntry(platform, chatId, cwd, uid);
|
|
461
|
+
map[key] = entry;
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
migrateEntry(entry, platform, chatId, cwd, uid);
|
|
465
|
+
switchCwd(entry, platform, chatId, cwd, uid);
|
|
466
|
+
}
|
|
467
|
+
return entry.sessionId;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/** `/new` — fork a fresh thread for the chat member's current dir (the old session remains on disk). */
|
|
471
|
+
export function newChatSession(platform, chatId, defaultCwd, who) {
|
|
472
|
+
const uid = scopedUser(who);
|
|
473
|
+
const key = mapKey(platform, chatId, uid);
|
|
474
|
+
return mutate((map) => {
|
|
475
|
+
let entry = map[key];
|
|
476
|
+
if (!entry) {
|
|
477
|
+
entry = freshEntry(platform, chatId, defaultCwd, uid);
|
|
478
|
+
map[key] = entry;
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
migrateEntry(entry, platform, chatId, defaultCwd, uid);
|
|
482
|
+
}
|
|
483
|
+
entry.fork += 1;
|
|
484
|
+
entry.sessionId = deriveId(platform, chatId, entry.cwd, entry.fork, uid);
|
|
485
|
+
rememberCurrent(entry);
|
|
486
|
+
return entry.sessionId;
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/** `/resume <id>` — select a persisted session and bind it to its cwd for future /cd round trips. */
|
|
490
|
+
export function setChatSession(platform, chatId, sessionId, cwd, who) {
|
|
491
|
+
const uid = scopedUser(who);
|
|
492
|
+
const key = mapKey(platform, chatId, uid);
|
|
493
|
+
mutate((map) => {
|
|
494
|
+
let entry = map[key];
|
|
495
|
+
if (!entry) {
|
|
496
|
+
entry = freshEntry(platform, chatId, cwd, uid);
|
|
497
|
+
map[key] = entry;
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
migrateEntry(entry, platform, chatId, cwd, uid);
|
|
501
|
+
switchCwd(entry, platform, chatId, cwd, uid);
|
|
502
|
+
}
|
|
503
|
+
entry.sessionId = sessionId;
|
|
504
|
+
rememberCurrent(entry);
|
|
505
|
+
});
|
|
109
506
|
}
|