@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 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`, `remember` | Comma-separated tool allow-list |
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
- Seven built-in handlers ship with the runtime. All network built-ins refuse
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
- Separately, `MEMORY.md` in the data dir is **agent-wide** (not per-persona,
287
- not per-channel): the `remember` skill appends a dated, one-line fact to it,
288
- and its contents are injected as a system message on every turn across every
289
- channel. It is capped at 8 kB; once it overflows, the **oldest** lines are
290
- dropped first, on the theory that the newest remembered facts are the ones
291
- still likely to be true.
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
- Two deliberate exceptions:
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
- The runtime also writes `local-config.json` into the managed directory when one
325
- exists: what this agent's OWN persona files say, and a single boolean for
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
- ### A known limitation: the generated tool paragraph
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
- this agent has. The backend derives that list from the saved configuration plus
335
- the search-key boolean above; it does not see the running process. Three things
336
- can therefore make it diverge from what the runtime actually offers:
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 is offered but unnamed.
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
- In any of those cases the paragraph is advisory rather than exact. The gate is
345
- still authoritative — a tool named in the paragraph but denied by policy is
346
- still refused at call time — so the risk is a wrong claim in conversation, not
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 readFileSync9 } from "node:fs";
4
+ import { readFileSync as readFileSync8 } from "node:fs";
5
5
  import { fileURLToPath as fileURLToPath3 } from "node:url";
6
- import { dirname as dirname4, join as join9 } from "node:path";
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 existsSync7, mkdirSync as mkdirSync3, realpathSync, rmSync, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
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
- const text = await resp.text();
477
- throw new Error(`LLM request failed (${resp.status}): ${text}`);
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
- memories = [];
701
- dataDir;
702
- constructor(dataDir) {
703
- this.dataDir = dataDir;
704
- this.loadHistory();
705
- this.loadMemories();
706
- }
707
- /** Add a message to conversation history. */
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 conversation history. */
721
+ /** Clear this process-local view; durable deletion belongs to the bridge. */
720
722
  clear() {
721
723
  this.history = [];
722
- this.saveHistory();
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((w) => w.length >= 3);
728
+ const queryWords = query.toLowerCase().split(/\W+/).filter((word) => word.length >= 3);
732
729
  if (queryWords.length === 0) return null;
733
- const scored = this.memories.map((m) => {
734
- const contentLower = m.content.toLowerCase();
730
+ const scored = this.facts.map((content) => {
731
+ const contentLower = content.toLowerCase();
735
732
  const score = queryWords.reduce(
736
- (acc, w) => contentLower.includes(w) ? acc + 1 : acc,
733
+ (acc, word) => contentLower.includes(word) ? acc + 1 : acc,
737
734
  0
738
735
  );
739
- return { memory: m, score };
740
- }).filter((s) => s.score > 0).sort((a, b) => b.score - a.score).slice(0, 5);
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
- ${memories}`;
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 readFileSync6, readdirSync, existsSync as existsSync5, statSync } from "node:fs";
804
- import { join as join6 } from "node:path";
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 existsSync3, readFileSync as readFileSync2 } from "node:fs";
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 as existsSync2 } from "node:fs";
905
+ import { existsSync } from "node:fs";
964
906
  import { homedir as homedir2 } from "node:os";
