@cjhyy/code-shell-core 0.8.9 → 0.8.11
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/dist/automation/scheduler.js +49 -0
- package/dist/automation/store.d.ts +1 -1
- package/dist/automation/store.js +184 -10
- package/dist/cli/agent-server-stdio.js +7 -0
- package/dist/credentials/store.d.ts +14 -0
- package/dist/credentials/store.js +245 -42
- package/dist/engine/engine.js +45 -6
- package/dist/engine/file-history-hook.js +24 -5
- package/dist/engine/run-types.d.ts +9 -0
- package/dist/engine/turn-loop.js +9 -8
- package/dist/goal/lifecycle.d.ts +2 -0
- package/dist/goal/lifecycle.js +56 -33
- package/dist/index.d.ts +2 -3
- package/dist/index.internal.d.ts +1 -0
- package/dist/index.internal.js +1 -0
- package/dist/index.js +2 -2
- package/dist/links/cli.d.ts +2 -0
- package/dist/links/cli.js +11 -4
- package/dist/model-catalog/index.js +19 -4
- package/dist/model-catalog/save-entry.js +122 -61
- package/dist/model-catalog/types.js +27 -23
- package/dist/panel-apps/installer.js +27 -14
- package/dist/panel-apps/registry.js +60 -12
- package/dist/plugins/installedPlugins.d.ts +4 -0
- package/dist/plugins/installedPlugins.js +70 -30
- package/dist/plugins/installer/types.d.ts +12 -12
- package/dist/plugins/installer/update.js +37 -38
- package/dist/plugins/knownMarketplaces.d.ts +7 -3
- package/dist/plugins/knownMarketplaces.js +127 -23
- package/dist/plugins/pluginCatalog.js +18 -4
- package/dist/plugins/pluginHookApproval.js +56 -60
- package/dist/plugins/pluginMcpApproval.js +50 -52
- package/dist/profile/catalog-store.js +39 -4
- package/dist/profile/catalog.js +55 -15
- package/dist/profile/store.js +51 -21
- package/dist/protocol/chat-session-manager.d.ts +9 -0
- package/dist/protocol/chat-session-manager.js +13 -0
- package/dist/protocol/chat-session.d.ts +5 -0
- package/dist/protocol/chat-session.js +1 -0
- package/dist/protocol/server.d.ts +2 -0
- package/dist/protocol/server.js +75 -29
- package/dist/protocol/types.d.ts +8 -0
- package/dist/run/FileRunStore.d.ts +2 -0
- package/dist/run/FileRunStore.js +153 -18
- package/dist/run/Heartbeat.js +63 -4
- package/dist/services/auto-dream.js +39 -17
- package/dist/services/session-memory.js +107 -8
- package/dist/session/file-history.d.ts +63 -2
- package/dist/session/file-history.js +593 -86
- package/dist/session/session-manager.d.ts +1 -0
- package/dist/session/session-manager.js +52 -21
- package/dist/session/transcript.js +33 -3
- package/dist/session/undo-target.d.ts +15 -6
- package/dist/session/undo-target.js +26 -9
- package/dist/settings/manager.d.ts +22 -3
- package/dist/settings/manager.js +185 -50
- package/dist/settings/schema.d.ts +3 -3
- package/dist/sources/adapters/local-files.js +49 -4
- package/dist/sources/catalog.js +64 -18
- package/dist/sources/types.d.ts +3 -3
- package/dist/sources/types.js +7 -4
- package/dist/themes/installer.js +192 -28
- package/dist/tool-system/builtin/add-marketplace.js +21 -1
- package/dist/tool-system/builtin/cron.d.ts +2 -1
- package/dist/tool-system/builtin/cron.js +20 -6
- package/dist/tool-system/builtin/index.js +44 -0
- package/dist/tool-system/builtin/install-capability.d.ts +52 -0
- package/dist/tool-system/builtin/install-capability.js +1057 -0
- package/dist/tool-system/builtin/skill.js +3 -1
- package/dist/tool-system/executor.js +1 -0
- package/dist/tool-system/registry.js +5 -0
- package/dist/tool-system/sandbox/index.d.ts +1 -0
- package/dist/tool-system/sandbox/index.js +4 -1
- package/dist/utils/file-mutex.d.ts +2 -0
- package/dist/utils/file-mutex.js +29 -4
- package/package.json +2 -1
package/dist/run/FileRunStore.js
CHANGED
|
@@ -9,34 +9,54 @@
|
|
|
9
9
|
* approvals/<id>.json — approval records
|
|
10
10
|
* artifacts/refs.jsonl — artifact reference log
|
|
11
11
|
*/
|
|
12
|
-
import { mkdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, readdirSync, renameSync, rmSync, } from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
12
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, appendFileSync, closeSync, chmodSync, constants, fchmodSync, readdirSync, fstatSync, openSync, readSync, renameSync, rmSync, lstatSync, } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { assertSafeRunFileId, assertSafeRunId } from "./ids.js";
|
|
17
|
+
const MAX_RUN_JSON_BYTES = 8 * 1024 * 1024;
|
|
18
|
+
const MAX_RUN_JSONL_BYTES = 128 * 1024 * 1024;
|
|
19
|
+
const MAX_RUN_JSONL_RECORD_BYTES = 2 * 1024 * 1024;
|
|
20
|
+
const MAX_RUN_DIRECTORIES = 100_000;
|
|
21
|
+
const MAX_RUN_CHILD_FILES = 100_000;
|
|
17
22
|
export class FileRunStore {
|
|
18
23
|
runsDir;
|
|
19
24
|
constructor(storageDir) {
|
|
20
25
|
this.runsDir = storageDir ?? join(homedir(), ".code-shell", "runs");
|
|
21
|
-
mkdirSync(this.runsDir, { recursive: true });
|
|
26
|
+
mkdirSync(this.runsDir, { recursive: true, mode: 0o700 });
|
|
27
|
+
this.assertRealDirectory(this.runsDir);
|
|
28
|
+
if (process.platform !== "win32")
|
|
29
|
+
chmodSync(this.runsDir, 0o700);
|
|
22
30
|
}
|
|
23
31
|
// ─── Helpers ───────────────────────────────────────────────────
|
|
24
32
|
runDir(runId) {
|
|
25
33
|
assertSafeRunId(runId);
|
|
26
|
-
|
|
34
|
+
const dir = join(this.runsDir, runId);
|
|
35
|
+
if (existsSync(dir))
|
|
36
|
+
this.assertRealDirectory(dir);
|
|
37
|
+
return dir;
|
|
27
38
|
}
|
|
28
39
|
ensureRunDir(runId) {
|
|
29
40
|
const dir = this.runDir(runId);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
const directories = [dir, join(dir, "checkpoints"), join(dir, "approvals"), join(dir, "artifacts")];
|
|
42
|
+
for (const directory of directories) {
|
|
43
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
44
|
+
this.assertRealDirectory(directory);
|
|
45
|
+
if (process.platform !== "win32")
|
|
46
|
+
chmodSync(directory, 0o700);
|
|
47
|
+
}
|
|
34
48
|
return dir;
|
|
35
49
|
}
|
|
36
50
|
writeJson(filePath, data) {
|
|
51
|
+
this.assertRealDirectory(dirname(filePath));
|
|
52
|
+
this.assertSafeFileTarget(filePath);
|
|
53
|
+
const serialized = JSON.stringify(data, null, 2);
|
|
54
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_RUN_JSON_BYTES) {
|
|
55
|
+
throw new Error(`Run JSON exceeds ${MAX_RUN_JSON_BYTES} bytes`);
|
|
56
|
+
}
|
|
37
57
|
const tmp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
38
58
|
try {
|
|
39
|
-
writeFileSync(tmp,
|
|
59
|
+
writeFileSync(tmp, serialized, { encoding: "utf-8", mode: 0o600, flag: "wx" });
|
|
40
60
|
// Atomic rename — prevents partial writes on crash
|
|
41
61
|
renameSync(tmp, filePath);
|
|
42
62
|
}
|
|
@@ -49,7 +69,42 @@ export class FileRunStore {
|
|
|
49
69
|
readJson(filePath) {
|
|
50
70
|
if (!existsSync(filePath))
|
|
51
71
|
return null;
|
|
52
|
-
|
|
72
|
+
this.assertRealDirectory(dirname(filePath));
|
|
73
|
+
let fd;
|
|
74
|
+
try {
|
|
75
|
+
const entry = lstatSync(filePath);
|
|
76
|
+
if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_RUN_JSON_BYTES) {
|
|
77
|
+
throw new Error(`Run JSON is not a bounded regular file: ${filePath}`);
|
|
78
|
+
}
|
|
79
|
+
fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
80
|
+
const opened = fstatSync(fd);
|
|
81
|
+
if (!opened.isFile() || opened.size > MAX_RUN_JSON_BYTES) {
|
|
82
|
+
throw new Error(`Run JSON is not a bounded regular file: ${filePath}`);
|
|
83
|
+
}
|
|
84
|
+
return JSON.parse(readFileSync(fd, "utf-8"));
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
if (fd !== undefined)
|
|
88
|
+
closeSync(fd);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
assertRealDirectory(directory) {
|
|
92
|
+
const info = lstatSync(directory);
|
|
93
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
94
|
+
throw new Error(`Run storage path is not a real directory: ${directory}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
assertSafeFileTarget(filePath) {
|
|
98
|
+
try {
|
|
99
|
+
const info = lstatSync(filePath);
|
|
100
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
101
|
+
throw new Error(`Run storage target is not a regular file: ${filePath}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (error.code !== "ENOENT")
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
53
108
|
}
|
|
54
109
|
/** Serializes concurrent JSONL appends per file path. */
|
|
55
110
|
appendLocks = new Map();
|
|
@@ -68,7 +123,39 @@ export class FileRunStore {
|
|
|
68
123
|
this.appendLocks.set(filePath, lock);
|
|
69
124
|
try {
|
|
70
125
|
await prev.catch(() => { });
|
|
71
|
-
|
|
126
|
+
this.assertRealDirectory(dirname(filePath));
|
|
127
|
+
this.assertSafeFileTarget(filePath);
|
|
128
|
+
const record = JSON.stringify(data);
|
|
129
|
+
if (Buffer.byteLength(record, "utf8") > MAX_RUN_JSONL_RECORD_BYTES) {
|
|
130
|
+
throw new Error(`Run JSONL record exceeds ${MAX_RUN_JSONL_RECORD_BYTES} bytes`);
|
|
131
|
+
}
|
|
132
|
+
// A process crash can leave a partial final JSONL record. Without a
|
|
133
|
+
// separator, the first append after restart would concatenate a valid
|
|
134
|
+
// record onto that fragment and lose both records. Inspect and repair
|
|
135
|
+
// the boundary through the same append-mode descriptor used to write.
|
|
136
|
+
const fd = openSync(filePath, constants.O_APPEND |
|
|
137
|
+
constants.O_CREAT |
|
|
138
|
+
constants.O_RDWR |
|
|
139
|
+
(constants.O_NOFOLLOW ?? 0), 0o600);
|
|
140
|
+
try {
|
|
141
|
+
if (process.platform !== "win32")
|
|
142
|
+
fchmodSync(fd, 0o600);
|
|
143
|
+
const size = fstatSync(fd).size;
|
|
144
|
+
if (size + Buffer.byteLength(record, "utf8") + 2 > MAX_RUN_JSONL_BYTES) {
|
|
145
|
+
throw new Error(`Run JSONL exceeds ${MAX_RUN_JSONL_BYTES} bytes`);
|
|
146
|
+
}
|
|
147
|
+
let prefix = "";
|
|
148
|
+
if (size > 0) {
|
|
149
|
+
const lastByte = Buffer.allocUnsafe(1);
|
|
150
|
+
readSync(fd, lastByte, 0, 1, size - 1);
|
|
151
|
+
if (lastByte[0] !== 0x0a)
|
|
152
|
+
prefix = "\n";
|
|
153
|
+
}
|
|
154
|
+
appendFileSync(fd, prefix + record + "\n", "utf-8");
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
}
|
|
72
159
|
}
|
|
73
160
|
finally {
|
|
74
161
|
// Release the lock for the next writer regardless of success/failure,
|
|
@@ -82,13 +169,41 @@ export class FileRunStore {
|
|
|
82
169
|
readJsonl(filePath) {
|
|
83
170
|
if (!existsSync(filePath))
|
|
84
171
|
return [];
|
|
85
|
-
|
|
172
|
+
this.assertRealDirectory(dirname(filePath));
|
|
173
|
+
let fd;
|
|
174
|
+
let content;
|
|
175
|
+
try {
|
|
176
|
+
const entry = lstatSync(filePath);
|
|
177
|
+
if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_RUN_JSONL_BYTES) {
|
|
178
|
+
throw new Error(`Run JSONL is not a bounded regular file: ${filePath}`);
|
|
179
|
+
}
|
|
180
|
+
fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
181
|
+
const opened = fstatSync(fd);
|
|
182
|
+
if (!opened.isFile() || opened.size > MAX_RUN_JSONL_BYTES) {
|
|
183
|
+
throw new Error(`Run JSONL is not a bounded regular file: ${filePath}`);
|
|
184
|
+
}
|
|
185
|
+
content = readFileSync(fd, "utf-8");
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
if (fd !== undefined)
|
|
189
|
+
closeSync(fd);
|
|
190
|
+
}
|
|
86
191
|
if (!content)
|
|
87
192
|
return [];
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
193
|
+
const records = [];
|
|
194
|
+
for (const line of content.split("\n")) {
|
|
195
|
+
if (!line.trim())
|
|
196
|
+
continue;
|
|
197
|
+
try {
|
|
198
|
+
records.push(JSON.parse(line));
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Append-only logs must remain readable after a torn final write. A
|
|
202
|
+
// malformed record is isolated to its line; later valid records still
|
|
203
|
+
// carry useful recovery state (same policy as Transcript.loadFromFile).
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return records;
|
|
92
207
|
}
|
|
93
208
|
// ─── Snapshot ──────────────────────────────────────────────────
|
|
94
209
|
async create(snapshot) {
|
|
@@ -109,11 +224,24 @@ export class FileRunStore {
|
|
|
109
224
|
if (!existsSync(this.runsDir))
|
|
110
225
|
return [];
|
|
111
226
|
const entries = readdirSync(this.runsDir, { withFileTypes: true });
|
|
227
|
+
if (entries.length > MAX_RUN_DIRECTORIES)
|
|
228
|
+
throw new Error("Run registry has too many entries");
|
|
112
229
|
const snapshots = [];
|
|
113
230
|
for (const entry of entries) {
|
|
114
231
|
if (!entry.isDirectory())
|
|
115
232
|
continue;
|
|
116
|
-
|
|
233
|
+
let snapshot;
|
|
234
|
+
try {
|
|
235
|
+
snapshot = this.readJson(join(this.runsDir, entry.name, "run.json"));
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
// One manually damaged/legacy partial snapshot must not make every
|
|
239
|
+
// healthy run disappear from the history list. Preserve real I/O
|
|
240
|
+
// failures, but isolate JSON corruption to the affected directory.
|
|
241
|
+
if (error instanceof SyntaxError)
|
|
242
|
+
continue;
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
117
245
|
if (!snapshot)
|
|
118
246
|
continue;
|
|
119
247
|
// Filter by status
|
|
@@ -123,8 +251,9 @@ export class FileRunStore {
|
|
|
123
251
|
continue;
|
|
124
252
|
}
|
|
125
253
|
// Filter by tag
|
|
126
|
-
if (query?.tag && !snapshot.tags.includes(query.tag))
|
|
254
|
+
if (query?.tag && (!Array.isArray(snapshot.tags) || !snapshot.tags.includes(query.tag))) {
|
|
127
255
|
continue;
|
|
256
|
+
}
|
|
128
257
|
snapshots.push(snapshot);
|
|
129
258
|
}
|
|
130
259
|
// Sort by createdAt descending (newest first)
|
|
@@ -165,7 +294,10 @@ export class FileRunStore {
|
|
|
165
294
|
const cpDir = join(this.runDir(runId), "checkpoints");
|
|
166
295
|
if (!existsSync(cpDir))
|
|
167
296
|
return null;
|
|
297
|
+
this.assertRealDirectory(cpDir);
|
|
168
298
|
const files = readdirSync(cpDir).filter((f) => f.endsWith(".json"));
|
|
299
|
+
if (files.length > MAX_RUN_CHILD_FILES)
|
|
300
|
+
throw new Error("Run has too many checkpoints");
|
|
169
301
|
if (files.length === 0)
|
|
170
302
|
return null;
|
|
171
303
|
// Find the latest by createdAt
|
|
@@ -194,7 +326,10 @@ export class FileRunStore {
|
|
|
194
326
|
const approvalDir = join(this.runDir(runId), "approvals");
|
|
195
327
|
if (!existsSync(approvalDir))
|
|
196
328
|
return null;
|
|
329
|
+
this.assertRealDirectory(approvalDir);
|
|
197
330
|
const files = readdirSync(approvalDir).filter((f) => f.endsWith(".json"));
|
|
331
|
+
if (files.length > MAX_RUN_CHILD_FILES)
|
|
332
|
+
throw new Error("Run has too many approvals");
|
|
198
333
|
for (const file of files) {
|
|
199
334
|
const approval = this.readJson(join(approvalDir, file));
|
|
200
335
|
if (approval?.status === "pending")
|
package/dist/run/Heartbeat.js
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
* File: ~/.code-shell/runs/<runId>/heartbeat
|
|
9
9
|
* Content: JSON { pid, timestamp, runId }
|
|
10
10
|
*/
|
|
11
|
-
import { existsSync, readFileSync,
|
|
12
|
-
import { join } from "node:path";
|
|
11
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
14
15
|
import { assertSafeRunId } from "./ids.js";
|
|
16
|
+
const MAX_HEARTBEAT_BYTES = 64 * 1024;
|
|
15
17
|
export class Heartbeat {
|
|
16
18
|
runsDir;
|
|
17
19
|
intervalMs;
|
|
@@ -66,14 +68,42 @@ export class Heartbeat {
|
|
|
66
68
|
read(runId) {
|
|
67
69
|
assertSafeRunId(runId);
|
|
68
70
|
const filePath = this.filePath(runId);
|
|
71
|
+
try {
|
|
72
|
+
const parentInfo = lstatSync(dirname(filePath));
|
|
73
|
+
if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
69
79
|
if (!existsSync(filePath))
|
|
70
80
|
return null;
|
|
81
|
+
let fd;
|
|
71
82
|
try {
|
|
72
|
-
|
|
83
|
+
const entry = lstatSync(filePath);
|
|
84
|
+
if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_HEARTBEAT_BYTES)
|
|
85
|
+
return null;
|
|
86
|
+
fd = openSync(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
87
|
+
const opened = fstatSync(fd);
|
|
88
|
+
if (!opened.isFile() || opened.size > MAX_HEARTBEAT_BYTES)
|
|
89
|
+
return null;
|
|
90
|
+
const value = JSON.parse(readFileSync(fd, "utf-8"));
|
|
91
|
+
if (value.runId !== runId ||
|
|
92
|
+
!Number.isSafeInteger(value.pid) ||
|
|
93
|
+
(value.pid ?? 0) <= 0 ||
|
|
94
|
+
!Number.isSafeInteger(value.timestamp) ||
|
|
95
|
+
(value.timestamp ?? -1) < 0) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
73
99
|
}
|
|
74
100
|
catch {
|
|
75
101
|
return null;
|
|
76
102
|
}
|
|
103
|
+
finally {
|
|
104
|
+
if (fd !== undefined)
|
|
105
|
+
closeSync(fd);
|
|
106
|
+
}
|
|
77
107
|
}
|
|
78
108
|
/**
|
|
79
109
|
* Check if a run's heartbeat is stale (older than threshold).
|
|
@@ -110,17 +140,46 @@ export class Heartbeat {
|
|
|
110
140
|
timestamp: Date.now(),
|
|
111
141
|
runId,
|
|
112
142
|
};
|
|
143
|
+
const filePath = this.filePath(runId);
|
|
144
|
+
const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
113
145
|
try {
|
|
114
|
-
|
|
146
|
+
const parent = dirname(filePath);
|
|
147
|
+
const parentInfo = lstatSync(parent);
|
|
148
|
+
if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
|
|
149
|
+
return;
|
|
150
|
+
try {
|
|
151
|
+
const targetInfo = lstatSync(filePath);
|
|
152
|
+
if (targetInfo.isSymbolicLink() || !targetInfo.isFile())
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error.code !== "ENOENT")
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
writeFileSync(temporary, JSON.stringify(data), {
|
|
160
|
+
encoding: "utf8",
|
|
161
|
+
mode: 0o600,
|
|
162
|
+
flag: "wx",
|
|
163
|
+
});
|
|
164
|
+
renameSync(temporary, filePath);
|
|
115
165
|
}
|
|
116
166
|
catch {
|
|
117
167
|
// Run directory may have been deleted
|
|
118
168
|
}
|
|
169
|
+
finally {
|
|
170
|
+
rmSync(temporary, { force: true });
|
|
171
|
+
}
|
|
119
172
|
}
|
|
120
173
|
remove(runId) {
|
|
121
174
|
try {
|
|
122
175
|
const filePath = this.filePath(runId);
|
|
176
|
+
const parentInfo = lstatSync(dirname(filePath));
|
|
177
|
+
if (parentInfo.isSymbolicLink() || !parentInfo.isDirectory())
|
|
178
|
+
return;
|
|
123
179
|
if (existsSync(filePath)) {
|
|
180
|
+
const targetInfo = lstatSync(filePath);
|
|
181
|
+
if (targetInfo.isSymbolicLink() || !targetInfo.isFile())
|
|
182
|
+
return;
|
|
124
183
|
unlinkSync(filePath);
|
|
125
184
|
}
|
|
126
185
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Periodically consolidates and organizes memories using the LLM,
|
|
5
5
|
* similar to the /dream command but running automatically.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { resolveMemoryBaseDir } from "../session/memory.js";
|
|
10
10
|
import { mutateJsonFile } from "../utils/file-mutex.js";
|
|
@@ -13,6 +13,8 @@ const DEFAULT_CONFIG = {
|
|
|
13
13
|
minTimeBetween: 24 * 60 * 60 * 1000, // 24 hours
|
|
14
14
|
enabled: true,
|
|
15
15
|
};
|
|
16
|
+
const MAX_DREAM_STATE_BYTES = 64 * 1024;
|
|
17
|
+
const MAX_SESSION_COUNT = 1_000_000;
|
|
16
18
|
// Co-locate the dream-cadence state with the memories it tracks: both resolve
|
|
17
19
|
// through resolveMemoryBaseDir (CODE_SHELL_HOME ?? $HOME ?? homedir()), so a
|
|
18
20
|
// relocated/test HOME moves them together and never writes the real ~/.code-shell.
|
|
@@ -21,18 +23,38 @@ function getStateFile() {
|
|
|
21
23
|
}
|
|
22
24
|
function loadState() {
|
|
23
25
|
const stateFile = getStateFile();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
let descriptor;
|
|
27
|
+
try {
|
|
28
|
+
const pathInfo = lstatSync(stateFile);
|
|
29
|
+
if (pathInfo.isSymbolicLink() || !pathInfo.isFile() || pathInfo.size > MAX_DREAM_STATE_BYTES) {
|
|
30
|
+
return { lastDreamAt: null, sessionsSinceLastDream: 0 };
|
|
27
31
|
}
|
|
28
|
-
|
|
32
|
+
descriptor = openSync(stateFile, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
33
|
+
const opened = fstatSync(descriptor);
|
|
34
|
+
if (!opened.isFile() || opened.size > MAX_DREAM_STATE_BYTES) {
|
|
35
|
+
return { lastDreamAt: null, sessionsSinceLastDream: 0 };
|
|
36
|
+
}
|
|
37
|
+
return parseDreamState(readFileSync(descriptor, "utf-8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { lastDreamAt: null, sessionsSinceLastDream: 0 };
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (descriptor !== undefined)
|
|
44
|
+
closeSync(descriptor);
|
|
29
45
|
}
|
|
30
|
-
return { lastDreamAt: null, sessionsSinceLastDream: 0 };
|
|
31
46
|
}
|
|
32
|
-
function
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
47
|
+
function parseDreamState(raw) {
|
|
48
|
+
const parsed = JSON.parse(raw);
|
|
49
|
+
const timestamp = typeof parsed.lastDreamAt === "string" ? parsed.lastDreamAt : null;
|
|
50
|
+
const lastDreamAt = timestamp && timestamp.length <= 64 && Number.isFinite(Date.parse(timestamp)) ? timestamp : null;
|
|
51
|
+
const count = parsed.sessionsSinceLastDream;
|
|
52
|
+
return {
|
|
53
|
+
lastDreamAt,
|
|
54
|
+
sessionsSinceLastDream: typeof count === "number" && Number.isSafeInteger(count) && count >= 0
|
|
55
|
+
? Math.min(count, MAX_SESSION_COUNT)
|
|
56
|
+
: 0,
|
|
57
|
+
};
|
|
36
58
|
}
|
|
37
59
|
/**
|
|
38
60
|
* Read-modify-write the cadence state under a cross-process lock.
|
|
@@ -47,11 +69,7 @@ function mutateState(change) {
|
|
|
47
69
|
if (raw === undefined)
|
|
48
70
|
return { lastDreamAt: null, sessionsSinceLastDream: 0 };
|
|
49
71
|
try {
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
lastDreamAt: parsed.lastDreamAt ?? null,
|
|
53
|
-
sessionsSinceLastDream: Number(parsed.sessionsSinceLastDream) || 0,
|
|
54
|
-
};
|
|
72
|
+
return parseDreamState(raw);
|
|
55
73
|
}
|
|
56
74
|
catch {
|
|
57
75
|
return { lastDreamAt: null, sessionsSinceLastDream: 0 };
|
|
@@ -59,6 +77,7 @@ function mutateState(change) {
|
|
|
59
77
|
},
|
|
60
78
|
serialize: (state) => JSON.stringify(state, null, 2),
|
|
61
79
|
mutation: (current) => ({ value: change(current) }),
|
|
80
|
+
maxBytes: MAX_DREAM_STATE_BYTES,
|
|
62
81
|
});
|
|
63
82
|
}
|
|
64
83
|
/**
|
|
@@ -99,7 +118,7 @@ export function recordSession() {
|
|
|
99
118
|
// reached its threshold and auto-consolidation silently stopped happening.
|
|
100
119
|
mutateState((state) => ({
|
|
101
120
|
...state,
|
|
102
|
-
sessionsSinceLastDream: state.sessionsSinceLastDream + 1,
|
|
121
|
+
sessionsSinceLastDream: Math.min(MAX_SESSION_COUNT, state.sessionsSinceLastDream + 1),
|
|
103
122
|
}));
|
|
104
123
|
}
|
|
105
124
|
/**
|
|
@@ -111,9 +130,12 @@ export function recordSession() {
|
|
|
111
130
|
* resetting to 0 threw away increments that belonged to the next cycle.
|
|
112
131
|
*/
|
|
113
132
|
export function recordDreamComplete(consumed) {
|
|
133
|
+
const safeConsumed = typeof consumed === "number" && Number.isSafeInteger(consumed) && consumed >= 0
|
|
134
|
+
? consumed
|
|
135
|
+
: undefined;
|
|
114
136
|
mutateState((state) => ({
|
|
115
137
|
lastDreamAt: new Date().toISOString(),
|
|
116
|
-
sessionsSinceLastDream:
|
|
138
|
+
sessionsSinceLastDream: safeConsumed === undefined ? 0 : Math.max(0, state.sessionsSinceLastDream - safeConsumed),
|
|
117
139
|
}));
|
|
118
140
|
}
|
|
119
141
|
/**
|
|
@@ -4,10 +4,15 @@
|
|
|
4
4
|
* Extracts and maintains conversation context as persistent memory
|
|
5
5
|
* entries that survive across sessions.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
8
9
|
import { join } from "node:path";
|
|
9
10
|
import { userHome } from "../settings/manager.js";
|
|
10
11
|
import { sortSessionMemoriesByRecency } from "./session-memory-sort.js";
|
|
12
|
+
const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/u;
|
|
13
|
+
const MAX_MEMORY_FILE_BYTES = 1024 * 1024;
|
|
14
|
+
const MAX_MEMORY_ENTRIES = 20_000;
|
|
15
|
+
const MAX_MEMORY_LIST_ITEMS = 1_000;
|
|
11
16
|
// Resolve per-call (NOT a module const): userHome() reads $HOME live, so a
|
|
12
17
|
// relocated/test HOME redirects writes instead of pinning the real ~/.code-shell
|
|
13
18
|
// at import time (bun freezes a module-level homedir() and never re-reads it).
|
|
@@ -22,23 +27,54 @@ function memoryDir(baseDir) {
|
|
|
22
27
|
*/
|
|
23
28
|
export function saveSessionMemory(entry, baseDir) {
|
|
24
29
|
const dir = memoryDir(baseDir);
|
|
25
|
-
|
|
30
|
+
assertSessionMemoryEntry(entry);
|
|
31
|
+
ensureRealMemoryDirectory(dir);
|
|
26
32
|
const file = join(dir, `${entry.sessionId}.json`);
|
|
27
|
-
|
|
33
|
+
assertSafeMemoryTarget(file);
|
|
34
|
+
const serialized = JSON.stringify(entry, null, 2);
|
|
35
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_MEMORY_FILE_BYTES) {
|
|
36
|
+
throw new Error("session memory is too large");
|
|
37
|
+
}
|
|
38
|
+
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
39
|
+
try {
|
|
40
|
+
writeFileSync(temporary, serialized, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
41
|
+
renameSync(temporary, file);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
rmSync(temporary, { force: true });
|
|
45
|
+
}
|
|
28
46
|
}
|
|
29
47
|
/**
|
|
30
48
|
* Load a session memory by session ID.
|
|
31
49
|
*/
|
|
32
50
|
export function loadSessionMemory(sessionId, baseDir) {
|
|
51
|
+
if (!validSessionId(sessionId))
|
|
52
|
+
return null;
|
|
33
53
|
const file = join(memoryDir(baseDir), `${sessionId}.json`);
|
|
34
54
|
if (!existsSync(file))
|
|
35
55
|
return null;
|
|
56
|
+
let fd;
|
|
36
57
|
try {
|
|
37
|
-
|
|
58
|
+
const dirInfo = lstatSync(memoryDir(baseDir));
|
|
59
|
+
if (dirInfo.isSymbolicLink() || !dirInfo.isDirectory())
|
|
60
|
+
return null;
|
|
61
|
+
const entry = lstatSync(file);
|
|
62
|
+
if (entry.isSymbolicLink() || !entry.isFile() || entry.size > MAX_MEMORY_FILE_BYTES)
|
|
63
|
+
return null;
|
|
64
|
+
fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
65
|
+
const opened = fstatSync(fd);
|
|
66
|
+
if (!opened.isFile() || opened.size > MAX_MEMORY_FILE_BYTES)
|
|
67
|
+
return null;
|
|
68
|
+
const parsed = JSON.parse(readFileSync(fd, "utf-8"));
|
|
69
|
+
return validSessionMemoryEntry(parsed) && parsed.sessionId === sessionId ? parsed : null;
|
|
38
70
|
}
|
|
39
71
|
catch {
|
|
40
72
|
return null;
|
|
41
73
|
}
|
|
74
|
+
finally {
|
|
75
|
+
if (fd !== undefined)
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
}
|
|
42
78
|
}
|
|
43
79
|
/**
|
|
44
80
|
* List all session memories, most recent first.
|
|
@@ -47,21 +83,84 @@ export function listSessionMemories(limit = 50, baseDir) {
|
|
|
47
83
|
const dir = memoryDir(baseDir);
|
|
48
84
|
if (!existsSync(dir))
|
|
49
85
|
return [];
|
|
86
|
+
try {
|
|
87
|
+
const info = lstatSync(dir);
|
|
88
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
50
94
|
// Read all entries, then order by createdAt (not by filename — the filename
|
|
51
95
|
// is the sessionId, which has no chronological meaning), then take `limit`.
|
|
52
96
|
const entries = [];
|
|
53
|
-
|
|
54
|
-
|
|
97
|
+
const files = readdirSync(dir, { withFileTypes: true });
|
|
98
|
+
if (files.length > MAX_MEMORY_ENTRIES)
|
|
99
|
+
return [];
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
if (!file.isFile() || !file.name.endsWith(".json"))
|
|
102
|
+
continue;
|
|
103
|
+
const sessionId = file.name.slice(0, -5);
|
|
104
|
+
if (!validSessionId(sessionId))
|
|
55
105
|
continue;
|
|
56
106
|
try {
|
|
57
|
-
|
|
107
|
+
const entry = loadSessionMemory(sessionId, baseDir);
|
|
108
|
+
if (entry)
|
|
109
|
+
entries.push(entry);
|
|
58
110
|
}
|
|
59
111
|
catch {
|
|
60
112
|
/* intentional: skip a corrupt/torn memory file rather than failing the
|
|
61
113
|
whole listing — one bad entry must not hide all the others. */
|
|
62
114
|
}
|
|
63
115
|
}
|
|
64
|
-
|
|
116
|
+
const safeLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, MAX_MEMORY_LIST_ITEMS) : 0;
|
|
117
|
+
return sortSessionMemoriesByRecency(entries).slice(0, safeLimit);
|
|
118
|
+
}
|
|
119
|
+
function validSessionId(value) {
|
|
120
|
+
return typeof value === "string" && SESSION_ID_RE.test(value) && value !== "." && value !== "..";
|
|
121
|
+
}
|
|
122
|
+
function validSessionMemoryEntry(value) {
|
|
123
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
124
|
+
return false;
|
|
125
|
+
const entry = value;
|
|
126
|
+
return (validSessionId(entry.sessionId) &&
|
|
127
|
+
typeof entry.summary === "string" &&
|
|
128
|
+
entry.summary.length <= 100_000 &&
|
|
129
|
+
validMemoryStringList(entry.keyTopics) &&
|
|
130
|
+
validMemoryStringList(entry.decisions) &&
|
|
131
|
+
typeof entry.createdAt === "string" &&
|
|
132
|
+
entry.createdAt.length <= 64 &&
|
|
133
|
+
Number.isFinite(Date.parse(entry.createdAt)) &&
|
|
134
|
+
(entry.tokenCount === undefined ||
|
|
135
|
+
(Number.isSafeInteger(entry.tokenCount) && entry.tokenCount >= 0 && entry.tokenCount <= 10_000_000)));
|
|
136
|
+
}
|
|
137
|
+
function validMemoryStringList(value) {
|
|
138
|
+
return (Array.isArray(value) &&
|
|
139
|
+
value.length <= 1_000 &&
|
|
140
|
+
value.every((item) => typeof item === "string" && item.length <= 4_096 && !item.includes("\0")));
|
|
141
|
+
}
|
|
142
|
+
function assertSessionMemoryEntry(entry) {
|
|
143
|
+
if (!validSessionMemoryEntry(entry))
|
|
144
|
+
throw new Error("invalid session memory entry");
|
|
145
|
+
}
|
|
146
|
+
function ensureRealMemoryDirectory(dir) {
|
|
147
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
148
|
+
const info = lstatSync(dir);
|
|
149
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
150
|
+
throw new Error("session memory directory must be a real directory");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function assertSafeMemoryTarget(file) {
|
|
154
|
+
try {
|
|
155
|
+
const info = lstatSync(file);
|
|
156
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
157
|
+
throw new Error("session memory target must be a regular file");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code !== "ENOENT")
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
65
164
|
}
|
|
66
165
|
/**
|
|
67
166
|
* Search session memories by keyword.
|