@cohortapp/agent-sdk 2.16.0 → 2.17.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.
@@ -11,7 +11,17 @@ import { promises as fsp } from "fs";
11
11
  import { tmpdir } from "os";
12
12
  import { join } from "path";
13
13
 
14
- import { routingKey, createRouter } from "./session-router.mjs";
14
+ import {
15
+ routingKey,
16
+ createRouter,
17
+ createRouterSync,
18
+ decideRoute,
19
+ routerItemFromDaemonItem,
20
+ claimSession,
21
+ releaseSession,
22
+ isSessionInFlight,
23
+ _resetInFlightForTests,
24
+ } from "./session-router.mjs";
15
25
 
16
26
  function tmpRegistryPath(suffix = "") {
17
27
  const name = `session-router-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${suffix}.json`;
@@ -293,3 +303,252 @@ test("registry round-trip — second router instance reads persisted state", asy
293
303
  assert.equal(dec.decision, "RESUME");
294
304
  assert.equal(dec.resumeId, "cli-A");
295
305
  });
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // 4. Conversation-shaped services (design §3 R10 / §5.6).
309
+ //
310
+ // `cohort` fell through to the throw, so every turn in the agent's own
311
+ // workspace was keyed by nothing and spawned cold. telegram / whatsapp /
312
+ // orgmail were in the same position.
313
+ // ---------------------------------------------------------------------------
314
+
315
+ test("routingKey — cohort channel with no thread is keyed by the room", () => {
316
+ assert.equal(routingKey({ source: "cohort", channel: "chan-abc" }), "cohort:chan-abc");
317
+ });
318
+
319
+ test("routingKey — cohort thread narrows the key, so two threads never share a session", () => {
320
+ const a = routingKey({ source: "cohort", channel: "chan-abc", thread_root_id: "msg-1" });
321
+ const b = routingKey({ source: "cohort", channel: "chan-abc", thread_root_id: "msg-2" });
322
+ assert.equal(a, "cohort:chan-abc:msg-1");
323
+ assert.notEqual(a, b);
324
+ });
325
+
326
+ test("routingKey — telegram / whatsapp / orgmail share the shape", () => {
327
+ assert.equal(routingKey({ source: "telegram", channel: "12345" }), "telegram:12345");
328
+ assert.equal(routingKey({ source: "whatsapp", channel: "4471@s.whatsapp.net" }), "whatsapp:4471@s.whatsapp.net");
329
+ assert.equal(routingKey({ source: "orgmail", channel: "mb-1", thread_id: "t-9" }), "orgmail:mb-1:t-9");
330
+ });
331
+
332
+ test("routingKey — a cohort item with no channel throws rather than colliding on a blank key", () => {
333
+ assert.throws(() => routingKey({ source: "cohort" }), /missing channel/);
334
+ });
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // 5. routerItemFromDaemonItem — ONE translation, both callers.
338
+ // ---------------------------------------------------------------------------
339
+
340
+ test("routerItemFromDaemonItem — a cohort item keys on channel_id, never the label", () => {
341
+ const out = routerItemFromDaemonItem({
342
+ service: "cohort",
343
+ channel: "task/Ship the thing", // the DISPLAY label
344
+ channel_id: "chan-real",
345
+ thread_id: "root-7",
346
+ });
347
+ assert.deepEqual(out, { source: "cohort", channel: "chan-real", thread_root_id: "root-7" });
348
+ assert.equal(routingKey(out), "cohort:chan-real:root-7");
349
+ });
350
+
351
+ test("routerItemFromDaemonItem — a cohort item with only a label has no session to continue", () => {
352
+ assert.equal(routerItemFromDaemonItem({ service: "cohort", channel: "doc/Pricing memo" }), null);
353
+ });
354
+
355
+ test("routerItemFromDaemonItem — slack / gmail / calendar are unchanged", () => {
356
+ assert.deepEqual(routerItemFromDaemonItem({ service: "slack", channel: "C1", thread_id: "1.2", ts: "3.4" }),
357
+ { source: "slack", channel: "C1", thread_ts: "1.2", ts: "3.4" });
358
+ assert.deepEqual(routerItemFromDaemonItem({ service: "gmail", thread_id: "t1" }), { source: "gmail", thread_id: "t1" });
359
+ assert.deepEqual(routerItemFromDaemonItem({ service: "calendar", event_id: "e1" }), { source: "calendar", event_id: "e1" });
360
+ });
361
+
362
+ test("routerItemFromDaemonItem — an unknown service is not keyed", () => {
363
+ assert.equal(routerItemFromDaemonItem({ service: "carrier-pigeon", channel_id: "x" }), null);
364
+ assert.equal(routerItemFromDaemonItem(null), null);
365
+ });
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // 6. decideRoute — the pure rule both IO shells share.
369
+ // ---------------------------------------------------------------------------
370
+
371
+ test("decideRoute — the memo §4.4 table", () => {
372
+ const live = { claude_session_id: "S1", last_used_at: 1000, status: "live", last_exit_code: 0 };
373
+ assert.deepEqual(decideRoute(undefined, { now: 1000, ttlSeconds: 30 }), { decision: "EPHEMERAL", resumeId: null });
374
+ assert.deepEqual(decideRoute(live, { now: 1000, ttlSeconds: 30 }), { decision: "RESUME", resumeId: "S1" });
375
+ assert.equal(decideRoute(live, { now: 1000 + 31_000, ttlSeconds: 30 }).decision, "EPHEMERAL_REPLACE");
376
+ assert.equal(decideRoute({ ...live, status: "killed" }, { now: 1000, ttlSeconds: 30 }).decision, "EPHEMERAL_REPLACE");
377
+ assert.equal(decideRoute({ ...live, last_exit_code: 1 }, { now: 1000, ttlSeconds: 30 }).decision, "EPHEMERAL_REPLACE");
378
+ });
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // 7. createRouterSync — the dispatcher's face of the SAME registry.
382
+ // ---------------------------------------------------------------------------
383
+
384
+ test("createRouterSync — a cold key is EPHEMERAL; a touched key then RESUMEs", async () => {
385
+ const path = tmpRegistryPath("-sync");
386
+ try {
387
+ const r = createRouterSync({ registryPath: path });
388
+ assert.equal(r.route("cohort:c1").decision, "EPHEMERAL");
389
+ r.touch("cohort:c1", { claudeSessionId: "S-live", daemonSessionId: "s-1", model: "sonnet" });
390
+ const again = r.route("cohort:c1");
391
+ assert.equal(again.decision, "RESUME");
392
+ assert.equal(again.resumeId, "S-live");
393
+ } finally {
394
+ await safeUnlink(path);
395
+ }
396
+ });
397
+
398
+ test("createRouterSync — the SYNC and ASYNC routers read the same file", async () => {
399
+ const path = tmpRegistryPath("-shared");
400
+ try {
401
+ const sync = createRouterSync({ registryPath: path });
402
+ sync.touch("cohort:c2", { claudeSessionId: "S-shared" });
403
+ const async_ = await createRouter({ registryPath: path });
404
+ const d = async_.route("cohort:c2");
405
+ assert.equal(d.decision, "RESUME");
406
+ assert.equal(d.resumeId, "S-shared", "one registry, or the dispatcher and the responder key two sessions per room");
407
+ } finally {
408
+ await safeUnlink(path);
409
+ }
410
+ });
411
+
412
+ test("createRouterSync — a non-zero exit stops the next turn resuming into a broken session", async () => {
413
+ const path = tmpRegistryPath("-exit");
414
+ try {
415
+ const r = createRouterSync({ registryPath: path });
416
+ r.touch("cohort:c3", { claudeSessionId: "S-bad" });
417
+ r.recordExit("cohort:c3", 1);
418
+ assert.equal(r.route("cohort:c3").decision, "EPHEMERAL_REPLACE");
419
+ } finally {
420
+ await safeUnlink(path);
421
+ }
422
+ });
423
+
424
+ test("createRouterSync — an unreadable registry routes cold rather than throwing", () => {
425
+ const r = createRouterSync({ registryPath: join(tmpdir(), "definitely", "not", "a", "path", "registry.json") });
426
+ assert.equal(r.route("cohort:c4").decision, "EPHEMERAL");
427
+ assert.equal(r.recordExit("cohort:c4", 0), false);
428
+ });
429
+
430
+ test("createRouterSync — a corrupt registry routes cold rather than throwing", async () => {
431
+ const path = tmpRegistryPath("-corrupt");
432
+ try {
433
+ await fsp.writeFile(path, "{ this is not json", "utf-8");
434
+ const r = createRouterSync({ registryPath: path });
435
+ assert.equal(r.route("cohort:c5").decision, "EPHEMERAL");
436
+ } finally {
437
+ await safeUnlink(path);
438
+ }
439
+ });
440
+
441
+ test("createRouterSync — the LRU cap is enforced", async () => {
442
+ const path = tmpRegistryPath("-lru");
443
+ try {
444
+ const r = createRouterSync({ registryPath: path, maxLiveSessions: 2 });
445
+ r.touch("cohort:a", { claudeSessionId: "A" });
446
+ r.touch("cohort:b", { claudeSessionId: "B" });
447
+ r.touch("cohort:c", { claudeSessionId: "C" });
448
+ assert.equal(r.route("cohort:a").decision, "EPHEMERAL", "oldest evicted");
449
+ assert.equal(r.route("cohort:c").decision, "RESUME");
450
+ } finally {
451
+ await safeUnlink(path);
452
+ }
453
+ });
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // TWO SHELLS, ONE FILE
457
+ //
458
+ // The dispatcher writes this registry through createRouterSync and the
459
+ // responder through createRouter. The async shell used to read the file once
460
+ // and keep the object for the daemon's life, so its `touch()` wrote a stale
461
+ // snapshot back — and the LRU sweep at the end of `touch()` deletes every key
462
+ // absent from that snapshot, which means one ordinary quick reply erased the
463
+ // dispatcher's live rows. The converse was worse than a lost row: the cached
464
+ // shell could answer RESUME for a session the other shell had already recorded
465
+ // as killed, handing a 60-second --print reply the transcript of a long
466
+ // agentic session.
467
+ // ---------------------------------------------------------------------------
468
+
469
+ test("an async touch() does not delete rows the SYNC shell wrote", async (t) => {
470
+ const path = tmpRegistryPath("-two-shells");
471
+ t.after(() => safeUnlink(path));
472
+
473
+ const async_ = await createRouter({ registryPath: path });
474
+ const sync = createRouterSync({ registryPath: path });
475
+
476
+ await async_.touch("slack:D1", { claudeSessionId: "S-QUICK" });
477
+ sync.touch("cohort:C-eng", { claudeSessionId: "S-LONG" });
478
+
479
+ // One perfectly ordinary quick reply on the other key.
480
+ await async_.touch("slack:D1", { claudeSessionId: "S-QUICK" });
481
+
482
+ const onDisk = JSON.parse(await fsp.readFile(path, "utf-8"));
483
+ assert.deepEqual(
484
+ Object.keys(onDisk.sessions).sort(),
485
+ ["cohort:C-eng", "slack:D1"],
486
+ "the async shell must merge onto what is on disk, not overwrite it with a snapshot",
487
+ );
488
+ assert.equal(sync.route("cohort:C-eng").decision, "RESUME", "§5.6 continuity survives an interleaved quick reply");
489
+ });
490
+
491
+ test("an async route() sees an exit the SYNC shell recorded — no resume into a killed session", async (t) => {
492
+ const path = tmpRegistryPath("-two-shells-exit");
493
+ t.after(() => safeUnlink(path));
494
+
495
+ const async_ = await createRouter({ registryPath: path });
496
+ const sync = createRouterSync({ registryPath: path });
497
+
498
+ sync.touch("cohort:C-eng", { claudeSessionId: "S-LONG" });
499
+ assert.equal(async_.route("cohort:C-eng").decision, "RESUME", "a live row resumes from either shell");
500
+
501
+ sync.recordExit("cohort:C-eng", 137);
502
+ const after = async_.route("cohort:C-eng");
503
+ assert.equal(after.decision, "EPHEMERAL_REPLACE", "a killed session must not be resumed by the other shell");
504
+ assert.equal(after.resumeId, null);
505
+ });
506
+
507
+ test("an async recordExit() is visible to the sync shell, and vice versa", async (t) => {
508
+ const path = tmpRegistryPath("-two-shells-sym");
509
+ t.after(() => safeUnlink(path));
510
+
511
+ const async_ = await createRouter({ registryPath: path });
512
+ const sync = createRouterSync({ registryPath: path });
513
+
514
+ sync.touch("cohort:C-x", { claudeSessionId: "S-1" });
515
+ await async_.recordExit("cohort:C-x", 1);
516
+ assert.equal(sync.route("cohort:C-x").decision, "EPHEMERAL_REPLACE");
517
+ });
518
+
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // the in-flight lease — one CLI process per session id
522
+ // ---------------------------------------------------------------------------
523
+
524
+ test("claimSession is exclusive, and releaseSession is idempotent", () => {
525
+ _resetInFlightForTests();
526
+ assert.equal(claimSession("cohort:C-eng"), true);
527
+ assert.equal(claimSession("cohort:C-eng"), false, "a second claim on a live key is refused");
528
+ assert.equal(isSessionInFlight("cohort:C-eng"), true);
529
+ releaseSession("cohort:C-eng");
530
+ releaseSession("cohort:C-eng");
531
+ assert.equal(isSessionInFlight("cohort:C-eng"), false);
532
+ assert.equal(claimSession("cohort:C-eng"), true);
533
+ _resetInFlightForTests();
534
+ assert.equal(claimSession(""), false, "an empty key is not a key");
535
+ });
536
+
537
+ test("neither shell resumes a key that already has a process on it", async (t) => {
538
+ const path = tmpRegistryPath("-inflight");
539
+ t.after(() => { _resetInFlightForTests(); return safeUnlink(path); });
540
+ _resetInFlightForTests();
541
+
542
+ const sync = createRouterSync({ registryPath: path });
543
+ const async_ = await createRouter({ registryPath: path });
544
+ sync.touch("cohort:C-busy", { claudeSessionId: "S-LIVE" });
545
+
546
+ assert.equal(sync.route("cohort:C-busy").decision, "RESUME", "idle: the row is resumable");
547
+ claimSession("cohort:C-busy");
548
+ assert.deepEqual(sync.route("cohort:C-busy"), { decision: "EPHEMERAL", resumeId: null });
549
+ assert.deepEqual(async_.route("cohort:C-busy"), { decision: "EPHEMERAL", resumeId: null },
550
+ "a 60-second quick reply must not be handed a running agentic session's transcript");
551
+
552
+ releaseSession("cohort:C-busy");
553
+ assert.equal(sync.route("cohort:C-busy").decision, "RESUME", "continuity returns when the room goes quiet");
554
+ });
@@ -13,6 +13,7 @@ import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
13
13
  import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
