@securecoms/agent-runtime 0.6.0 → 0.7.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/README.md +52 -29
- package/dist/bin.js +382 -232
- package/package.json +2 -2
- package/skills/remember/SKILL.md +0 -13
package/README.md
CHANGED
|
@@ -98,7 +98,7 @@ All configuration is via `SECURECOMS_*` environment variables (see
|
|
|
98
98
|
| `SECURECOMS_DATA_DIR` | `~/.securecoms/agent-runtime` | Root directory for per-channel state |
|
|
99
99
|
| `SECURECOMS_SKILLS_CONFIG` | `<dataDir>/skills.json` (or `--skills-config <path>`) | Path to `skills.json` — named endpoints, databases and flags |
|
|
100
100
|
| `SECURECOMS_LOG_LEVEL` | `info` | One of `debug`, `info`, `warn`, `error` |
|
|
101
|
-
| `SECURECOMS_TOOL_ALLOW` | `echo`, `web_search`, `fetch_url`, `http_request`, `sql_query`, `current_time
|
|
101
|
+
| `SECURECOMS_TOOL_ALLOW` | `echo`, `web_search`, `fetch_url`, `http_request`, `sql_query`, `current_time` | Comma-separated tool allow-list |
|
|
102
102
|
| `SECURECOMS_TOOL_DENY` | unset | Comma-separated tool deny-list (wins over allow) |
|
|
103
103
|
| `SECURECOMS_TOOL_DEFAULT` | `deny` | Fallback policy for a tool matched by neither list |
|
|
104
104
|
|
|
@@ -205,7 +205,7 @@ the line — for example `requires: [endpoint:crm]` in
|
|
|
205
205
|
|
|
206
206
|
## Curated skills
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
Six built-in handlers ship with the runtime. All network built-ins refuse
|
|
209
209
|
`localhost` and any private, loopback or link-local resolved address, and
|
|
210
210
|
every tool result is capped at 20 kB (truncated, not an error). A redirect to a
|
|
211
211
|
different origin is followed without the caller's headers and without the
|
|
@@ -220,7 +220,6 @@ to a host the operator did not name.
|
|
|
220
220
|
| `web_search` | `query`, `count` (1–10, default 5) | `requires: [search_provider]` — needs a readable, non-empty `SECURECOMS_BRAVE_KEY_FILE` or `SECURECOMS_TAVILY_KEY_FILE`; Brave is used first when both are set. |
|
|
221
221
|
| `http_request` | `name` (a configured `endpoint:<name>`), `path` (a query string is kept, a `#fragment` is dropped), `method` (GET/POST/PUT/DELETE), `body` | `requires: [any_endpoint]`. The model names an endpoint and a path, never a host; a path that would leave the endpoint's `baseUrl` origin is refused. |
|
|
222
222
|
| `sql_query` | `name` (a configured `database:<name>`), `sql` (read-only `SELECT` only; a `;` or a write keyword anywhere in the text — inside a string literal or a comment included — is refused) | `requires: [any_database]`. SQLite or Postgres (`pg` required separately for Postgres). Results capped at 200 rows. |
|
|
223
|
-
| `remember` | `fact` (≤300 chars) | Appends a dated line to the agent-wide `MEMORY.md`. |
|
|
224
223
|
|
|
225
224
|
## skills.json
|
|
226
225
|
|
|
@@ -283,12 +282,16 @@ file that already exists, so a customized `IDENTITY.md` is safe to keep
|
|
|
283
282
|
across a re-init. `securecoms-agent persona show` prints the assembled system
|
|
284
283
|
prompt exactly as a turn would see it, useful for checking an edit landed.
|
|
285
284
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
Legacy `MEMORY.md` files are not active configuration. Earlier runtime versions
|
|
286
|
+
injected one agent-wide file into every channel, which crossed channel
|
|
287
|
+
audiences. On startup the runtime moves each legacy file to a mode-0600
|
|
288
|
+
`MEMORY.quarantined*.md` file in the same directory without overwriting an
|
|
289
|
+
existing quarantine. The quarantined content is never injected or writable by
|
|
290
|
+
a tool. The runtime inventories that quarantine plus inactive per-channel JSON
|
|
291
|
+
stores for the owner-review surface. Review offers a passphrase-encrypted
|
|
292
|
+
export and exact-source deletion; the 90-day clock begins only after the
|
|
293
|
+
machine successfully reports the source. Explicit fact-to-channel assignment
|
|
294
|
+
is a separate migration step and never guesses or fans out an audience.
|
|
292
295
|
|
|
293
296
|
## Managed configuration (from the web app)
|
|
294
297
|
|
|
@@ -307,7 +310,7 @@ managed directory at all behaves exactly as it did before. `skills.json` is
|
|
|
307
310
|
looked up the same way, and re-read on every turn — a new endpoint or a skill
|
|
308
311
|
switched off in the app takes effect on the next message, with no restart.
|
|
309
312
|
|
|
310
|
-
|
|
313
|
+
One deliberate exception:
|
|
311
314
|
|
|
312
315
|
- **The environment still wins for the tool policy.** `SECURECOMS_TOOL_ALLOW`
|
|
313
316
|
overrides the `allow` list in the `tools` block of `skills.json`, so an
|
|
@@ -316,35 +319,55 @@ Two deliberate exceptions:
|
|
|
316
319
|
rather than replacing it: deny is a safety mechanism, so one entry in the
|
|
317
320
|
service unit must not silently un-deny every tool the owner switched off in
|
|
318
321
|
the app. Everything the environment denies stays denied either way.
|
|
319
|
-
- **Writes go to the data dir**, not the managed directory — except `MEMORY.md`,
|
|
320
|
-
which is written wherever the agent already keeps it. An existing
|
|
321
|
-
`<dataDir>/MEMORY.md` is copied into the managed directory once, at startup,
|
|
322
|
-
so the owner can read and prune it in the app.
|
|
323
322
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
whether it has a readable search key. It is written at startup and rewritten
|
|
327
|
-
whenever the search-key answer changes, so a key file dropped in (or taken
|
|
328
|
-
away) mid-run is reported without a restart. It is a report the daemon passes
|
|
329
|
-
upward — nothing in the runtime reads it back, and it holds no key value.
|
|
323
|
+
Runtime-owned writes go to the data directory, not the managed directory.
|
|
324
|
+
Legacy `MEMORY.md` is handled only by the quarantine behavior described above.
|
|
330
325
|
|
|
331
|
-
|
|
326
|
+
The runtime also writes mode-0600 `local-config.json` into the managed directory
|
|
327
|
+
when one exists: what this agent's OWN persona files say, booleans for readable
|
|
328
|
+
search/managed-LLM keys, and a bounded inventory of inert legacy-memory files.
|
|
329
|
+
It is written at startup and rewritten whenever those answers or the inventory
|
|
330
|
+
change. The daemon revalidates current regular-file bytes, removes local paths,
|
|
331
|
+
and only then reports it upward. Nothing in the runtime reads the report back,
|
|
332
|
+
and it holds no key value.
|
|
333
|
+
|
|
334
|
+
### Per-turn channel authorization
|
|
335
|
+
|
|
336
|
+
Installation configuration is only a ceiling. On every inbound message the
|
|
337
|
+
bridge supplies an immutable, server-derived authorization claim containing
|
|
338
|
+
the sender audience, membership state, and channel tool grants. The runtime
|
|
339
|
+
intersects that claim with installed skills, configuration requirements, and
|
|
340
|
+
the local allow/deny gate before showing tools to the model, then repeats the
|
|
341
|
+
same channel-grant check immediately before execution. Missing or malformed
|
|
342
|
+
claims, inactive/unknown membership, and absent grants all fail closed.
|
|
343
|
+
|
|
344
|
+
This staged release issues no channel grants yet, so no generic tool is offered
|
|
345
|
+
even when it is installed and enabled. Read grants can enable only the curated
|
|
346
|
+
read-only tools; `http_request` is additionally limited to GET at execution.
|
|
347
|
+
Side-effect grant fields are reserved in the protocol, but side-effect calls
|
|
348
|
+
remain denied until exact-operation confirmation, expiry, and idempotency are
|
|
349
|
+
implemented end to end.
|
|
350
|
+
|
|
351
|
+
### The generated tool paragraph is an installation summary
|
|
332
352
|
|
|
333
353
|
The `AGENTS.md` the app delivers ends in a generated paragraph naming the tools
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
354
|
+
configured for the installation, and explicitly tells the model that the
|
|
355
|
+
per-turn offered list is authoritative. The backend derives the installation
|
|
356
|
+
list from saved configuration plus the search-key boolean above; it does not
|
|
357
|
+
see the running process or a turn's channel grants. These things can therefore
|
|
358
|
+
make the installation summary differ from what the runtime offers:
|
|
337
359
|
|
|
338
360
|
- a `SECURECOMS_TOOL_ALLOW` or `SECURECOMS_TOOL_DENY` set in the service unit,
|
|
339
361
|
which the backend cannot see;
|
|
340
362
|
- a customer skill folder whose `requires:` is narrower than the shipped one, so
|
|
341
363
|
the runtime withholds a tool the backend counted;
|
|
342
|
-
- a custom skill the backend knows nothing about, so it
|
|
364
|
+
- a custom skill the backend knows nothing about, so it can be eligible but
|
|
365
|
+
unnamed;
|
|
366
|
+
- the current channel has no matching server-issued read grant.
|
|
343
367
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
a wrong permission.
|
|
368
|
+
The paragraph is therefore an advisory configuration summary, not an authority
|
|
369
|
+
claim. Channel authorization and the local gate are both enforced at call
|
|
370
|
+
time, so a configured but ungranted or denied tool is still refused.
|
|
348
371
|
|
|
349
372
|
## Provenance
|
|
350
373
|
|
package/dist/bin.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
5
5
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
6
|
-
import { dirname as dirname4, join as
|
|
6
|
+
import { dirname as dirname4, join as join8 } from "node:path";
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
-
import { accessSync, constants, existsSync as
|
|
8
|
+
import { accessSync, constants, existsSync as existsSync6, mkdirSync, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
9
9
|
import { homedir as homedir4, userInfo } from "node:os";
|
|
10
10
|
|
|
11
11
|
// src/config.ts
|
|
@@ -57,8 +57,7 @@ var DEFAULT_TOOL_ALLOW = [
|
|
|
57
57
|
"fetch_url",
|
|
58
58
|
"http_request",
|
|
59
59
|
"sql_query",
|
|
60
|
-
"current_time"
|
|
61
|
-
"remember"
|
|
60
|
+
"current_time"
|
|
62
61
|
];
|
|
63
62
|
function loadConfig(argv, env) {
|
|
64
63
|
const identityId = parseIdentity(argv, env);
|
|
@@ -473,8 +472,8 @@ var LLMDispatcher = class {
|
|
|
473
472
|
body: JSON.stringify(body)
|
|
474
473
|
});
|
|
475
474
|
if (!resp.ok) {
|
|
476
|
-
|
|
477
|
-
throw new Error(`LLM request failed (${resp.status})
|
|
475
|
+
await resp.body?.cancel();
|
|
476
|
+
throw new Error(`LLM request failed (${resp.status})`);
|
|
478
477
|
}
|
|
479
478
|
const data = await resp.json();
|
|
480
479
|
const choice = data.choices?.[0];
|
|
@@ -692,116 +691,59 @@ function bootLine(cfg, explicit, deps) {
|
|
|
692
691
|
}
|
|
693
692
|
|
|
694
693
|
// src/memory.ts
|
|
695
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
696
|
-
import { join as join3 } from "node:path";
|
|
697
694
|
var MAX_HISTORY = 50;
|
|
695
|
+
var MAX_FACTS = 200;
|
|
698
696
|
var AgentMemory = class {
|
|
699
697
|
history = [];
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
698
|
+
facts = [];
|
|
699
|
+
constructor(snapshot) {
|
|
700
|
+
if (snapshot) this.replace(snapshot);
|
|
701
|
+
}
|
|
702
|
+
/** Replace the working view at the start of every authenticated turn. */
|
|
703
|
+
replace(snapshot) {
|
|
704
|
+
this.history = snapshot.recent.slice(-MAX_HISTORY).map((turn) => ({
|
|
705
|
+
role: turn.role === "peer" ? "user" : "assistant",
|
|
706
|
+
content: turn.text
|
|
707
|
+
}));
|
|
708
|
+
this.facts = snapshot.facts.slice(-MAX_FACTS);
|
|
709
|
+
}
|
|
710
|
+
/** Add a message to this turn's in-process history only. */
|
|
708
711
|
addMessage(msg) {
|
|
709
712
|
this.history.push(msg);
|
|
710
713
|
if (this.history.length > MAX_HISTORY * 2) {
|
|
711
714
|
this.history = this.history.slice(-MAX_HISTORY);
|
|
712
715
|
}
|
|
713
|
-
this.saveHistory();
|
|
714
716
|
}
|
|
715
717
|
/** Get recent conversation history for LLM context. */
|
|
716
718
|
getHistory() {
|
|
717
719
|
return this.history.slice(-MAX_HISTORY);
|
|
718
720
|
}
|
|
719
|
-
/** Clear
|
|
721
|
+
/** Clear this process-local view; durable deletion belongs to the bridge. */
|
|
720
722
|
clear() {
|
|
721
723
|
this.history = [];
|
|
722
|
-
this.
|
|
724
|
+
this.facts = [];
|
|
723
725
|
}
|
|
724
|
-
/**
|
|
725
|
-
* Recall relevant memories to prepend to the LLM context.
|
|
726
|
-
* Case-insensitive keyword-overlap match over captured entries: query is
|
|
727
|
-
* split into words of length >= 3, entries are scored by number of
|
|
728
|
-
* matching words, and the top 5 with score > 0 are returned.
|
|
729
|
-
*/
|
|
726
|
+
/** Case-insensitive keyword-overlap recall over protected channel facts. */
|
|
730
727
|
async recall(query) {
|
|
731
|
-
const queryWords = query.toLowerCase().split(/\W+/).filter((
|
|
728
|
+
const queryWords = query.toLowerCase().split(/\W+/).filter((word) => word.length >= 3);
|
|
732
729
|
if (queryWords.length === 0) return null;
|
|
733
|
-
const scored = this.
|
|
734
|
-
const contentLower =
|
|
730
|
+
const scored = this.facts.map((content) => {
|
|
731
|
+
const contentLower = content.toLowerCase();
|
|
735
732
|
const score = queryWords.reduce(
|
|
736
|
-
(acc,
|
|
733
|
+
(acc, word) => contentLower.includes(word) ? acc + 1 : acc,
|
|
737
734
|
0
|
|
738
735
|
);
|
|
739
|
-
return {
|
|
740
|
-
}).filter((
|
|
736
|
+
return { content, score };
|
|
737
|
+
}).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
|
|
741
738
|
if (scored.length === 0) return null;
|
|
742
|
-
const memories = scored.map((s) => `- ${s.memory.content}`).join("\n");
|
|
743
739
|
return `Relevant context from memory:
|
|
744
|
-
${
|
|
745
|
-
}
|
|
746
|
-
/**
|
|
747
|
-
* Capture a notable fact to local memory (best-effort).
|
|
748
|
-
*/
|
|
749
|
-
async capture(content, kind) {
|
|
750
|
-
try {
|
|
751
|
-
this.memories.push({
|
|
752
|
-
content,
|
|
753
|
-
category: kind,
|
|
754
|
-
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
755
|
-
});
|
|
756
|
-
this.saveMemories();
|
|
757
|
-
} catch {
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
get historyPath() {
|
|
761
|
-
return join3(this.dataDir, "conversation-history.json");
|
|
762
|
-
}
|
|
763
|
-
get memoriesPath() {
|
|
764
|
-
return join3(this.dataDir, "memories.json");
|
|
765
|
-
}
|
|
766
|
-
loadHistory() {
|
|
767
|
-
try {
|
|
768
|
-
if (existsSync(this.historyPath)) {
|
|
769
|
-
const raw = readFileSync(this.historyPath, "utf-8");
|
|
770
|
-
this.history = JSON.parse(raw);
|
|
771
|
-
}
|
|
772
|
-
} catch {
|
|
773
|
-
this.history = [];
|
|
774
|
-
}
|
|
775
|
-
}
|
|
776
|
-
saveHistory() {
|
|
777
|
-
try {
|
|
778
|
-
mkdirSync(this.dataDir, { recursive: true });
|
|
779
|
-
writeFileSync(this.historyPath, JSON.stringify(this.history));
|
|
780
|
-
} catch {
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
loadMemories() {
|
|
784
|
-
try {
|
|
785
|
-
if (existsSync(this.memoriesPath)) {
|
|
786
|
-
const raw = readFileSync(this.memoriesPath, "utf-8");
|
|
787
|
-
this.memories = JSON.parse(raw);
|
|
788
|
-
}
|
|
789
|
-
} catch {
|
|
790
|
-
this.memories = [];
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
saveMemories() {
|
|
794
|
-
try {
|
|
795
|
-
mkdirSync(this.dataDir, { recursive: true });
|
|
796
|
-
writeFileSync(this.memoriesPath, JSON.stringify(this.memories));
|
|
797
|
-
} catch {
|
|
798
|
-
}
|
|
740
|
+
${scored.map((entry) => `- ${entry.content}`).join("\n")}`;
|
|
799
741
|
}
|
|
800
742
|
};
|
|
801
743
|
|
|
802
744
|
// src/skill-invoker.ts
|
|
803
|
-
import { readFileSync as
|
|
804
|
-
import { join as
|
|
745
|
+
import { readFileSync as readFileSync4, readdirSync, existsSync as existsSync3, statSync } from "node:fs";
|
|
746
|
+
import { join as join4 } from "node:path";
|
|
805
747
|
|
|
806
748
|
// src/skill-md.ts
|
|
807
749
|
var SkillMdError = class extends Error {
|
|
@@ -956,23 +898,23 @@ function resolveRequires(req, env) {
|
|
|
956
898
|
}
|
|
957
899
|
|
|
958
900
|
// src/skills-config.ts
|
|
959
|
-
import { existsSync as
|
|
901
|
+
import { existsSync as existsSync2, readFileSync } from "node:fs";
|
|
960
902
|
import { isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
|
|
961
903
|
|
|
962
904
|
// src/config-dirs.ts
|
|
963
|
-
import { existsSync
|
|
905
|
+
import { existsSync } from "node:fs";
|
|
964
906
|
import { homedir as homedir2 } from "node:os";
|
|
965
|
-
import { join as
|
|
907
|
+
import { join as join3 } from "node:path";
|
|
966
908
|
function managedConfigDir(identityId, home = homedir2()) {
|
|
967
|
-
return
|
|
909
|
+
return join3(home, ".securecoms", "runtime-config", identityId);
|
|
968
910
|
}
|
|
969
911
|
function configSearchPath(identityId, dataDir, home = homedir2()) {
|
|
970
912
|
return [managedConfigDir(identityId, home), dataDir];
|
|
971
913
|
}
|
|
972
914
|
function findConfigFile(name, dirs) {
|
|
973
915
|
for (const dir of dirs) {
|
|
974
|
-
const path =
|
|
975
|
-
if (
|
|
916
|
+
const path = join3(dir, name);
|
|
917
|
+
if (existsSync(path)) return path;
|
|
976
918
|
}
|
|
977
919
|
return null;
|
|
978
920
|
}
|
|
@@ -982,10 +924,10 @@ var EMPTY_SKILLS_CONFIG = { endpoints: {}, databases: {}, flags: [] };
|
|
|
982
924
|
var empty = () => ({ endpoints: {}, databases: {}, flags: [] });
|
|
983
925
|
var abs2 = (p, dataDir) => isAbsolute2(p) ? p : resolve2(dataDir, p);
|
|
984
926
|
function loadSkillsConfigFile(path, dataDir) {
|
|
985
|
-
if (!
|
|
927
|
+
if (!existsSync2(path)) return empty();
|
|
986
928
|
let raw;
|
|
987
929
|
try {
|
|
988
|
-
raw = JSON.parse(
|
|
930
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
989
931
|
} catch (e) {
|
|
990
932
|
console.warn(`[skills-config] ignoring ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
991
933
|
return empty();
|
|
@@ -1240,7 +1182,7 @@ var fetchUrl = async (args, ctx) => {
|
|
|
1240
1182
|
};
|
|
1241
1183
|
|
|
1242
1184
|
// src/builtins/http-request.ts
|
|
1243
|
-
import { readFileSync as
|
|
1185
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1244
1186
|
var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "DELETE"]);
|
|
1245
1187
|
var httpRequest = async (args, ctx) => {
|
|
1246
1188
|
const name = typeof args.name === "string" ? args.name : "";
|
|
@@ -1283,7 +1225,7 @@ var httpRequest = async (args, ctx) => {
|
|
|
1283
1225
|
const headers = { accept: "application/json" };
|
|
1284
1226
|
if (endpoint.headersFile) {
|
|
1285
1227
|
try {
|
|
1286
|
-
for (const [k, v] of Object.entries(JSON.parse(
|
|
1228
|
+
for (const [k, v] of Object.entries(JSON.parse(readFileSync2(endpoint.headersFile, "utf8")))) {
|
|
1287
1229
|
if (typeof v === "string") headers[k] = v;
|
|
1288
1230
|
}
|
|
1289
1231
|
} catch {
|
|
@@ -1308,59 +1250,8 @@ var httpRequest = async (args, ctx) => {
|
|
|
1308
1250
|
}
|
|
1309
1251
|
};
|
|
1310
1252
|
|
|
1311
|
-
// src/long-term-memory.ts
|
|
1312
|
-
import { copyFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1313
|
-
import { join as join5 } from "node:path";
|
|
1314
|
-
var MEMORY_FILE = "MEMORY.md";
|
|
1315
|
-
var MEMORY_MAX_BYTES = 8192;
|
|
1316
|
-
var FACT_MAX_CHARS = 300;
|
|
1317
|
-
function readLongTermMemory(dirs) {
|
|
1318
|
-
const p = findConfigFile(MEMORY_FILE, dirs);
|
|
1319
|
-
if (!p) return null;
|
|
1320
|
-
const body = readFileSync4(p, "utf8").trim();
|
|
1321
|
-
return body || null;
|
|
1322
|
-
}
|
|
1323
|
-
function memoryTarget(dirs) {
|
|
1324
|
-
for (const d of dirs) if (existsSync4(join5(d, MEMORY_FILE))) return d;
|
|
1325
|
-
for (const d of dirs) if (existsSync4(d)) return d;
|
|
1326
|
-
return dirs[dirs.length - 1];
|
|
1327
|
-
}
|
|
1328
|
-
function migrateMemoryIntoManaged(managedDir, dataDir) {
|
|
1329
|
-
const managed = join5(managedDir, MEMORY_FILE);
|
|
1330
|
-
const local = join5(dataDir, MEMORY_FILE);
|
|
1331
|
-
if (!existsSync4(managedDir) || existsSync4(managed) || !existsSync4(local)) return false;
|
|
1332
|
-
copyFileSync(local, managed);
|
|
1333
|
-
return true;
|
|
1334
|
-
}
|
|
1335
|
-
function rememberFact(dirs, fact, now) {
|
|
1336
|
-
const clean = fact.replace(/\s+/g, " ").trim().slice(0, FACT_MAX_CHARS);
|
|
1337
|
-
if (!clean) throw new Error("remember needs a non-empty fact");
|
|
1338
|
-
const line = `- ${now.toISOString().slice(0, 10)} ${clean}`;
|
|
1339
|
-
const dir = memoryTarget(dirs);
|
|
1340
|
-
mkdirSync2(dir, { recursive: true });
|
|
1341
|
-
const existing = readLongTermMemory(dirs);
|
|
1342
|
-
let lines = existing ? existing.split("\n") : [];
|
|
1343
|
-
lines.push(line);
|
|
1344
|
-
while (Buffer.byteLength(`${lines.join("\n")}
|
|
1345
|
-
`, "utf8") > MEMORY_MAX_BYTES && lines.length > 1) {
|
|
1346
|
-
lines = lines.slice(1);
|
|
1347
|
-
}
|
|
1348
|
-
writeFileSync2(join5(dir, MEMORY_FILE), `${lines.join("\n")}
|
|
1349
|
-
`, { mode: 384 });
|
|
1350
|
-
return line;
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
// src/builtins/remember.ts
|
|
1354
|
-
var remember = async (args, ctx) => {
|
|
1355
|
-
try {
|
|
1356
|
-
return JSON.stringify({ remembered: rememberFact(ctx.configDirs, String(args.fact ?? ""), ctx.now()) });
|
|
1357
|
-
} catch (e) {
|
|
1358
|
-
return toolError(e instanceof Error ? e.message : String(e));
|
|
1359
|
-
}
|
|
1360
|
-
};
|
|
1361
|
-
|
|
1362
1253
|
// src/builtins/sql-query.ts
|
|
1363
|
-
import { readFileSync as
|
|
1254
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
1364
1255
|
|
|
1365
1256
|
// src/sql-guard.ts
|
|
1366
1257
|
var SqlNotReadOnlyError = class extends Error {
|
|
@@ -1461,7 +1352,7 @@ var sqlQuery = async (args, ctx) => {
|
|
|
1461
1352
|
const Client = pg.Client ?? pg.default?.Client;
|
|
1462
1353
|
if (!Client) return toolError(`the installed "pg" module does not export a Client`);
|
|
1463
1354
|
const client = new Client({
|
|
1464
|
-
connectionString:
|
|
1355
|
+
connectionString: readFileSync3(db.postgresUrlFile, "utf8").trim(),
|
|
1465
1356
|
// never logged (D4)
|
|
1466
1357
|
statement_timeout: SQL_TIMEOUT_MS
|
|
1467
1358
|
});
|
|
@@ -1573,8 +1464,7 @@ var BUILTINS = {
|
|
|
1573
1464
|
fetch_url: fetchUrl,
|
|
1574
1465
|
web_search: webSearch,
|
|
1575
1466
|
http_request: httpRequest,
|
|
1576
|
-
sql_query: sqlQuery
|
|
1577
|
-
remember
|
|
1467
|
+
sql_query: sqlQuery
|
|
1578
1468
|
};
|
|
1579
1469
|
|
|
1580
1470
|
// src/skill-invoker.ts
|
|
@@ -1600,19 +1490,19 @@ var SkillInvoker = class {
|
|
|
1600
1490
|
* `<skillsDir>/<subdir>/manifest.json`.
|
|
1601
1491
|
*/
|
|
1602
1492
|
loadSkills() {
|
|
1603
|
-
if (!
|
|
1493
|
+
if (!existsSync3(this.skillsDir)) {
|
|
1604
1494
|
console.warn(`[SkillInvoker] Skills directory not found: ${this.skillsDir}`);
|
|
1605
1495
|
return;
|
|
1606
1496
|
}
|
|
1607
1497
|
const flat = [], manifests = [], skillMds = [];
|
|
1608
1498
|
for (const entry of readdirSync(this.skillsDir, { withFileTypes: true })) {
|
|
1609
1499
|
if (entry.isFile() && entry.name.endsWith(".json")) {
|
|
1610
|
-
flat.push(
|
|
1500
|
+
flat.push(join4(this.skillsDir, entry.name));
|
|
1611
1501
|
} else if (entry.isDirectory()) {
|
|
1612
|
-
const m =
|
|
1613
|
-
if (
|
|
1614
|
-
const s =
|
|
1615
|
-
if (
|
|
1502
|
+
const m = join4(this.skillsDir, entry.name, "manifest.json");
|
|
1503
|
+
if (existsSync3(m) && statSync(m).isFile()) manifests.push(m);
|
|
1504
|
+
const s = join4(this.skillsDir, entry.name, "SKILL.md");
|
|
1505
|
+
if (existsSync3(s) && statSync(s).isFile()) skillMds.push(s);
|
|
1616
1506
|
}
|
|
1617
1507
|
}
|
|
1618
1508
|
for (const p of [...flat.sort(), ...manifests.sort()]) this.loadManifestFile(p);
|
|
@@ -1628,14 +1518,14 @@ var SkillInvoker = class {
|
|
|
1628
1518
|
}
|
|
1629
1519
|
loadSkillMdFile(path) {
|
|
1630
1520
|
try {
|
|
1631
|
-
this.register(parseSkillMd(
|
|
1521
|
+
this.register(parseSkillMd(readFileSync4(path, "utf-8"), path), path);
|
|
1632
1522
|
} catch (err) {
|
|
1633
1523
|
console.warn(`[SkillInvoker] Skipping ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1634
1524
|
}
|
|
1635
1525
|
}
|
|
1636
1526
|
loadManifestFile(path) {
|
|
1637
1527
|
try {
|
|
1638
|
-
const raw =
|
|
1528
|
+
const raw = readFileSync4(path, "utf-8");
|
|
1639
1529
|
const manifest = JSON.parse(raw);
|
|
1640
1530
|
if (typeof manifest.handler !== "string" || !(manifest.handler.startsWith("builtin:") || manifest.handler.startsWith("script:"))) {
|
|
1641
1531
|
console.warn(
|
|
@@ -1798,10 +1688,78 @@ function createDynamicToolGate(policyFor) {
|
|
|
1798
1688
|
}
|
|
1799
1689
|
|
|
1800
1690
|
// src/data-plane.ts
|
|
1801
|
-
import { readFileSync as
|
|
1691
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
1802
1692
|
import { WebSocket } from "node:http";
|
|
1803
1693
|
import { homedir as homedir3 } from "node:os";
|
|
1804
|
-
import { join as
|
|
1694
|
+
import { join as join5 } from "node:path";
|
|
1695
|
+
var NO_TURN_MEMORY = Object.freeze({
|
|
1696
|
+
recent: Object.freeze([]),
|
|
1697
|
+
facts: Object.freeze([])
|
|
1698
|
+
});
|
|
1699
|
+
function parseTurnMemory(value) {
|
|
1700
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_MEMORY;
|
|
1701
|
+
const raw = value;
|
|
1702
|
+
if (!Array.isArray(raw.recent) || !Array.isArray(raw.facts)) return NO_TURN_MEMORY;
|
|
1703
|
+
const recent = [];
|
|
1704
|
+
for (const value2 of raw.recent.slice(-50)) {
|
|
1705
|
+
if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_MEMORY;
|
|
1706
|
+
const turn = value2;
|
|
1707
|
+
if (turn.role !== "peer" && turn.role !== "agent" || typeof turn.text !== "string") {
|
|
1708
|
+
return NO_TURN_MEMORY;
|
|
1709
|
+
}
|
|
1710
|
+
recent.push(Object.freeze({ role: turn.role, text: turn.text }));
|
|
1711
|
+
}
|
|
1712
|
+
const facts = [];
|
|
1713
|
+
for (const fact of raw.facts.slice(-200)) {
|
|
1714
|
+
if (typeof fact !== "string") return NO_TURN_MEMORY;
|
|
1715
|
+
facts.push(fact);
|
|
1716
|
+
}
|
|
1717
|
+
return Object.freeze({ recent: Object.freeze(recent), facts: Object.freeze(facts) });
|
|
1718
|
+
}
|
|
1719
|
+
var AUDIENCES = /* @__PURE__ */ new Set([
|
|
1720
|
+
"owner_dm",
|
|
1721
|
+
"team_room",
|
|
1722
|
+
"partner_room",
|
|
1723
|
+
"other_human_dm",
|
|
1724
|
+
"agent_to_agent",
|
|
1725
|
+
"unknown"
|
|
1726
|
+
]);
|
|
1727
|
+
var NO_TURN_AUTHORIZATION = Object.freeze({
|
|
1728
|
+
version: 1,
|
|
1729
|
+
audience: "unknown",
|
|
1730
|
+
sender_membership: "unknown",
|
|
1731
|
+
tool_grants: Object.freeze([])
|
|
1732
|
+
});
|
|
1733
|
+
function parseTurnAuthorization(value) {
|
|
1734
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return NO_TURN_AUTHORIZATION;
|
|
1735
|
+
const raw = value;
|
|
1736
|
+
if (raw.version !== 1 || typeof raw.audience !== "string" || !AUDIENCES.has(raw.audience) || raw.sender_membership !== "active" && raw.sender_membership !== "unknown" || !Array.isArray(raw.tool_grants)) return NO_TURN_AUTHORIZATION;
|
|
1737
|
+
const grants = [];
|
|
1738
|
+
for (const value2 of raw.tool_grants) {
|
|
1739
|
+
if (!value2 || typeof value2 !== "object" || Array.isArray(value2)) return NO_TURN_AUTHORIZATION;
|
|
1740
|
+
const grant = value2;
|
|
1741
|
+
if (typeof grant.tool !== "string" || !grant.tool || grant.tool.length > 100) return NO_TURN_AUTHORIZATION;
|
|
1742
|
+
if (grant.mode === "read") {
|
|
1743
|
+
grants.push(Object.freeze({ tool: grant.tool, mode: "read" }));
|
|
1744
|
+
continue;
|
|
1745
|
+
}
|
|
1746
|
+
if (grant.mode !== "side_effect" || typeof grant.operation_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(grant.operation_sha256) || typeof grant.confirmation_id !== "string" || !grant.confirmation_id || typeof grant.idempotency_key !== "string" || !grant.idempotency_key || typeof grant.expires_at !== "string" || !grant.expires_at) return NO_TURN_AUTHORIZATION;
|
|
1747
|
+
grants.push(Object.freeze({
|
|
1748
|
+
tool: grant.tool,
|
|
1749
|
+
mode: "side_effect",
|
|
1750
|
+
operation_sha256: grant.operation_sha256,
|
|
1751
|
+
confirmation_id: grant.confirmation_id,
|
|
1752
|
+
idempotency_key: grant.idempotency_key,
|
|
1753
|
+
expires_at: grant.expires_at
|
|
1754
|
+
}));
|
|
1755
|
+
}
|
|
1756
|
+
return Object.freeze({
|
|
1757
|
+
version: 1,
|
|
1758
|
+
audience: raw.audience,
|
|
1759
|
+
sender_membership: raw.sender_membership,
|
|
1760
|
+
tool_grants: Object.freeze(grants)
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1805
1763
|
var SEEN_MAX = 5e3;
|
|
1806
1764
|
function errText(e) {
|
|
1807
1765
|
return e instanceof Error ? e.message : String(e);
|
|
@@ -1844,8 +1802,8 @@ var SecureComsDataPlane = class {
|
|
|
1844
1802
|
generation = 0;
|
|
1845
1803
|
constructor(cfg, onMessage) {
|
|
1846
1804
|
this.identityId = cfg.identityId;
|
|
1847
|
-
const dir = cfg.connectionsDir ??
|
|
1848
|
-
this.connectionPath =
|
|
1805
|
+
const dir = cfg.connectionsDir ?? join5(homedir3(), ".securecoms", "connections");
|
|
1806
|
+
this.connectionPath = join5(dir, `${cfg.identityId}.json`);
|
|
1849
1807
|
this.establishedMs = cfg.establishedMs ?? 5e3;
|
|
1850
1808
|
this.initialDelayMs = cfg.initialDelayMs ?? 250;
|
|
1851
1809
|
this.maxDelayMs = cfg.maxDelayMs ?? 3e4;
|
|
@@ -1948,9 +1906,9 @@ var SecureComsDataPlane = class {
|
|
|
1948
1906
|
if (this.generation === generation) this.running = false;
|
|
1949
1907
|
}
|
|
1950
1908
|
async attemptOnce() {
|
|
1951
|
-
const conn = JSON.parse(
|
|
1909
|
+
const conn = JSON.parse(readFileSync5(this.connectionPath, "utf8"));
|
|
1952
1910
|
const httpBase = conn.bridge_url.replace(/^ws/, "http").replace(/\/v1\/ws$/, "");
|
|
1953
|
-
const token =
|
|
1911
|
+
const token = readFileSync5(conn.token_file, "utf8").trim();
|
|
1954
1912
|
const health = await (await fetch(`${httpBase}/healthz`)).json();
|
|
1955
1913
|
if (conn.identity_id !== health.identity_id || this.identityId !== health.identity_id) {
|
|
1956
1914
|
throw new Error(
|
|
@@ -2026,10 +1984,17 @@ var SecureComsDataPlane = class {
|
|
|
2026
1984
|
text: String(frame.text ?? ""),
|
|
2027
1985
|
senderId: String(frame.sender_id ?? ""),
|
|
2028
1986
|
channelId,
|
|
2029
|
-
messageId
|
|
1987
|
+
messageId,
|
|
1988
|
+
authorization: parseTurnAuthorization(frame.authorization),
|
|
1989
|
+
memory: parseTurnMemory(frame.memory)
|
|
2030
1990
|
});
|
|
2031
1991
|
if (reply != null) {
|
|
2032
|
-
ws.send(JSON.stringify({
|
|
1992
|
+
ws.send(JSON.stringify({
|
|
1993
|
+
type: "send",
|
|
1994
|
+
channel_id: channelId,
|
|
1995
|
+
text: String(reply),
|
|
1996
|
+
in_reply_to: messageId
|
|
1997
|
+
}));
|
|
2033
1998
|
}
|
|
2034
1999
|
}
|
|
2035
2000
|
} catch (e) {
|
|
@@ -2074,6 +2039,33 @@ async function runToolCalls(toolCalls, skills, context) {
|
|
|
2074
2039
|
// src/turn-loop.ts
|
|
2075
2040
|
var DEFAULT_SYSTEM_PROMPT = "You are a SecureComs agent. Respond helpfully and concisely.";
|
|
2076
2041
|
var DEFAULT_MAX_ITERATIONS = 5;
|
|
2042
|
+
var READ_ONLY_TOOLS = /* @__PURE__ */ new Set(["echo", "web_search", "fetch_url", "sql_query", "current_time"]);
|
|
2043
|
+
function hasReadGrant(auth, toolName) {
|
|
2044
|
+
return auth.sender_membership === "active" && auth.tool_grants.some((grant) => grant.tool === toolName && grant.mode === "read");
|
|
2045
|
+
}
|
|
2046
|
+
function toolIsOffered(auth, toolName) {
|
|
2047
|
+
return hasReadGrant(auth, toolName) && (READ_ONLY_TOOLS.has(toolName) || toolName === "http_request");
|
|
2048
|
+
}
|
|
2049
|
+
function authorizeToolExecution(auth, toolName, argumentsJson, offeredNames) {
|
|
2050
|
+
if (auth.sender_membership !== "active") {
|
|
2051
|
+
return { allowed: false, reason: "Sender membership is not active" };
|
|
2052
|
+
}
|
|
2053
|
+
if (!offeredNames.has(toolName) || !toolIsOffered(auth, toolName)) {
|
|
2054
|
+
return { allowed: false, reason: `Tool "${toolName}" has no active channel read grant` };
|
|
2055
|
+
}
|
|
2056
|
+
if (toolName === "http_request") {
|
|
2057
|
+
try {
|
|
2058
|
+
const args = JSON.parse(argumentsJson);
|
|
2059
|
+
const method = typeof args.method === "string" ? args.method.toUpperCase() : "GET";
|
|
2060
|
+
if (method !== "GET") {
|
|
2061
|
+
return { allowed: false, reason: "State-changing HTTP calls require an exact confirmed operation grant" };
|
|
2062
|
+
}
|
|
2063
|
+
} catch {
|
|
2064
|
+
return { allowed: false, reason: "Tool arguments are not valid JSON" };
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
return { allowed: true, reason: `Tool "${toolName}" has an active channel read grant` };
|
|
2068
|
+
}
|
|
2077
2069
|
function buildHandler(deps) {
|
|
2078
2070
|
const maxIterations = deps.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
|
2079
2071
|
const log = deps.log ?? (() => {
|
|
@@ -2082,18 +2074,20 @@ function buildHandler(deps) {
|
|
|
2082
2074
|
return async (m) => {
|
|
2083
2075
|
const text = m.text?.trim() ? m.text : "";
|
|
2084
2076
|
if (!text) return null;
|
|
2085
|
-
log(
|
|
2077
|
+
log(
|
|
2078
|
+
"info",
|
|
2079
|
+
`message received (sender=${m.senderId.slice(0, 8)}, channel=${m.channelId.slice(0, 8)}, chars=${text.length})`
|
|
2080
|
+
);
|
|
2086
2081
|
const memory = memoryFor(m.channelId);
|
|
2082
|
+
memory.replace(m.memory);
|
|
2087
2083
|
const messages = [{ role: "system", content: deps.systemPromptFor() }];
|
|
2088
2084
|
const recalled = await memory.recall(text);
|
|
2089
2085
|
if (recalled) messages.push({ role: "system", content: recalled });
|
|
2090
|
-
const longTerm = deps.longTermMemory?.() ?? null;
|
|
2091
|
-
if (longTerm) messages.push({ role: "system", content: `Things you have remembered:
|
|
2092
|
-
${longTerm}` });
|
|
2093
2086
|
messages.push(...memory.getHistory());
|
|
2094
2087
|
messages.push({ role: "user", content: text });
|
|
2095
2088
|
memory.addMessage({ role: "user", content: text });
|
|
2096
|
-
const tools = deps.tools();
|
|
2089
|
+
const tools = deps.tools().filter((tool) => toolIsOffered(m.authorization, tool.function.name));
|
|
2090
|
+
const offeredNames = new Set(tools.map((tool) => tool.function.name));
|
|
2097
2091
|
let response = null;
|
|
2098
2092
|
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
2099
2093
|
const result = await llm.complete(messages, tools.length > 0 ? tools : void 0);
|
|
@@ -2106,10 +2100,18 @@ ${longTerm}` });
|
|
|
2106
2100
|
content: result.content ?? "",
|
|
2107
2101
|
tool_calls: result.toolCalls
|
|
2108
2102
|
});
|
|
2109
|
-
const verdicts = result.toolCalls.map((call) =>
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2103
|
+
const verdicts = result.toolCalls.map((call) => {
|
|
2104
|
+
const channelVerdict = authorizeToolExecution(
|
|
2105
|
+
m.authorization,
|
|
2106
|
+
call.function.name,
|
|
2107
|
+
call.function.arguments,
|
|
2108
|
+
offeredNames
|
|
2109
|
+
);
|
|
2110
|
+
return {
|
|
2111
|
+
call,
|
|
2112
|
+
verdict: channelVerdict.allowed ? gate.gate(call.function.name) : channelVerdict
|
|
2113
|
+
};
|
|
2114
|
+
});
|
|
2113
2115
|
const allowed = verdicts.filter((v) => v.verdict.allowed).map((v) => v.call);
|
|
2114
2116
|
for (const v of verdicts) {
|
|
2115
2117
|
if (!v.verdict.allowed) {
|
|
@@ -2144,25 +2146,168 @@ ${longTerm}` });
|
|
|
2144
2146
|
return null;
|
|
2145
2147
|
}
|
|
2146
2148
|
memory.addMessage({ role: "assistant", content: response });
|
|
2147
|
-
|
|
2148
|
-
try {
|
|
2149
|
-
await memory.capture(
|
|
2150
|
-
`User asked: "${text.slice(0, 100)}". Agent responded: "${response.slice(0, 200)}"`,
|
|
2151
|
-
"conversation"
|
|
2152
|
-
);
|
|
2153
|
-
} catch (e) {
|
|
2154
|
-
log("warn", `memory capture failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
2155
|
-
}
|
|
2156
|
-
}
|
|
2157
|
-
log("info", `response: ${response.slice(0, 80)}`);
|
|
2149
|
+
log("info", `response ready (chars=${response.length})`);
|
|
2158
2150
|
return response;
|
|
2159
2151
|
};
|
|
2160
2152
|
}
|
|
2161
2153
|
|
|
2162
2154
|
// src/persona.ts
|
|
2163
|
-
import { existsSync as
|
|
2164
|
-
import { dirname as dirname3, join as
|
|
2155
|
+
import { existsSync as existsSync5, readFileSync as readFileSync7, renameSync as renameSync2, unlinkSync, writeFileSync } from "node:fs";
|
|
2156
|
+
import { dirname as dirname3, join as join7 } from "node:path";
|
|
2165
2157
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2158
|
+
|
|
2159
|
+
// src/long-term-memory.ts
|
|
2160
|
+
import { createHash } from "node:crypto";
|
|
2161
|
+
import { chmodSync, existsSync as existsSync4, lstatSync, readFileSync as readFileSync6, readdirSync as readdirSync2, renameSync } from "node:fs";
|
|
2162
|
+
import { join as join6 } from "node:path";
|
|
2163
|
+
var MEMORY_FILE = "MEMORY.md";
|
|
2164
|
+
var QUARANTINE_MEMORY_FILE = "MEMORY.quarantined.md";
|
|
2165
|
+
var LEGACY_MEMORY_SOURCE_MAX_BYTES = 4 * 1024 * 1024;
|
|
2166
|
+
var LEGACY_MEMORY_REPORT_MAX_BYTES = 4 * 1024 * 1024;
|
|
2167
|
+
var LEGACY_MEMORY_REPORT_MAX_SOURCES = 100;
|
|
2168
|
+
function quarantineOrder(a, b) {
|
|
2169
|
+
const index = (name) => {
|
|
2170
|
+
const match = /^MEMORY\.quarantined(?:\.(\d+))?\.md$/.exec(name);
|
|
2171
|
+
return match?.[1] ? Number(match[1]) + 1 : 0;
|
|
2172
|
+
};
|
|
2173
|
+
return index(a) - index(b) || a.localeCompare(b);
|
|
2174
|
+
}
|
|
2175
|
+
function sourceId(kind, channelId, path, bytes) {
|
|
2176
|
+
return createHash("sha256").update(kind).update("\0").update(channelId ?? "").update("\0").update(path).update("\0").update(bytes).digest("hex");
|
|
2177
|
+
}
|
|
2178
|
+
function readLegacySource(path, kind, contentBudget, channelId) {
|
|
2179
|
+
try {
|
|
2180
|
+
let st = lstatSync(path);
|
|
2181
|
+
if (st.isSymbolicLink() || !st.isFile()) return null;
|
|
2182
|
+
if ((st.mode & 511) !== 384) {
|
|
2183
|
+
chmodSync(path, 384);
|
|
2184
|
+
st = lstatSync(path);
|
|
2185
|
+
}
|
|
2186
|
+
if (st.size > LEGACY_MEMORY_SOURCE_MAX_BYTES) {
|
|
2187
|
+
const marker = Buffer.from(`oversize:${st.size}:${st.mtimeMs}:${st.ctimeMs}`, "utf8");
|
|
2188
|
+
return {
|
|
2189
|
+
id: sourceId(kind, channelId, path, marker),
|
|
2190
|
+
kind,
|
|
2191
|
+
...channelId ? { channelId } : {},
|
|
2192
|
+
path,
|
|
2193
|
+
content: null,
|
|
2194
|
+
bytes: st.size,
|
|
2195
|
+
exportable: false
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
if (st.size > contentBudget) return null;
|
|
2199
|
+
const bytes = readFileSync6(path);
|
|
2200
|
+
if (bytes.byteLength > LEGACY_MEMORY_SOURCE_MAX_BYTES || bytes.byteLength > contentBudget) return null;
|
|
2201
|
+
return {
|
|
2202
|
+
id: sourceId(kind, channelId, path, bytes),
|
|
2203
|
+
kind,
|
|
2204
|
+
...channelId ? { channelId } : {},
|
|
2205
|
+
path,
|
|
2206
|
+
content: bytes.toString("utf8"),
|
|
2207
|
+
bytes: bytes.byteLength,
|
|
2208
|
+
exportable: true
|
|
2209
|
+
};
|
|
2210
|
+
} catch {
|
|
2211
|
+
return null;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
function collectLegacyMemorySources(managedDir, dataDir) {
|
|
2215
|
+
const sources = [];
|
|
2216
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2217
|
+
let contentBudget = LEGACY_MEMORY_REPORT_MAX_BYTES;
|
|
2218
|
+
const add = (path, kind, channelId) => {
|
|
2219
|
+
if (sources.length >= LEGACY_MEMORY_REPORT_MAX_SOURCES) return;
|
|
2220
|
+
const source = readLegacySource(path, kind, contentBudget, channelId);
|
|
2221
|
+
if (!source) return;
|
|
2222
|
+
sources.push(source);
|
|
2223
|
+
if (source.exportable) contentBudget -= source.bytes;
|
|
2224
|
+
};
|
|
2225
|
+
for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
|
|
2226
|
+
let names;
|
|
2227
|
+
try {
|
|
2228
|
+
names = readdirSync2(dir);
|
|
2229
|
+
} catch {
|
|
2230
|
+
continue;
|
|
2231
|
+
}
|
|
2232
|
+
for (const name of names.sort(quarantineOrder)) {
|
|
2233
|
+
if (!/^MEMORY\.quarantined(?:\.\d+)?\.md$/.test(name)) continue;
|
|
2234
|
+
const path = join6(dir, name);
|
|
2235
|
+
if (seen.has(path)) continue;
|
|
2236
|
+
seen.add(path);
|
|
2237
|
+
add(path, "global-memory");
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
const channelsDir = join6(dataDir, "channels");
|
|
2241
|
+
let channelNames;
|
|
2242
|
+
try {
|
|
2243
|
+
channelNames = readdirSync2(channelsDir);
|
|
2244
|
+
} catch {
|
|
2245
|
+
channelNames = [];
|
|
2246
|
+
}
|
|
2247
|
+
for (const channelId of channelNames.sort()) {
|
|
2248
|
+
const channelDir = join6(channelsDir, channelId);
|
|
2249
|
+
for (const [name, kind] of [
|
|
2250
|
+
["conversation-history.json", "conversation-history"],
|
|
2251
|
+
["memories.json", "captured-memory"]
|
|
2252
|
+
]) {
|
|
2253
|
+
add(join6(channelDir, name), kind, channelId);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return sources;
|
|
2257
|
+
}
|
|
2258
|
+
function legacyMemoryFingerprint(managedDir, dataDir) {
|
|
2259
|
+
const metadata = [];
|
|
2260
|
+
const add = (path) => {
|
|
2261
|
+
try {
|
|
2262
|
+
const st = lstatSync(path);
|
|
2263
|
+
if (!st.isFile() || st.isSymbolicLink()) return;
|
|
2264
|
+
metadata.push(`${path}\0${st.size}\0${st.mtimeMs}\0${st.ctimeMs}`);
|
|
2265
|
+
} catch {
|
|
2266
|
+
}
|
|
2267
|
+
};
|
|
2268
|
+
for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
|
|
2269
|
+
try {
|
|
2270
|
+
for (const name of readdirSync2(dir).sort(quarantineOrder)) {
|
|
2271
|
+
if (/^MEMORY\.quarantined(?:\.\d+)?\.md$/.test(name)) add(join6(dir, name));
|
|
2272
|
+
}
|
|
2273
|
+
} catch {
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
try {
|
|
2277
|
+
const channelsDir = join6(dataDir, "channels");
|
|
2278
|
+
for (const channelId of readdirSync2(channelsDir).sort()) {
|
|
2279
|
+
add(join6(channelsDir, channelId, "conversation-history.json"));
|
|
2280
|
+
add(join6(channelsDir, channelId, "memories.json"));
|
|
2281
|
+
}
|
|
2282
|
+
} catch {
|
|
2283
|
+
}
|
|
2284
|
+
return createHash("sha256").update(metadata.join("\n")).digest("hex");
|
|
2285
|
+
}
|
|
2286
|
+
function nextQuarantinePath(dir) {
|
|
2287
|
+
const first = join6(dir, QUARANTINE_MEMORY_FILE);
|
|
2288
|
+
if (!existsSync4(first)) return first;
|
|
2289
|
+
for (let n = 1; ; n++) {
|
|
2290
|
+
const candidate = join6(dir, `MEMORY.quarantined.${n}.md`);
|
|
2291
|
+
if (!existsSync4(candidate)) return candidate;
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
function quarantineLegacyMemory(managedDir, dataDir) {
|
|
2295
|
+
const moved = [];
|
|
2296
|
+
for (const dir of /* @__PURE__ */ new Set([managedDir, dataDir])) {
|
|
2297
|
+
const source = join6(dir, MEMORY_FILE);
|
|
2298
|
+
if (!existsSync4(source)) continue;
|
|
2299
|
+
if (lstatSync(source).isSymbolicLink()) {
|
|
2300
|
+
throw new Error(`refusing to quarantine symlinked legacy memory: ${source}`);
|
|
2301
|
+
}
|
|
2302
|
+
const target = nextQuarantinePath(dir);
|
|
2303
|
+
renameSync(source, target);
|
|
2304
|
+
chmodSync(target, 384);
|
|
2305
|
+
moved.push(target);
|
|
2306
|
+
}
|
|
2307
|
+
return moved;
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// src/persona.ts
|
|
2166
2311
|
var PERSONA_MAX_BYTES = 8192;
|
|
2167
2312
|
var PERSONA_SOURCES = [
|
|
2168
2313
|
{ file: "IDENTITY.md", heading: "## Identity" },
|
|
@@ -2177,7 +2322,7 @@ var SAFETY_PREAMBLE = [
|
|
|
2177
2322
|
"- Only use the tools you were given; do not invent capabilities."
|
|
2178
2323
|
].join("\n");
|
|
2179
2324
|
function defaultPersonaDir() {
|
|
2180
|
-
return
|
|
2325
|
+
return join7(dirname3(fileURLToPath2(import.meta.url)), "..", "persona");
|
|
2181
2326
|
}
|
|
2182
2327
|
function assemblePrompt(parts) {
|
|
2183
2328
|
return parts.map((p) => `${p.heading}
|
|
@@ -2190,7 +2335,7 @@ function loadPersona(dirs) {
|
|
|
2190
2335
|
if (!path) continue;
|
|
2191
2336
|
let body;
|
|
2192
2337
|
try {
|
|
2193
|
-
body =
|
|
2338
|
+
body = readFileSync7(path, "utf8");
|
|
2194
2339
|
} catch {
|
|
2195
2340
|
console.warn(`[persona] cannot read ${path}; ignoring it`);
|
|
2196
2341
|
continue;
|
|
@@ -2210,29 +2355,32 @@ function systemPromptFor(dirs, fallback) {
|
|
|
2210
2355
|
${SAFETY_PREAMBLE}`;
|
|
2211
2356
|
}
|
|
2212
2357
|
function writeLocalConfigReport(managedDir, dataDir, now, flags) {
|
|
2213
|
-
if (!
|
|
2358
|
+
if (!existsSync5(managedDir)) return false;
|
|
2214
2359
|
const persona = {};
|
|
2215
2360
|
for (const { file } of PERSONA_SOURCES) {
|
|
2216
|
-
const path2 =
|
|
2217
|
-
if (!
|
|
2361
|
+
const path2 = join7(dataDir, file);
|
|
2362
|
+
if (!existsSync5(path2)) continue;
|
|
2218
2363
|
try {
|
|
2219
|
-
persona[file] =
|
|
2364
|
+
persona[file] = readFileSync7(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
|
|
2220
2365
|
} catch {
|
|
2221
2366
|
}
|
|
2222
2367
|
}
|
|
2223
2368
|
const doc = {
|
|
2224
|
-
schema: "securecoms.local-config/
|
|
2369
|
+
schema: "securecoms.local-config/2",
|
|
2225
2370
|
written_at: now.toISOString(),
|
|
2226
2371
|
has_search_key: flags.hasSearchKey,
|
|
2227
2372
|
has_llm_key: flags.hasLlmKey,
|
|
2228
|
-
persona
|
|
2373
|
+
persona,
|
|
2374
|
+
// Paths stay in this owner-only local handoff file. The daemon validates
|
|
2375
|
+
// each source again and strips `path` before reporting anything upstream.
|
|
2376
|
+
legacy_memory: collectLegacyMemorySources(managedDir, dataDir)
|
|
2229
2377
|
};
|
|
2230
|
-
const path =
|
|
2378
|
+
const path = join7(managedDir, "local-config.json");
|
|
2231
2379
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
2232
2380
|
try {
|
|
2233
|
-
|
|
2381
|
+
writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
|
|
2234
2382
|
`, { mode: 384 });
|
|
2235
|
-
|
|
2383
|
+
renameSync2(tmp, path);
|
|
2236
2384
|
} catch (e) {
|
|
2237
2385
|
try {
|
|
2238
2386
|
unlinkSync(tmp);
|
|
@@ -2246,12 +2394,12 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
|
|
|
2246
2394
|
const written = [];
|
|
2247
2395
|
const skipped = [];
|
|
2248
2396
|
for (const { file } of PERSONA_SOURCES) {
|
|
2249
|
-
const target =
|
|
2397
|
+
const target = join7(dataDir, file);
|
|
2250
2398
|
if (exists(target)) {
|
|
2251
2399
|
skipped.push(target);
|
|
2252
2400
|
continue;
|
|
2253
2401
|
}
|
|
2254
|
-
writeFile(target,
|
|
2402
|
+
writeFile(target, readFileSync7(join7(defaultsDir, file), "utf8"));
|
|
2255
2403
|
written.push(target);
|
|
2256
2404
|
}
|
|
2257
2405
|
return { written, skipped };
|
|
@@ -2259,11 +2407,17 @@ function personaInit(dataDir, defaultsDir, writeFile, exists) {
|
|
|
2259
2407
|
function createLocalConfigReporter(managedDir, dataDir, now) {
|
|
2260
2408
|
let lastWritten = null;
|
|
2261
2409
|
return (flags) => {
|
|
2262
|
-
|
|
2410
|
+
const legacyFingerprint = legacyMemoryFingerprint(managedDir, dataDir);
|
|
2411
|
+
if (lastWritten !== null && lastWritten.hasSearchKey === flags.hasSearchKey && lastWritten.hasLlmKey === flags.hasLlmKey && lastWritten.legacyFingerprint === legacyFingerprint) {
|
|
2263
2412
|
return false;
|
|
2264
2413
|
}
|
|
2265
2414
|
const wrote = writeLocalConfigReport(managedDir, dataDir, now(), flags);
|
|
2266
|
-
if (wrote)
|
|
2415
|
+
if (wrote) {
|
|
2416
|
+
lastWritten = {
|
|
2417
|
+
...flags,
|
|
2418
|
+
legacyFingerprint: legacyMemoryFingerprint(managedDir, dataDir)
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2267
2421
|
return wrote;
|
|
2268
2422
|
};
|
|
2269
2423
|
}
|
|
@@ -2284,7 +2438,7 @@ function installWarningFilter(proc = process) {
|
|
|
2284
2438
|
|
|
2285
2439
|
// src/index.ts
|
|
2286
2440
|
if (process.argv.includes("--version")) {
|
|
2287
|
-
const pkg = JSON.parse(
|
|
2441
|
+
const pkg = JSON.parse(readFileSync8(join8(dirname4(dirname4(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
|
|
2288
2442
|
console.log(pkg.version);
|
|
2289
2443
|
process.exit(0);
|
|
2290
2444
|
}
|
|
@@ -2298,15 +2452,12 @@ function makeLogger(cfg) {
|
|
|
2298
2452
|
else console.log(line);
|
|
2299
2453
|
};
|
|
2300
2454
|
}
|
|
2301
|
-
function safeChannelSegment(channelId) {
|
|
2302
|
-
return channelId.replace(/[^A-Za-z0-9_-]/g, "_") || "_";
|
|
2303
|
-
}
|
|
2304
2455
|
function realLlmSourceDeps() {
|
|
2305
2456
|
return {
|
|
2306
2457
|
home: homedir4(),
|
|
2307
|
-
readFile: (p) =>
|
|
2458
|
+
readFile: (p) => readFileSync8(p, "utf8"),
|
|
2308
2459
|
statFile: (p) => ({ mode: statSync2(p).mode }),
|
|
2309
|
-
exists: (p) =>
|
|
2460
|
+
exists: (p) => existsSync6(p)
|
|
2310
2461
|
};
|
|
2311
2462
|
}
|
|
2312
2463
|
function realServiceDeps() {
|
|
@@ -2316,10 +2467,10 @@ function realServiceDeps() {
|
|
|
2316
2467
|
const r = spawnSync(cmd, args, { encoding: "utf8" });
|
|
2317
2468
|
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
2318
2469
|
},
|
|
2319
|
-
writeFile: (p, data) =>
|
|
2470
|
+
writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
|
|
2320
2471
|
removeFile: (p) => rmSync(p, { force: true }),
|
|
2321
|
-
mkdirp: (p) =>
|
|
2322
|
-
fileExists: (p) =>
|
|
2472
|
+
mkdirp: (p) => mkdirSync(p, { recursive: true }),
|
|
2473
|
+
fileExists: (p) => existsSync6(p),
|
|
2323
2474
|
canRead: (p) => {
|
|
2324
2475
|
try {
|
|
2325
2476
|
accessSync(p, constants.R_OK);
|
|
@@ -2334,7 +2485,7 @@ function realServiceDeps() {
|
|
|
2334
2485
|
function realResolveNodeDeps() {
|
|
2335
2486
|
return {
|
|
2336
2487
|
realpath: (p) => realpathSync(p),
|
|
2337
|
-
fileExists: (p) =>
|
|
2488
|
+
fileExists: (p) => existsSync6(p),
|
|
2338
2489
|
nodeMajorOf: (p) => {
|
|
2339
2490
|
const r = spawnSync(p, ["--version"], { encoding: "utf8" });
|
|
2340
2491
|
if (r.status !== 0) return null;
|
|
@@ -2426,12 +2577,12 @@ async function main() {
|
|
|
2426
2577
|
return;
|
|
2427
2578
|
}
|
|
2428
2579
|
if (personaVerb === "init") {
|
|
2429
|
-
|
|
2580
|
+
mkdirSync(cfgForPersona.dataDir, { recursive: true });
|
|
2430
2581
|
const r = personaInit(
|
|
2431
2582
|
cfgForPersona.dataDir,
|
|
2432
2583
|
defaultPersonaDir(),
|
|
2433
|
-
(p, d) =>
|
|
2434
|
-
(p) =>
|
|
2584
|
+
(p, d) => writeFileSync2(p, d, { mode: 384 }),
|
|
2585
|
+
(p) => existsSync6(p)
|
|
2435
2586
|
);
|
|
2436
2587
|
for (const p of r.written) console.log(`wrote ${p}`);
|
|
2437
2588
|
for (const p of r.skipped) console.log(`kept ${p} (already exists)`);
|
|
@@ -2459,7 +2610,7 @@ async function main() {
|
|
|
2459
2610
|
const memoryFor = (channelId) => {
|
|
2460
2611
|
let mem = memories.get(channelId);
|
|
2461
2612
|
if (!mem) {
|
|
2462
|
-
mem = new AgentMemory(
|
|
2613
|
+
mem = new AgentMemory();
|
|
2463
2614
|
memories.set(channelId, mem);
|
|
2464
2615
|
}
|
|
2465
2616
|
return mem;
|
|
@@ -2478,14 +2629,14 @@ async function main() {
|
|
|
2478
2629
|
const path = keyFilePath(which);
|
|
2479
2630
|
if (!path) return null;
|
|
2480
2631
|
try {
|
|
2481
|
-
return
|
|
2632
|
+
return readFileSync8(path, "utf8").trim() || null;
|
|
2482
2633
|
} catch {
|
|
2483
2634
|
log("warn", `cannot read the ${which} key file ${path}`);
|
|
2484
2635
|
return null;
|
|
2485
2636
|
}
|
|
2486
2637
|
};
|
|
2487
2638
|
const configDirs = configSearchPath(cfg.identityId, cfg.dataDir);
|
|
2488
|
-
const hasSearchKey = () => hasReadableKeyFile([keyFilePath("brave"), keyFilePath("tavily")], (p) =>
|
|
2639
|
+
const hasSearchKey = () => hasReadableKeyFile([keyFilePath("brave"), keyFilePath("tavily")], (p) => readFileSync8(p, "utf8"));
|
|
2489
2640
|
const hasLlmKey = () => {
|
|
2490
2641
|
try {
|
|
2491
2642
|
return hasLlmKeyFor(resolveLlmSource(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps()));
|
|
@@ -2493,7 +2644,7 @@ async function main() {
|
|
|
2493
2644
|
return false;
|
|
2494
2645
|
}
|
|
2495
2646
|
};
|
|
2496
|
-
|
|
2647
|
+
quarantineLegacyMemory(managedDir, cfg.dataDir);
|
|
2497
2648
|
const reportLocalConfig = createLocalConfigReporter(managedDir, cfg.dataDir, () => /* @__PURE__ */ new Date());
|
|
2498
2649
|
reportLocalConfig({ hasSearchKey: hasSearchKey(), hasLlmKey: hasLlmKey() });
|
|
2499
2650
|
const builtinCtx = {
|
|
@@ -2515,7 +2666,7 @@ async function main() {
|
|
|
2515
2666
|
allow: DEFAULT_TOOL_ALLOW
|
|
2516
2667
|
});
|
|
2517
2668
|
const gate = createDynamicToolGate(policyFor);
|
|
2518
|
-
const
|
|
2669
|
+
const installationEligibleTools = () => {
|
|
2519
2670
|
const sc = currentSkillsConfig();
|
|
2520
2671
|
const g = createLocalToolGate(policyFor());
|
|
2521
2672
|
const searchKey = hasSearchKey();
|
|
@@ -2533,16 +2684,15 @@ async function main() {
|
|
|
2533
2684
|
skills,
|
|
2534
2685
|
gate,
|
|
2535
2686
|
log,
|
|
2536
|
-
// All of these are read PER TURN: a persona edit, a
|
|
2537
|
-
//
|
|
2538
|
-
//
|
|
2687
|
+
// All of these are read PER TURN: a persona edit, a key file dropped in,
|
|
2688
|
+
// or configuration saved in the web app takes effect on the next message,
|
|
2689
|
+
// with no restart.
|
|
2539
2690
|
systemPromptFor: () => systemPromptFor(configDirs, DEFAULT_SYSTEM_PROMPT),
|
|
2540
|
-
|
|
2541
|
-
tools: offeredTools
|
|
2691
|
+
tools: installationEligibleTools
|
|
2542
2692
|
})
|
|
2543
2693
|
);
|
|
2544
2694
|
console.log(
|
|
2545
|
-
`${bootLine(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps())}, ${
|
|
2695
|
+
`${bootLine(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps())}, ${installationEligibleTools().length} configured skills (channel grant required), tool gate default=${cfg.toolGate.default}`
|
|
2546
2696
|
);
|
|
2547
2697
|
await plane.start();
|
|
2548
2698
|
const shutdown = () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@securecoms/agent-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"securecoms-agent": "dist/bin.js"
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@types/ws": "^8.5.12",
|
|
44
44
|
"esbuild": "0.24.0",
|
|
45
45
|
"typescript": "^5.4.0",
|
|
46
|
-
"vitest": "^2.
|
|
46
|
+
"vitest": "^3.2.6",
|
|
47
47
|
"ws": "^8.21.0"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/skills/remember/SKILL.md
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: remember
|
|
3
|
-
description: Save a durable fact to the agent's long-term memory.
|
|
4
|
-
handler: builtin:remember
|
|
5
|
-
parameters:
|
|
6
|
-
type: object
|
|
7
|
-
properties:
|
|
8
|
-
fact: { type: string, description: One durable fact of at most 300 characters. }
|
|
9
|
-
required: [fact]
|
|
10
|
-
---
|
|
11
|
-
Save a durable fact about the people or the business you work for — a renewal
|
|
12
|
-
date, a preference, a name. Not a passing detail from this conversation, and
|
|
13
|
-
never a password, key or token.
|