deepdebug-local-agent 1.12.0 → 1.13.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/docs/la-chat.md +35 -0
  3. package/package.json +1 -1
  4. package/src/dlp/rules.js +2 -2
  5. package/src/la-protocol-v2/chat/config.js +60 -0
  6. package/src/la-protocol-v2/chat/cursors.js +57 -0
  7. package/src/la-protocol-v2/chat/dlp.js +92 -0
  8. package/src/la-protocol-v2/chat/index.js +28 -0
  9. package/src/la-protocol-v2/chat/store.js +337 -0
  10. package/src/la-protocol-v2/chat/stream-source.js +23 -0
  11. package/src/la-protocol-v2/content-handlers/chat-session.js +41 -0
  12. package/src/la-protocol-v2/content-handlers/handler.js +10 -2
  13. package/src/la-protocol-v2/content-handlers/install.js +2 -2
  14. package/src/la-protocol-v2/content-handlers/job-stream.js +62 -0
  15. package/src/la-protocol-v2/contract/CONTRACT_SOURCE.json +13 -8
  16. package/src/la-protocol-v2/contract/README.md +42 -0
  17. package/src/la-protocol-v2/contract/chat.session.body.schema.json +47 -0
  18. package/src/la-protocol-v2/contract/index.json +1 -1
  19. package/src/la-protocol-v2/contract/job.assign.schema.json +3 -2
  20. package/src/la-protocol-v2/contract/job.stream.body.schema.json +49 -0
  21. package/src/la-protocol-v2/jobs/agent2/api-client.js +78 -51
  22. package/src/la-protocol-v2/jobs/agent2/config.js +1 -0
  23. package/src/la-protocol-v2/jobs/agent2/runner.js +92 -11
  24. package/src/la-protocol-v2/jobs/agent2/supervisor.js +2 -0
  25. package/src/la-protocol-v2/jobs/git-exec.js +5 -4
  26. package/src/la-protocol-v2/jobs/manager.js +4 -2
  27. package/src/la-protocol-v2/jobs/workspace-materializer.js +35 -12
  28. package/src/la-protocol-v2/runtime.js +32 -3
  29. package/src/workspace-manager.js +3 -2
  30. package/test/contract/la-protocol-v2/vectors/chat.session.body.vectors.json +10379 -0
  31. package/test/contract/la-protocol-v2/vectors/content.request.vectors.json +245 -0
  32. package/test/contract/la-protocol-v2/vectors/hello.vectors.json +112 -0
  33. package/test/contract/la-protocol-v2/vectors/job.assign.vectors.json +568 -3
  34. package/test/contract/la-protocol-v2/vectors/job.stream.body.vectors.json +4572 -0
  35. package/test/contract/la-protocol-v2/vectors/register.vectors.json +106 -0
  36. package/test/contract/la-protocol-v2/vectors/traces/valid-chat-job-and-stream.json +311 -0
  37. package/test/la-protocol-v2/chat-dlp-json.test.js +169 -0
  38. package/test/la-protocol-v2/chat-la.test.js +490 -0
  39. package/test/la-protocol-v2/chat-real-e2e.test.js +69 -0
  40. package/test/la-protocol-v2/chat-store.test.js +301 -0
  41. package/test/la-protocol-v2/contract-v21.test.js +2 -2
  42. package/test/la-protocol-v2/contract.test.js +6 -6
  43. package/test/la-protocol-v2/helpers/agent2-rig.js +2 -2
  44. package/test/la-protocol-v2/helpers/fake-agent2-cli.js +1 -0
  45. package/test/la-protocol-v2/helpers/fake-agent2.js +80 -5
  46. package/test/la-protocol-v2/traces.test.js +3 -3
  47. package/test/la-protocol-v2/workspace-materializer-hostile.test.js +105 -0
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ platform side does not sign commands yet.
8
8
 
9
9
  ### Added (opt-in, off by default)
