@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.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * lib/context/history-scope.mjs — which interaction logs may be read for THIS
3
+ * reply.
4
+ *
5
+ * THE DEFECT THIS EXISTS TO CLOSE
6
+ *
7
+ * `scripts/daemon/responder.mjs#logInteraction` files every exchange under
8
+ * `memory/interactions/<service>/…`, and it files each one TWICE: once under the
9
+ * room (`<channelId>`) and once under the person (`dm-<sender-slug>`). The two
10
+ * readers — `prompt-builder.mjs` and `context-compiler.mjs` — then merged every
11
+ * candidate directory into one timestamp-sorted block headed "Recent
12
+ * conversation history with this sender".
13
+ *
14
+ * While that read was pinned to `…/slack/…` the merge was inert for every other
15
+ * service. Reading the item's own service (design §3 R12) makes it live, and
16
+ * Cohort is the one service where a person's DMs and the public rooms they post
17
+ * in share a namespace. So: Dana DMs the agent something private, later
18
+ * @-mentions the agent in a public channel, and the public room's prompt
19
+ * carries the DM sentence verbatim. That is design §5.5's resource-scope leak —
20
+ * "per-person facts may inform a reply but are NEVER quoted into a shared room"
21
+ * — arriving through the back door of a directory list.
22
+ *
23
+ * THE RULE, stated once so both readers and the writer cannot drift:
24
+ *
25
+ * A per-person directory is readable only when the reply itself is going to
26
+ * that person privately. In a shared room only the room's own log is read.
27
+ * Unknown is not private: an item that does not positively say it is a DM is
28
+ * treated as a room.
29
+ *
30
+ * PURE. Everything arrives on the argument; nothing is read from disk, the
31
+ * clock or the environment.
32
+ *
33
+ * @module lib/context/history-scope
34
+ */
35
+
36
+ "use strict";
37
+
38
+ /** A blank-safe string. */
39
+ function s(v) {
40
+ return v == null ? "" : String(v);
41
+ }
42
+
43
+ /**
44
+ * Is this inbound item a private 1:1 (or small group DM) conversation?
45
+ *
46
+ * The signals, in the order they are trusted:
47
+ * 1. `is_dm` — set from Cohort's `channel_kind` (`lib/org/inbound/project.mjs`)
48
+ * and from Slack's `channel_type` (`scripts/poller/utils.mjs`). A literal
49
+ * `false` is a positive assertion of "room" and ends the question.
50
+ * 2. `channel_type` — Slack's own envelope field: `im` / `mpim`.
51
+ * 3. A Slack channel id beginning `D` — the convention the raw_ref parser in
52
+ * `prompt-builder.mjs` already relies on.
53
+ * 4. A `dm/<name>` channel label, which is what the Telegram and Slack
54
+ * adapters write for a private chat.
55
+ *
56
+ * Anything else is a room. That direction is deliberate and is the opposite of
57
+ * this module's neighbours: everywhere else in hydration an unknown degrades
58
+ * towards MORE context, because the cost is a thinner prompt. Here the cost of
59
+ * guessing wrong is one person's private sentence in a public channel, so
60
+ * unknown buys nothing.
61
+ *
62
+ * @param {object} item inbound item (`{service, is_dm, channel_type, channel_id, channel}`)
63
+ * @returns {boolean}
64
+ */
65
+ export function isPrivateConversation(item) {
66
+ if (!item || typeof item !== "object") return false;
67
+ if (item.is_dm === true) return true;
68
+ if (item.is_dm === false) return false;
69
+
70
+ const type = s(item.channel_type).toLowerCase();
71
+ if (type === "im" || type === "mpim") return true;
72
+ if (type === "channel" || type === "group") return false;
73
+
74
+ // Slack's own id convention. `service` defaults to slack here for the same
75
+ // reason both readers write `String(item.service || "slack")` — an item that
76
+ // predates the per-service split carries no service field, and a `D…` id on
77
+ // such an item is a Slack DM.
78
+ const service = s(item.service) || "slack";
79
+ const channelId = s(item.channel_id || item.channelId);
80
+ if (service === "slack" && /^D[A-Z0-9]+$/.test(channelId)) return true;
81
+
82
+ if (/^dm\//i.test(s(item.channel))) return true;
83
+
84
+ return false;
85
+ }
86
+
87
+ /**
88
+ * The interaction-log directory NAMES this reply may read, relative to
89
+ * `memory/interactions/<service>/`. Callers join them onto the service root.
90
+ *
91
+ * Ordered room-first so a merged read still prefers the room's own record.
92
+ * Slack keeps its legacy bare `<sender-slug>` directory alongside the `dm-`
93
+ * one — historical logs live there and must not go dark — and it is subject to
94
+ * exactly the same gate.
95
+ *
96
+ * @param {object} o
97
+ * @param {string|null} [o.channelId] the room/DM channel id, when known
98
+ * @param {string|null} [o.senderSlug] the sender, slugified
99
+ * @param {boolean} [o.isPrivate] is the reply going to that person privately
100
+ * @param {string} [o.service]
101
+ * @returns {string[]} directory names, possibly empty
102
+ */
103
+ export function historyDirNames({ channelId, senderSlug, isPrivate, service } = {}) {
104
+ const dirs = [];
105
+ const cid = s(channelId);
106
+ const slug = s(senderSlug);
107
+ if (cid) dirs.push(cid);
108
+ if (isPrivate === true && slug) {
109
+ dirs.push(`dm-${slug}`);
110
+ if ((s(service) || "slack") === "slack") dirs.push(slug);
111
+ }
112
+ return dirs;
113
+ }
114
+
115
+ /**
116
+ * The directories `logInteraction` may WRITE this exchange to.
117
+ *
118
+ * The read gate above is the one that stops the leak, but leaving the writer
119
+ * unconditional keeps every per-person directory permanently contaminated with
120
+ * public-room content — so a future reader, or an operator grepping the tree,
121
+ * meets the same defect again. One rule, both ends.
122
+ *
123
+ * @param {object} o
124
+ * @param {string|null} [o.channelId]
125
+ * @param {string|null} [o.senderSlug]
126
+ * @param {boolean} [o.isPrivate]
127
+ * @returns {string[]}
128
+ */
129
+ export function interactionWriteDirNames({ channelId, senderSlug, isPrivate } = {}) {
130
+ const dirs = [];
131
+ const cid = s(channelId);
132
+ const slug = s(senderSlug);
133
+ if (cid) dirs.push(cid);
134
+ if (isPrivate === true && slug) dirs.push(`dm-${slug}`);
135
+ return dirs;
136
+ }
137
+
138
+ export default { isPrivateConversation, historyDirNames, interactionWriteDirNames };
@@ -0,0 +1,79 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import {
5
+ isPrivateConversation,
6
+ historyDirNames,
7
+ interactionWriteDirNames,
8
+ } from "./history-scope.mjs";
9
+
10
+ test("is_dm is the authoritative signal, in both directions", () => {
11
+ assert.equal(isPrivateConversation({ is_dm: true }), true);
12
+ assert.equal(isPrivateConversation({ is_dm: false, channel_type: "im" }), false,
13
+ "an explicit is_dm:false is an assertion of 'room' and outranks a stale channel_type");
14
+ });
15
+
16
+ test("Slack's channel_type and the D-prefixed channel id both read as private", () => {
17
+ assert.equal(isPrivateConversation({ service: "slack", channel_type: "im" }), true);
18
+ assert.equal(isPrivateConversation({ service: "slack", channel_type: "mpim" }), true);
19
+ assert.equal(isPrivateConversation({ service: "slack", channel_id: "D099N1JGKRQ" }), true);
20
+ assert.equal(isPrivateConversation({ service: "slack", channel_id: "C099N1JGKRQ" }), false);
21
+ assert.equal(isPrivateConversation({ service: "slack", channel_type: "channel", channel_id: "D1" }), false);
22
+ });
23
+
24
+ test("a missing service reads as Slack — the historical default both readers use", () => {
25
+ assert.equal(isPrivateConversation({ channel_id: "D001TESTCH" }), true);
26
+ assert.equal(isPrivateConversation({ channel_id: "C001TESTCH" }), false);
27
+ assert.deepEqual(
28
+ historyDirNames({ channelId: "D001TESTCH", senderSlug: "alice-smith", isPrivate: true }),
29
+ ["D001TESTCH", "dm-alice-smith", "alice-smith"],
30
+ "…including its legacy bare-slug directory",
31
+ );
32
+ });
33
+
34
+ test("a dm/<name> label counts, and everything unknown is a ROOM", () => {
35
+ assert.equal(isPrivateConversation({ service: "telegram", channel: "dm/dana" }), true);
36
+ assert.equal(isPrivateConversation({ service: "cohort", channel_id: "c_capital_formation" }), false,
37
+ "unknown is not private — guessing wrong here puts a private sentence in a public room");
38
+ assert.equal(isPrivateConversation(null), false);
39
+ assert.equal(isPrivateConversation("nope"), false);
40
+ });
41
+
42
+ test("a shared room reads ONLY the room's own log — never a per-person directory", () => {
43
+ const dirs = historyDirNames({
44
+ channelId: "c_capital_formation", senderSlug: "dana", isPrivate: false, service: "cohort",
45
+ });
46
+ assert.deepEqual(dirs, ["c_capital_formation"]);
47
+ assert.equal(dirs.some((d) => d.includes("dana")), false);
48
+ });
49
+
50
+ test("a DM reads the room and the person; Slack also keeps its legacy bare slug", () => {
51
+ assert.deepEqual(
52
+ historyDirNames({ channelId: "c_dm", senderSlug: "dana", isPrivate: true, service: "cohort" }),
53
+ ["c_dm", "dm-dana"],
54
+ );
55
+ assert.deepEqual(
56
+ historyDirNames({ channelId: "D1", senderSlug: "dana", isPrivate: true, service: "slack" }),
57
+ ["D1", "dm-dana", "dana"],
58
+ );
59
+ });
60
+
61
+ test("the WRITER is gated by the same rule, so per-person logs stay per-person", () => {
62
+ assert.deepEqual(
63
+ interactionWriteDirNames({ channelId: "c_public", senderSlug: "dana", isPrivate: false }),
64
+ ["c_public"],
65
+ );
66
+ assert.deepEqual(
67
+ interactionWriteDirNames({ channelId: "c_dm", senderSlug: "dana", isPrivate: true }),
68
+ ["c_dm", "dm-dana"],
69
+ );
70
+ assert.deepEqual(
71
+ interactionWriteDirNames({ channelId: null, senderSlug: "dana", isPrivate: true }),
72
+ ["dm-dana"],
73
+ );
74
+ assert.deepEqual(
75
+ interactionWriteDirNames({ channelId: null, senderSlug: "dana", isPrivate: false }),
76
+ [],
77
+ "a room exchange with no room id is not filed under the person as a consolation prize",
78
+ );
79
+ });
@@ -310,6 +310,8 @@ const DEFERRABLE_PREFIXES = Object.freeze([
310
310
  // the live conversational / inbox path.
311
311
  const REALTIME_TASK_CLASSES = Object.freeze(new Set([
312
312
  "session.responder",
313
+ "session.answer",
314
+ "session.plan",
313
315
  "classify.inbox",
314
316
  "huddle",
315
317
  "voice.brief",
@@ -556,6 +558,13 @@ const HAIKU_EXPLORE = Object.freeze({
556
558
  // cheap classes where a subagent would only add overhead.
557
559
  const SPAWN_KNOBS = Object.freeze({
558
560
  "session.responder": { maxTurns: 40, effort: null, agentsJson: HAIKU_EXPLORE },
561
+ // The reply tiers (design §5.6 / §5.1). Until now `dispatcher.mjs` hardcoded
562
+ // `session.responder` for every spawn, so `effort` was ALWAYS null and
563
+ // `maxTurns` always 40 — complexity moved the model and the timeout and
564
+ // nothing else. An answer gets low effort and a short leash; a plan gets high
565
+ // effort, a longer one, and the cheap-subagent map it will actually use.
566
+ "session.answer": { maxTurns: 6, effort: "low", agentsJson: null },
567
+ "session.plan": { maxTurns: 60, effort: "high", agentsJson: HAIKU_EXPLORE },
559
568
  "classify.inbox": { maxTurns: 1, effort: null, agentsJson: null },
560
569
  lookup: { maxTurns: 2, effort: null, agentsJson: null },
561
570
  enrich: { maxTurns: 3, effort: null, agentsJson: null },
@@ -93,6 +93,12 @@ const TASK_CLASS_MAX_TURNS = Object.freeze({
93
93
  "voice.brief": 4,
94
94
  "learning.reflect": 6,
95
95
  "learning.curate": 6,
96
+ // The reply tiers (design §5.6). `session.responder` keeps the historical
97
+ // default; an ANSWER is a question the classifier already judged answerable,
98
+ // so a 40-turn ceiling is 39 turns of rope it does not need, and a PLAN is
99
+ // the rung that genuinely has multi-step work to do.
100
+ "session.answer": 6,
101
+ "session.plan": 60,
96
102
  });
97
103
 
98
104
  // ---------------------------------------------------------------------------
@@ -76,7 +76,7 @@ export const DEFAULT_LIMITS = Object.freeze({
76
76
  * @property {Map<string,Set<string>>} decisionVoices
77
77
  * @property {Map<string,object>|null} escalations
78
78
  * @property {Map<string,object>|null} approvals
79
- * @property {Map<string,object>|null} fileAcl fileId → {ownerId, shared, mentionsMe}
79
+ * @property {Map<string,object>|null} fileAcl fileId → {ownerId, shared, mentionsMe, kind}
80
80
  * @property {Set<string>|null} myEventIds calendar events I own or attend (null == unreadable)
81
81
  * @property {Map<string,object>|null} myEvents eventId → the calendar event DTO
82
82
  * @property {string[]} degraded names of facts that could not be read
@@ -602,7 +602,9 @@ async function resolveFileFacts({ candidates, facts, io, limits, log, degraded }
602
602
  const permission = String(res.permission || "").toLowerCase();
603
603
  const ownerId = String(file.ownerId || file.owner || "") || null;
604
604
  const shared = Boolean(permission && permission !== "none");
605
- const entry = { ownerId, shared, permission, mentionsMe: false, name: file.name || "" };
605
+ // `kind` (DOC | SHEET | DECK | …) rides along so the doc hydrator can skip a
606
+ // `files.docRead` that hq would answer BAD_REQUEST for. One field, no read.
607
+ const entry = { ownerId, shared, permission, mentionsMe: false, name: file.name || "", kind: String(file.kind || "") };
606
608
 
607
609
  if (!shared && !(ownerId && ownerId === facts.me) && facts.myNames.length > 0) {
608
610
  const cf = await io.call("files.comments", { fileId, limit: 20 });