965
- import { join as join4 } from "node:path";
907
+ import { join as join3 } from "node:path";
966
908
  function managedConfigDir(identityId, home = homedir2()) {
967
- return join4(home, ".securecoms", "runtime-config", identityId);
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 = join4(dir, name);
975
- if (existsSync2(path)) return path;
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 (!existsSync3(path)) return empty();
927
+ if (!existsSync2(path)) return empty();
986
928
  let raw;
987
929
  try {
988
- raw = JSON.parse(readFileSync2(path, "utf8"));
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 readFileSync3 } from "node:fs";
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(readFileSync3(endpoint.headersFile, "utf8")))) {
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 readFileSync5 } from "node:fs";
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: readFileSync5(db.postgresUrlFile, "utf8").trim(),
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 (!existsSync5(this.skillsDir)) {
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(join6(this.skillsDir, entry.name));
1500
+ flat.push(join4(this.skillsDir, entry.name));
1611
1501
  } else if (entry.isDirectory()) {
1612
- const m = join6(this.skillsDir, entry.name, "manifest.json");
1613
- if (existsSync5(m) && statSync(m).isFile()) manifests.push(m);
1614
- const s = join6(this.skillsDir, entry.name, "SKILL.md");
1615
- if (existsSync5(s) && statSync(s).isFile()) skillMds.push(s);
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(readFileSync6(path, "utf-8"), path), path);
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 = readFileSync6(path, "utf-8");
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 readFileSync7 } from "node:fs";
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 join7 } from "node:path";
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 ?? join7(homedir3(), ".securecoms", "connections");
1848
- this.connectionPath = join7(dir, `${cfg.identityId}.json`);
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(readFileSync7(this.connectionPath, "utf8"));
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 = readFileSync7(conn.token_file, "utf8").trim();
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({ type: "send", channel_id: channelId, text: String(reply) }));
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("info", `message from ${m.senderId.slice(0, 8)}: ${text.slice(0, 80)}`);
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
- call,
2111
- verdict: gate.gate(call.function.name)
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
- if (response.length > 50) {
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 existsSync6, readFileSync as readFileSync8, renameSync, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
2164
- import { dirname as dirname3, join as join8 } from "node:path";
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 join8(dirname3(fileURLToPath2(import.meta.url)), "..", "persona");
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 = readFileSync8(path, "utf8");
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 (!existsSync6(managedDir)) return false;
2358
+ if (!existsSync5(managedDir)) return false;
2214
2359
  const persona = {};
2215
2360
  for (const { file } of PERSONA_SOURCES) {
2216
- const path2 = join8(dataDir, file);
2217
- if (!existsSync6(path2)) continue;
2361
+ const path2 = join7(dataDir, file);
2362
+ if (!existsSync5(path2)) continue;
2218
2363
  try {
2219
- persona[file] = readFileSync8(path2, "utf8").slice(0, PERSONA_MAX_BYTES);
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/1",
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 = join8(managedDir, "local-config.json");
2378
+ const path = join7(managedDir, "local-config.json");
2231
2379
  const tmp = `${path}.${process.pid}.tmp`;
2232
2380
  try {
2233
- writeFileSync3(tmp, `${JSON.stringify(doc, null, 2)}
2381
+ writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}
2234
2382
  `, { mode: 384 });
2235
- renameSync(tmp, path);
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 = join8(dataDir, file);
2397
+ const target = join7(dataDir, file);
2250
2398
  if (exists(target)) {
2251
2399
  skipped.push(target);
2252
2400
  continue;
2253
2401
  }
2254
- writeFile(target, readFileSync8(join8(defaultsDir, file), "utf8"));
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
- if (lastWritten !== null && lastWritten.hasSearchKey === flags.hasSearchKey && lastWritten.hasLlmKey === flags.hasLlmKey) {
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) lastWritten = { ...flags };
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(readFileSync9(join9(dirname4(dirname4(fileURLToPath3(import.meta.url))), "package.json"), "utf8"));
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) => readFileSync9(p, "utf8"),
2458
+ readFile: (p) => readFileSync8(p, "utf8"),
2308
2459
  statFile: (p) => ({ mode: statSync2(p).mode }),
2309
- exists: (p) => existsSync7(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) => writeFileSync4(p, data, { mode: 420 }),
2470
+ writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
2320
2471
  removeFile: (p) => rmSync(p, { force: true }),
2321
- mkdirp: (p) => mkdirSync3(p, { recursive: true }),
2322
- fileExists: (p) => existsSync7(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) => existsSync7(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
- mkdirSync3(cfgForPersona.dataDir, { recursive: true });
2580
+ mkdirSync(cfgForPersona.dataDir, { recursive: true });
2430
2581
  const r = personaInit(
2431
2582
  cfgForPersona.dataDir,
2432
2583
  defaultPersonaDir(),
2433
- (p, d) => writeFileSync4(p, d, { mode: 384 }),
2434
- (p) => existsSync7(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(join9(cfg.dataDir, "channels", safeChannelSegment(channelId)));
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 readFileSync9(path, "utf8").trim() || null;
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) => readFileSync9(p, "utf8"));
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
- migrateMemoryIntoManaged(managedDir, cfg.dataDir);
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 offeredTools = () => {
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 new remembered fact, a
2537
- // key file dropped in, or configuration saved in the web app takes effect
2538
- // on the next message, with no restart.
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
- longTermMemory: () => readLongTermMemory(configDirs),
2541
- tools: offeredTools
2691
+ tools: installationEligibleTools
2542
2692
  })
2543
2693
  );
2544
2694
  console.log(
2545
- `${bootLine(cfg, isExplicitSource(cfg, process.env), realLlmSourceDeps())}, ${offeredTools().length} skills, tool gate default=${cfg.toolGate.default}`
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.6.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.0.0",
46
+ "vitest": "^3.2.6",
47
47
  "ws": "^8.21.0"
48
48
  }
49
49
  }
@@ -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.