10
10
 
11
+ - **`LOCAL_AGENT_CHAT=true`** (LA-1 + LA-3, contract 2.3, default off). Chat on the Local Agent: `job.assign kind=chat` runs agent2's unified loop, a ChatStore on disk (0600, 30 days, no secrets) keeps the conversation, `job.stream` (long-poll with cursor) and `chat.session` serve it through the content relay (DLP enforce), `job.chat` / `job.inline.16k` are announced only if agent2 lists `chat`. See `docs/la-chat.md`. No behaviour change when off.
12
+
11
13
  - **`LOCAL_AGENT_RUNNER=agent2`** (F3-08c, default off). The packaged agent loop (agent2, next to the agent) runs `job.assign` jobs on the customer's side: supervised process or sidecar, a loopback bridge that
12
14
  serves its model calls (Anthropic Messages API with tools, new `ctx.llm.messages` / `LOCAL_AGENT_LLM_MESSAGES_PATH`) through the DLP and the key holder, `approval.decision` delivery with `vhapr1` token check (the approval is found through agent2's pending list of the job), PolicyGate over the v2 channel (contract 2.2 `policy.check` / `policy.decision`: the control plane
13
15
  derives tenant, agent and origin from the session, nothing is claimed here), agent2's `/workspace` calls signed for it by the bridge (signature mode stays `enforce`), a pidfile so a restart ends an orphan agent2,
@@ -0,0 +1,35 @@
1
+ # Chat on the Local Agent (LA-1 + LA-3, ADR-0014 Design A, contract 2.3)
2
+
3
+ For a tenant in `data_plane_mode=client` the chat loop runs HERE; the cloud only holds metadata. Flag `LOCAL_AGENT_CHAT` (default off).
4
+
5
+ ## What it needs (all of them, else it says why and stays off)
6
+ `LOCAL_AGENT_PROTOCOL_V2`, `LOCAL_AGENT_JOBS`, `LOCAL_AGENT_RUNNER=agent2` with the LLM client, `LOCAL_AGENT_CONTENT_HANDLERS` and `LOCAL_AGENT_DLP_MODE=enforce`.
7
+ The agent starts agent2 with `AGENT2_LOCAL_CHAT_ENABLED=true` (spawn mode); in attach mode the sidecar must have it.
8
+
9
+ ## Capabilities
10
+ `job.chat` and `job.inline.16k` are announced in `register`/`hello` only when agent2's `GET /local/v1/health` lists `chat` in `kinds`. The agent asks at start-up
11
+ (`LOCAL_AGENT_CHAT_PROBE_MS`, default 20 s; in spawn mode this starts agent2 at boot instead of at the first job). If agent2 is not ready in time, the capability appears at the next
12
+ reconnection. The job start asks `/health` again: an agent2 that lost `chat` fails the job (`infra`); a chat is never run as another kind.
13
+
14
+ ## Flow
15
+ `job.assign kind=chat` -> the ChatStore opens the conversation (conversationId = jobId) -> agent2 `unified` loop (no PR, no push) -> agent2's content stream
16
+ (`GET /jobs/{id}/stream`, LOCAL ONLY) is recorded line by line -> the control plane reads it with `content.request`:
17
+ - `job.stream`: long-poll (<= 8 s, `LOCAL_AGENT_CHAT_POLL_MS`) with a cursor. First `ref` = the job UUID; then the `nextRef` (43 chars) of the previous page. Body `{events:[{i,e}], nextRef, done}`.
18
+ The same cursor gives the same page. A job that is queued or being prepared is waited for.
19
+ - `chat.session`: `ref` = conversation UUID. Body `{turns:[{role, ts, text}], truncated}`.
20
+ The job frames (`job.event`, `job.result`, `audit.event`) stay metadata only.
21
+
22
+ ## Outcome of a chat
23
+ `no_change`, `failed` or `cancelled`. The closed enum has no value for "answered and changed files locally", so `filesChangedCount` carries it: the greater of agent2's own count
24
+ and the largest `filesChanged` of its job.event frames. A prompt over 16 KiB of UTF-8 is refused (`failed`/`unknown`), never cut.
25
+
26
+ ## Store (the customer's machine only)
27
+ `LOCAL_AGENT_CHAT_DIR` (default `~/.deepdebug/chat`) `/<tenant>/<conversationId>.jsonl`, files 0600, folders 0700. Retention `LOCAL_AGENT_CHAT_RETENTION_DAYS` (30), ceiling
28
+ `LOCAL_AGENT_CHAT_MAX_BYTES` (256 MiB, oldest first), per conversation `LOCAL_AGENT_CHAT_MAX_CONVERSATION_BYTES` (8 MiB, then `truncated`). A conversation being written is never swept.
29
+ No secret is written: every piece (prompt, event line, answer) is masked by the DLP (enforce) BEFORE the disk and again on the way out; a blocked class (`LOCAL_AGENT_DLP_BLOCK_CLASSES`)
30
+ is replaced by a fixed "withheld" text. Pieces are masked one by one, never the JSON around them. Nothing of the content is logged, audited or put in a stat.
31
+
32
+ ## Known gaps
33
+ - The contract has no conversation id or `parentId` in `job.assign`: each chat job is its own conversation (`chat.session` ref = the job id) and a follow-up turn does not see the previous one.
34
+ - `ChatStore.deleteWorkspace()` exists but nothing calls it yet (the agent has no workspace-removed hook).
35
+ - The projection of agent2's lines into turns (answer from `summary.content.answer`, else the `token` lines) is checked against the real jar in `chat-real-e2e.test.js` only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepdebug-local-agent",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "DeepDebug Local Agent - AI-powered code debugging assistant",
5
5
  "private": false,