14
14
  import { outcomeSourceShareable } from "./session-outcomes.mjs";
15
15
  import { withParallelism } from "../../lib/prompts/parallelism.mjs";
16
+ import { isPrivateConversation, historyDirNames } from "../../lib/context/history-scope.mjs";
16
17
 
17
18
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
18
19
 
@@ -38,6 +39,74 @@ function loadAgent() {
38
39
  // Legacy: Maximum lines of conversation history (used when DAEMON_CONTEXT_COMPILER is off)
39
40
  const MAX_HISTORY_LINES = 30;
40
41
 
42
+ /**
43
+ * Is the compiled-context path on?
44
+ *
45
+ * IT DEFAULTS ON, and that is the fix. The code read `=== "1"` while
46
+ * `.env.example:231` shipped `DAEMON_CONTEXT_COMPILER=1`, so which of two
47
+ * materially different context paths ran depended on whether the operator had
48
+ * copied the example env — an agent set up by hand got the legacy path and an
49
+ * agent set up from the template got the compiled one, with nothing anywhere
50
+ * saying so (design §3 R11).
51
+ *
52
+ * One path ships. The legacy path stays reachable for a seat that has to turn
53
+ * it off in a hurry (`DAEMON_CONTEXT_COMPILER=0`), which is why the flag is not
54
+ * simply deleted.
55
+ *
56
+ * A value that is neither a recognised on nor a recognised off is the dangerous
57
+ * case, because it selects between two materially different context paths and
58
+ * an operator who wrote `disabled` meant OFF. The word lists below are wide
59
+ * enough to cover what people actually write, and {@link contextCompilerSetting}
60
+ * reports the leftovers so the caller can say so out loud instead of resolving
61
+ * a typo in silence.
62
+ *
63
+ * @param {NodeJS.ProcessEnv} [env]
64
+ * @returns {boolean}
65
+ */
66
+ export function contextCompilerEnabled(env = process.env) {
67
+ return contextCompilerSetting(env).enabled;
68
+ }
69
+
70
+ /** Values that mean OFF. Wider than `"0"` because operators write words. */
71
+ const CONTEXT_COMPILER_OFF = Object.freeze(["0", "false", "off", "no", "n", "disabled", "disable", "none"]);
72
+
73
+ /** Values that mean ON. */
74
+ const CONTEXT_COMPILER_ON = Object.freeze(["1", "true", "on", "yes", "y", "enabled", "enable"]);
75
+
76
+ /**
77
+ * The flag, and whether the operator's word was one we know.
78
+ *
79
+ * PURE — the env arrives on the argument.
80
+ *
81
+ * @param {NodeJS.ProcessEnv} [env]
82
+ * @returns {{enabled:boolean, recognised:boolean, raw:string}}
83
+ */
84
+ export function contextCompilerSetting(env = process.env) {
85
+ const raw = String((env && env.DAEMON_CONTEXT_COMPILER) ?? "").trim().toLowerCase();
86
+ if (!raw) return { enabled: true, recognised: true, raw };
87
+ if (CONTEXT_COMPILER_OFF.includes(raw)) return { enabled: false, recognised: true, raw };
88
+ if (CONTEXT_COMPILER_ON.includes(raw)) return { enabled: true, recognised: true, raw };
89
+ return { enabled: true, recognised: false, raw };
90
+ }
91
+
92
+ /** Warn once per process about a DAEMON_CONTEXT_COMPILER value we do not know. */
93
+ let warnedAboutContextCompiler = false;
94
+ function warnIfContextCompilerUnrecognised(env = process.env) {
95
+ if (warnedAboutContextCompiler) return;
96
+ const setting = contextCompilerSetting(env);
97
+ if (setting.recognised) return;
98
+ warnedAboutContextCompiler = true;
99
+ console.warn(
100
+ `[prompt-builder] DAEMON_CONTEXT_COMPILER="${setting.raw}" is not a value this reads. ` +
101
+ `Using the compiled context path. Set it to 0 to use the legacy path.`,
102
+ );
103
+ }
104
+
105
+ /** For tests: allow the one-time warning to fire again. */
106
+ export function _resetContextCompilerWarning() {
107
+ warnedAboutContextCompiler = false;
108
+ }
109
+
41
110
  // Org shared-knowledge injection (org enrollment). Bounded so a large recall can
42
111
  // never blow up the prompt: at most this many facts, each truncated.
43
112
  const ORG_RECALL_MAX_FACTS = 6;
@@ -603,8 +672,22 @@ function today() {
603
672
 
604
673
  /**
605
674
  * Load recent conversation history for a sender/channel from interaction logs.
606
- * Searches memory/interactions/slack/{channel-or-sender}/ for recent JSONL entries.
607
- * Returns the most recent entries as formatted context text.
675
+ *
676
+ * IT READS THE ITEM'S OWN SERVICE. `responder.mjs#logInteraction` files every
677
+ * exchange under `memory/interactions/<service>/…` and has done since the
678
+ * write-half was fixed; this read looked only under `…/slack/…`, so a Cohort,
679
+ * Telegram or WhatsApp conversation was written down and then never found
680
+ * (design §3 R12). The agent had the record and could not see it.
681
+ *
682
+ * Slack keeps its legacy `<sender-slug>` directory alongside the `dm-` one —
683
+ * historical logs live there and must not go dark on this change.
684
+ *
685
+ * AND IT READS ONLY WHAT THIS ROOM MAY SEE. `logInteraction` files every
686
+ * exchange under BOTH the room and `dm-<sender-slug>`; merging all of them into
687
+ * one block put a person's private DM sentence into the prompt built for a
688
+ * public channel the moment this read stopped being Slack-only. The gate is
689
+ * `lib/context/history-scope.mjs`, shared with the writer and with the compiled
690
+ * path so the three cannot drift (design §5.5, resource scope).
608
691
  */
609
692
  function loadConversationHistory(item) {
610
693
  if (!item) return null;
@@ -613,13 +696,14 @@ function loadConversationHistory(item) {
613
696
  // Try multiple paths where interaction logs may live
614
697
  const channelId = extractChannelId(item);
615
698
  const senderSlug = item.sender ? item.sender.replace(/\s+/g, "-").toLowerCase() : null;
699
+ const service = String(item.service || "slack");
616
700
 
617
- const candidateDirs = [];
618
- if (channelId) candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", channelId));
619
- if (senderSlug) {
620
- candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", `dm-${senderSlug}`));
621
- candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", senderSlug));
622
- }
701
+ const candidateDirs = historyDirNames({
702
+ channelId,
703
+ senderSlug,
704
+ isPrivate: isPrivateConversation(item),
705
+ service,
706
+ }).map((name) => join(AGENT_REPO_DIR, "memory", "interactions", service, name));
623
707
 
624
708
  const entries = [];
625
709
 
@@ -681,7 +765,7 @@ function loadConversationHistory(item) {
681
765
  */
682
766
  function extractChannelId(item) {
683
767
  if (!item) return null;
684
- // Direct channel ID field
768
+ // Direct channel ID field — every non-Slack service sets this.
685
769
  if (item.channel_id) return item.channel_id;
686
770
  // From raw_ref format: "slack:D099N1JGKRQ:1775331885.690669"
687
771
  if (item.raw_ref) {
@@ -1007,7 +1091,8 @@ export async function buildPrompt(item, classResult, options = {}) {
1007
1091
  // (context-compiler.mjs → loadOrgMemory), so skip here to avoid a duplicate
1008
1092
  // recall + duplicated context in the same prompt. Backlog items pass their
1009
1093
  // queueItem into compileContext (below) so entity coverage is preserved.
1010
- if (process.env.DAEMON_CONTEXT_COMPILER !== "1") {
1094
+ warnIfContextCompilerUnrecognised();
1095
+ if (!contextCompilerEnabled()) {
1011
1096
  const orgBlock = await buildOrgKnowledgeBlock(item, classResult, queueItem);
1012
1097
  if (orgBlock) {
1013
1098
  parts.push(orgBlock);
@@ -1016,7 +1101,7 @@ export async function buildPrompt(item, classResult, options = {}) {
1016
1101
  }
1017
1102
 
1018
1103
  // 4. Compiled context (profile + history + disclosure boundaries + active sessions + sent messages)
1019
- if (process.env.DAEMON_CONTEXT_COMPILER === "1" && (type === "inbox" ? item : true)) {
1104
+ if (contextCompilerEnabled() && (type === "inbox" ? item : true)) {
1020
1105
  try {
1021
1106
  const { contextBlock } = await compileContext(
1022
1107
  item || {},
@@ -1056,7 +1141,7 @@ export async function buildPrompt(item, classResult, options = {}) {
1056
1141
  parts.push("");
1057
1142
 
1058
1143
  // 6. User profile lookup — legacy only (when feature flag is off, profile is in compiled context)
1059
- if (process.env.DAEMON_CONTEXT_COMPILER !== "1" && type === "inbox" && item && item.sender) {
1144
+ if (!contextCompilerEnabled() && type === "inbox" && item && item.sender) {
1060
1145
  const profileName = item.sender.replace(/\s+/g, "-").toLowerCase();
1061
1146
  parts.push(`Before responding, read memory/profiles/users/${profileName}.yaml for sender preferences, standing instructions, and relationship context. If the file does not exist, proceed without it.`);
1062
1147
  parts.push("");