6
6
  "type": "module",
package/src/dlp/rules.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  plausibleCard, ibanValid, ptNifValid,
18
18
  } from './validators.js';
19
19
 
20
- export const RULESET_VERSION = '2026.10.28-1';
20
+ export const RULESET_VERSION = '2026.11.05-1';
21
21
 
22
22
  export const RULE_CLASSES = Object.freeze([
23
23
  'private-key', 'cloud-credential', 'vcs-token', 'payment-key', 'saas-token', 'token',
@@ -79,7 +79,7 @@ export const RULES = Object.freeze([
79
79
 
80
80
  // Generic "name = literal" assignments
81
81
  r('generic_secret_assignment', 'credential',
82
- /\b(?:[A-Za-z0-9_-]{0,40}?(?:password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|token))["']?\s*(?::|=|=>)\s*["']?([^\s"'`,;]{8,})/gi,
82
+ /\b(?:[A-Za-z0-9_-]{0,40}?(?:password|passwd|pwd|secret|api[_-]?key|apikey|access[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key|token))(?:\\*["'`])?\s*(?::|=|=>)\s*(?:\\*["'`])?([^\s"'`,;]{8,})/gi,
83
83
  { group: 1, validate: looksLikeLiteralSecret }),
84
84
 
85
85
  // PII
@@ -0,0 +1,60 @@
1
+ /**
2
+ * config.js - LOCAL_AGENT_CHAT flag and limits of the chat on the Local Agent (LA-1 + LA-3, ADR-0014 Design A, contract 2.3).
3
+ *
4
+ * false (default) nothing of this folder is loaded: `job.chat` / `job.inline.16k` are not announced, `job.stream` and `chat.session` answer
5
+ * `protocol_unsupported`, agent2 is not told to accept `kind=chat`.
6
+ * true the agent2 runner also runs `kind: "chat"` (agent2 `unified` loop), the ChatStore keeps the conversation ON THIS MACHINE and the content
7
+ * handlers serve `job.stream` (long-poll) and `chat.session`. Needs, all of them: LOCAL_AGENT_PROTOCOL_V2, LOCAL_AGENT_JOBS,
8
+ * LOCAL_AGENT_RUNNER=agent2 (+ the LLM client), LOCAL_AGENT_CONTENT_HANDLERS and LOCAL_AGENT_DLP_MODE=enforce (runtime.js refuses, and says so,
9
+ * when one is missing).
10
+ *
11
+ * Unset/empty/unrecognised = false. Every number is clamped: a bad value falls back to the default, never to "unlimited".
12
+ */
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ export const CHAT_FLAG = "LOCAL_AGENT_CHAT";
17
+
18
+ const TRUE = new Set(["true", "1", "yes", "on"]);
19
+ const FALSE = new Set(["", "false", "0", "no", "off"]);
20
+ const KIB = 1024;
21
+ const MIB = 1024 * KIB;
22
+ const DAY_MS = 86_400_000;
23
+
24
+ function int(raw, def, min, max) {
25
+ const n = Number.parseInt(String(raw ?? ""), 10);
26
+ return Number.isFinite(n) && n >= min && n <= max ? n : def;
27
+ }
28
+
29
+ /** True when LOCAL_AGENT_CHAT asks for the chat (the other prerequisites are checked by runtime.js). */
30
+ export function chatRequested(env = process.env) {
31
+ return TRUE.has(String(env[CHAT_FLAG] ?? "").trim().toLowerCase());
32
+ }
33
+
34
+ export function resolveChatConfig(env = process.env, warn = console.warn) {
35
+ const raw = String(env[CHAT_FLAG] ?? "").trim().toLowerCase();
36
+ if (!TRUE.has(raw)) {
37
+ if (!FALSE.has(raw)) warn(`[la-chat] unrecognised ${CHAT_FLAG} value; the chat stays off`);
38
+ return { enabled: false };
39
+ }
40
+ return {
41
+ enabled: true,
42
+ // where the conversations live: <dir>/<tenant>/<conversationId>.jsonl, 0600 in a 0700 folder; the customer's machine only
43
+ dir: path.resolve(String(env.LOCAL_AGENT_CHAT_DIR ?? "").trim() || path.join(os.homedir(), ".deepdebug", "chat")),
44
+ retentionMs: int(env.LOCAL_AGENT_CHAT_RETENTION_DAYS, 30, 1, 3650) * DAY_MS, // Edu Q7: 30 days
45
+ maxBytes: int(env.LOCAL_AGENT_CHAT_MAX_BYTES, 256 * MIB, MIB, 64 * 1024 * MIB), // the whole store; the oldest conversations go first
46
+ maxConversationBytes: int(env.LOCAL_AGENT_CHAT_MAX_CONVERSATION_BYTES, 8 * MIB, 16 * KIB, 256 * MIB), // events beyond it are dropped (the chat is `truncated`)
47
+ sweepMs: int(env.LOCAL_AGENT_CHAT_SWEEP_MS, 3_600_000, 1_000, 86_400_000),
48
+ // job.stream long-poll: the gateway gives a request 10 s to the first chunk and 30 s in total (contract 2.3); the default hold is 8 s
49
+ pollMs: int(env.LOCAL_AGENT_CHAT_POLL_MS, 8_000, 10, 8_000),
50
+ pageEvents: int(env.LOCAL_AGENT_CHAT_PAGE_EVENTS, 256, 1, 512), // the contract caps a page at 512 events
51
+ pageBytes: int(env.LOCAL_AGENT_CHAT_PAGE_BYTES, 256 * KIB, 16 * KIB, 1024 * KIB), // and the whole body must fit a content.response (1 MiB by default)
52
+ cursorTtlMs: int(env.LOCAL_AGENT_CHAT_CURSOR_TTL_MS, 30 * 60_000, 1_000, 24 * 3_600_000),
53
+ maxCursors: int(env.LOCAL_AGENT_CHAT_MAX_CURSORS, 4_096, 16, 100_000),
54
+ // how long the runner waits for agent2's content stream to end after the job's result before it lets go
55
+ streamDrainMs: int(env.LOCAL_AGENT_CHAT_STREAM_DRAIN_MS, 5_000, 100, 60_000),
56
+ // at start-up the runner asks agent2 which kinds it runs (`GET /health`), because `job.chat` is announced only if it says `chat`;
57
+ // the link waits for that at most this long (in spawn mode this starts agent2 at boot instead of at the first job)
58
+ probeWaitMs: int(env.LOCAL_AGENT_CHAT_PROBE_MS, 20_000, 0, 120_000),
59
+ };
60
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * cursors.js - the `nextRef` of `job.stream` (contract 2.3): an opaque 43-character id (base64url, no prefix, 256 random bits) that THIS agent mints for
3
+ * "this job, from event N". The control plane cannot read it. It is valid for ONE tenant and ONE workspace; any other combination is `null`, as if it never
4
+ * existed. In memory only: a restart of the agent forgets every cursor and the caller starts again from the job id (the ChatStore on disk is the truth).
5
+ *
6
+ * A cursor is NOT consumed when it is used: a lost answer is asked for again with the same cursor and gets the same page (idempotent). The same
7
+ * (job, position) always maps to the same id, so a long-poll that times out empty does not mint a new id every 8 s.
8
+ */
9
+ import crypto from "node:crypto";
10
+
11
+ export const CURSOR_PATTERN = /^[A-Za-z0-9_-]{43}$/;
12
+
13
+ export function createCursorRegistry({ tenantId, ttlMs = 30 * 60_000, maxEntries = 4096, now = Date.now } = {}) {
14
+ const byId = new Map(); // id -> { workspaceId, jobId, next, expiresAt } (insertion order = age)
15
+ const byKey = new Map(); // `${workspaceId}|${jobId}|${next}` -> id
16
+ const keyOf = (e) => `${e.workspaceId}|${e.jobId}|${e.next}`;
17
+
18
+ function drop(id) {
19
+ const e = byId.get(id);
20
+ if (!e) return;
21
+ byId.delete(id);
22
+ if (byKey.get(keyOf(e)) === id) byKey.delete(keyOf(e));
23
+ }
24
+ function purge() {
25
+ const t = now();
26
+ for (const [id, e] of byId) { if (e.expiresAt > t) break; drop(id); } // one ttl for all: insertion order is expiry order
27
+ while (byId.size > maxEntries) drop(byId.keys().next().value);
28
+ }
29
+
30
+ return {
31
+ get size() { return byId.size; },
32
+ /** @returns {string} the 43-char id for (workspace, job, next) */
33
+ mint({ workspaceId, jobId, next }) {
34
+ purge();
35
+ const k = keyOf({ workspaceId, jobId, next });
36
+ const known = byKey.get(k);
37
+ if (known && byId.has(known)) { const e = byId.get(known); drop(known); byId.set(known, { ...e, expiresAt: now() + ttlMs }); byKey.set(k, known); return known; } // refresh: moves it to the young end
38
+ let id;
39
+ do { id = crypto.randomBytes(32).toString("base64url"); } while (byId.has(id));
40
+ byId.set(id, { workspaceId, jobId, next, expiresAt: now() + ttlMs });
41
+ byKey.set(k, id);
42
+ purge();
43
+ return id;
44
+ },
45
+ /** @returns {{jobId:string, next:number} | null} */
46
+ resolve(id, { workspaceId }) {
47
+ if (typeof id !== "string" || !CURSOR_PATTERN.test(id)) return null;
48
+ const e = byId.get(id);
49
+ if (!e) return null;
50
+ if (e.expiresAt <= now()) { drop(id); return null; }
51
+ if (e.workspaceId !== workspaceId) return null;
52
+ return { jobId: e.jobId, next: e.next };
53
+ },
54
+ clear() { byId.clear(); byKey.clear(); },
55
+ tenantId,
56
+ };
57
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * dlp.js - the one place where chat text is masked (LA-3). The conversation is client content: it is masked BEFORE it is written to the ChatStore (the store
3
+ * never holds a secret) and again, per piece, on the way out (`job.stream`, `chat.session`). Pieces are masked one by one, never the JSON that carries them,
4
+ * so a mask can never reach across the structure of a body and make it invalid.
5
+ *
6
+ * Enforce only. Any other mode, a DLP failure or an input above the DLP limit is `ok:false` (fail closed). A hit in LOCAL_AGENT_DLP_BLOCK_CLASSES withholds the piece.
7
+ */
8
+ import { dlpConfigFromEnv, protectEgress, DENIED } from "../../dlp/index.js";
9
+ import { OVERSIZE } from "../../dlp/engine.js";
10
+
11
+ /** What replaces a withheld piece of text (a turn). */
12
+ export const WITHHELD_TEXT = "[withheld by the Local Agent DLP]";
13
+ /** What replaces a withheld event line: a valid event of the hosted format, so the front end renders it like any status. */
14
+ export const WITHHELD_LINE = JSON.stringify({ type: "status", content: WITHHELD_TEXT });
15
+
16
+ /**
17
+ * @returns {{ok:true, text:string, blocked:boolean, total:number, redactions:object} | {ok:false}}
18
+ */
19
+ export function maskChatText(text, { env = process.env, source = "chat" } = {}) {
20
+ const cfg = dlpConfigFromEnv(env);
21
+ if (cfg.mode !== "enforce") return { ok: false };
22
+ if (typeof text !== "string") return { ok: false };
23
+ if (text.length > cfg.maxBytes) return { ok: false };
24
+ const res = protectEgress(text, { env, source });
25
+ if (res.denied || res.text === DENIED || res.text === OVERSIZE) return { ok: false };
26
+ return { ok: true, text: res.text, blocked: !!res.blocked, total: res.total, redactions: res.redactions };
27
+ }
28
+
29
+ /** Masks a piece and applies the withhold rule: a blocked or un-maskable piece becomes `withheld`. */
30
+ export function maskOrWithhold(text, withheld, opts) {
31
+ const m = maskChatText(text, opts);
32
+ if (!m.ok) return { text: withheld, withheld: true, total: 0, redactions: {} };
33
+ if (m.blocked) return { text: withheld, withheld: true, total: m.total, redactions: m.redactions };
34
+ return { text: m.text, withheld: false, total: m.total, redactions: m.redactions };
35
+ }
36
+
37
+ const MAX_JSON_DEPTH = 64;
38
+ const MAX_JSON_NODES = 20_000;
39
+
40
+ /**
41
+ * Masks ONE event line of agent2 (a JSON string) on its DECODED strings. Masking the serialized line is wrong: inside JSON the quotes of a value are `\"`, the
42
+ * assignment patterns with a quoted value stop matching and the secret stays in the clear. So: JSON.parse, mask every decoded string (values and keys,
43
+ * recursively), re-serialize. A string value under a key is masked together with its key (`key: value`), because that is the context a secret is recognised by
44
+ * (`{"db_password":"..."}`). A line that is not valid JSON is masked as text. Any failure (DLP not enforce, oversize, depth, a blocked class, a context that
45
+ * cannot be told apart from the value) withholds the whole line: fail closed.
46
+ *
47
+ * @returns {{text:string, withheld:boolean, total:number, redactions:object}}
48
+ */
49
+ export function maskEventLine(line, withheld, opts) {
50
+ let parsed;
51
+ try { parsed = JSON.parse(line); } catch { return maskOrWithhold(line, withheld, opts); }
52
+
53
+ const fail = { text: withheld, withheld: true, total: 0, redactions: {} };
54
+ let total = 0;
55
+ let nodes = 0;
56
+ const redactions = {};
57
+ let bad = false;
58
+ const add = (m) => { total += m.total || 0; for (const [k, n] of Object.entries(m.redactions || {})) redactions[k] = (redactions[k] || 0) + n; };
59
+ const piece = (s) => {
60
+ const m = maskChatText(s, opts);
61
+ if (!m.ok || m.blocked) { bad = true; return s; }
62
+ add(m);
63
+ return m.text;
64
+ };
65
+ const SEP = ": ";
66
+ const value = (key, s) => {
67
+ if (key === null) return piece(s);
68
+ // the key gives the context; it is removed again from the masked text. If the mask touched the key, the cut is not safe: withhold.
69
+ const ctx = `${key}${SEP}`;
70
+ const out = piece(ctx + s);
71
+ if (bad) return s;
72
+ if (!out.startsWith(ctx)) { bad = true; return s; }
73
+ return out.slice(ctx.length);
74
+ };
75
+ const walk = (v, key, depth) => {
76
+ if (bad) return v;
77
+ if (++nodes > MAX_JSON_NODES || depth > MAX_JSON_DEPTH) { bad = true; return v; }
78
+ if (typeof v === "string") return value(key, v);
79
+ if (v === null || typeof v !== "object") return v;
80
+ if (Array.isArray(v)) return v.map((x) => walk(x, key, depth + 1));
81
+ const entries = [];
82
+ for (const [k, x] of Object.entries(v)) {
83
+ const mk = piece(k); // a key can be a secret too (a map keyed by token)
84
+ entries.push([mk, walk(x, mk, depth + 1)]); // the value is judged with its (masked) key as context
85
+ }
86
+ return Object.fromEntries(entries); // own data properties, even for a key named __proto__
87
+ };
88
+ let out;
89
+ try { out = walk(parsed, null, 0); } catch { return fail; }
90
+ if (bad) return fail;
91
+ return { text: JSON.stringify(out), withheld: false, total, redactions };
92
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * index.js - assembles the chat of the Local Agent (LA-3): the ChatStore, the cursor registry and the `chat` dependency the content handlers take.
3
+ * Built by runtime.js only with LOCAL_AGENT_CHAT=true and every prerequisite (see config.js).
4
+ */
5
+ import { createAudit } from "../content-handlers/audit.js";
6
+ import { createChatStore } from "./store.js";
7
+ import { createCursorRegistry } from "./cursors.js";
8
+
9
+ export { resolveChatConfig, chatRequested, CHAT_FLAG } from "./config.js";
10
+ export { consumeAgent2Stream } from "./stream-source.js";
11
+
12
+ /**
13
+ * @param {object} o
14
+ * @param {object} o.config resolveChatConfig() (enabled)
15
+ * @param {string} o.tenantId
16
+ * @param {(jobId:string)=>boolean} [o.isActive] true while the job manager still holds that chat job (queued, cloning, running): a request that comes first waits for it
17
+ */
18
+ export function createChat({ config, tenantId, env = process.env, log = console, now = Date.now, isActive = () => false, audit = null }) {
19
+ const auditor = audit || createAudit({ now });
20
+ const store = createChatStore({ dir: config.dir, tenantId, config, env, now, log, audit: auditor, isActive: (id) => { try { return !!isActive(id); } catch { return false; } } });
21
+ const cursors = createCursorRegistry({ tenantId, ttlMs: config.cursorTtlMs, maxEntries: config.maxCursors, now });
22
+ return {
23
+ config, store, cursors, audit: auditor,
24
+ /** What the content handler needs to serve `job.stream` and `chat.session`. */
25
+ content: Object.freeze({ store, cursors, chatConfig: config }),
26
+ close() { store.close(); cursors.clear(); },
27
+ };
28
+ }