@cohortapp/agent-sdk 2.6.1 → 2.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/bin/maestro.mjs +68 -0
- package/bin/maestro.test.mjs +81 -1
- package/package.json +1 -1
- package/scripts/daemon/responder-history.test.mjs +169 -0
- package/scripts/daemon/responder.mjs +86 -4
package/bin/maestro.mjs
CHANGED
|
@@ -785,6 +785,43 @@ function isPruneExcluded(repoRel) {
|
|
|
785
785
|
return PRUNE_EXCLUDE_PREFIXES.some((p) => repoRel.startsWith(p));
|
|
786
786
|
}
|
|
787
787
|
|
|
788
|
+
// Record of what the framework actually delivered to THIS machine, written at
|
|
789
|
+
// the end of every non-dry upgrade. Per-machine state, hence .maestro/.
|
|
790
|
+
//
|
|
791
|
+
// Prune needs to answer "did the framework once ship this file?", and git
|
|
792
|
+
// cannot answer it. A tracked-and-clean file absent from upstream is EITHER a
|
|
793
|
+
// framework file deleted upstream OR a file the operator wrote and committed
|
|
794
|
+
// themselves — the two are byte-for-byte indistinguishable in git status.
|
|
795
|
+
// Getting that wrong destroys operator work: the first cut of this pass
|
|
796
|
+
// proposed deleting an agent's pre-commit secret-scan hook (protecting a live
|
|
797
|
+
// org credential) and its hand-authored workflows, purely because the SDK had
|
|
798
|
+
// never shipped them.
|
|
799
|
+
//
|
|
800
|
+
// So provenance is recorded explicitly. Prune considers a file only if the
|
|
801
|
+
// PREVIOUS manifest lists it — proof this machine received it from the SDK.
|
|
802
|
+
// A machine with no manifest yet prunes nothing and starts clean from the next
|
|
803
|
+
// upgrade; that is the safe direction to fail.
|
|
804
|
+
const SHIPPED_MANIFEST_REL = ".maestro/shipped-manifest.json";
|
|
805
|
+
|
|
806
|
+
function loadShippedManifest(cwd) {
|
|
807
|
+
try {
|
|
808
|
+
const raw = JSON.parse(readFileSync(join(cwd, SHIPPED_MANIFEST_REL), "utf-8"));
|
|
809
|
+
if (Array.isArray(raw?.files)) return new Set(raw.files);
|
|
810
|
+
} catch {
|
|
811
|
+
// Absent or unreadable — caller treats this as "provenance unknown".
|
|
812
|
+
}
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function writeShippedManifest(cwd, files) {
|
|
817
|
+
const dst = join(cwd, SHIPPED_MANIFEST_REL);
|
|
818
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
819
|
+
writeFileSync(
|
|
820
|
+
dst,
|
|
821
|
+
JSON.stringify({ version: 1, files: [...files].sort() }, null, 2) + "\n",
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
|
|
788
825
|
function sha256File(p) {
|
|
789
826
|
return createHash("sha256").update(readFileSync(p)).digest("hex");
|
|
790
827
|
}
|
|
@@ -1174,6 +1211,8 @@ Per-file behaviour:
|
|
|
1174
1211
|
const ignoredFiles = [];
|
|
1175
1212
|
const prunedFiles = [];
|
|
1176
1213
|
const pruneKeptFiles = [];
|
|
1214
|
+
// What upstream ships right now — becomes this machine's provenance record.
|
|
1215
|
+
const shippedNow = new Set();
|
|
1177
1216
|
|
|
1178
1217
|
for (const { path: relRoot, mode } of UPGRADE_PATHS) {
|
|
1179
1218
|
const srcRoot = join(MAESTRO_ROOT, relRoot);
|
|
@@ -1185,6 +1224,9 @@ Per-file behaviour:
|
|
|
1185
1224
|
const relFromRoot = relative(srcRoot, srcFile);
|
|
1186
1225
|
const dstFile = join(dstRoot, relFromRoot);
|
|
1187
1226
|
const repoRel = relative(cwd, dstFile).split(sep).join("/");
|
|
1227
|
+
// Recorded regardless of what we then decide to do with the file: the
|
|
1228
|
+
// manifest answers "does the framework ship this?", not "did we write it?"
|
|
1229
|
+
shippedNow.add(repoRel);
|
|
1188
1230
|
|
|
1189
1231
|
const isIgnored = matchesIgnore(repoRel, ignorePatterns);
|
|
1190
1232
|
|
|
@@ -1287,10 +1329,15 @@ Per-file behaviour:
|
|
|
1287
1329
|
//
|
|
1288
1330
|
// Removals are backed up under .maestro/backup/ exactly like a forced
|
|
1289
1331
|
// overwrite, so a prune is always reversible.
|
|
1332
|
+
const priorManifest = loadShippedManifest(cwd);
|
|
1333
|
+
|
|
1290
1334
|
if (flags.noPrune) {
|
|
1291
1335
|
log("Prune skipped (--no-prune): upstream-deleted files left in place.");
|
|
1292
1336
|
} else if (!inGit) {
|
|
1293
1337
|
warn("Prune skipped: not a git repo, so pristine framework files can't be told from yours.");
|
|
1338
|
+
} else if (!priorManifest) {
|
|
1339
|
+
log("Prune skipped: no shipped-manifest yet, so framework files can't be told from your own.");
|
|
1340
|
+
log(`Recording one now (${SHIPPED_MANIFEST_REL}); prune takes effect from the next upgrade.`);
|
|
1294
1341
|
} else {
|
|
1295
1342
|
for (const { path: relRoot, mode } of UPGRADE_PATHS) {
|
|
1296
1343
|
// merge-mode roots hold deliberately-local files — nothing to reconcile.
|
|
@@ -1313,6 +1360,15 @@ Per-file behaviour:
|
|
|
1313
1360
|
continue;
|
|
1314
1361
|
}
|
|
1315
1362
|
|
|
1363
|
+
// Provenance gate: unless a previous upgrade recorded delivering this
|
|
1364
|
+
// file, it is the operator's until proven otherwise. See the note on
|
|
1365
|
+
// SHIPPED_MANIFEST_REL — this is what stops prune eating hand-authored
|
|
1366
|
+
// scripts, workflows and policies that merely sit under a smart root.
|
|
1367
|
+
if (!priorManifest.has(repoRel)) {
|
|
1368
|
+
if (flags.verbose) console.log(` · ${repoRel} (never shipped by the framework — yours)`);
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1316
1372
|
if (matchesIgnore(repoRel, ignorePatterns)) {
|
|
1317
1373
|
counts.ignored++;
|
|
1318
1374
|
ignoredFiles.push(repoRel);
|
|
@@ -1341,6 +1397,18 @@ Per-file behaviour:
|
|
|
1341
1397
|
}
|
|
1342
1398
|
}
|
|
1343
1399
|
|
|
1400
|
+
// Record provenance for the next run. Written even when nothing was pruned —
|
|
1401
|
+
// it is what makes the FOLLOWING upgrade able to prune safely. Never written
|
|
1402
|
+
// on a dry run, so --dry-run stays genuinely side-effect-free.
|
|
1403
|
+
if (!flags.dryRun) {
|
|
1404
|
+
try {
|
|
1405
|
+
writeShippedManifest(cwd, shippedNow);
|
|
1406
|
+
} catch (err) {
|
|
1407
|
+
// Non-fatal: a missing manifest costs pruning, not correctness.
|
|
1408
|
+
warn(`Could not write ${SHIPPED_MANIFEST_REL}: ${err.message}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1344
1412
|
// Ensure standard runtime directories exist.
|
|
1345
1413
|
const ensureDirs = [
|
|
1346
1414
|
"state/handoffs", "state/huddle", "state/indexes", "state/rag",
|
package/bin/maestro.test.mjs
CHANGED
|
@@ -1160,7 +1160,7 @@ test("doctor surfaces the new framework systems (org / secrets / diagnostics)",
|
|
|
1160
1160
|
* Agent repo carrying `orphans` — paths upstream does not ship. Written before
|
|
1161
1161
|
* the commit so git sees them as tracked and clean (prune's precondition).
|
|
1162
1162
|
*/
|
|
1163
|
-
async function makeAgentWithOrphans(orphans) {
|
|
1163
|
+
async function makeAgentWithOrphans(orphans, opts = {}) {
|
|
1164
1164
|
const root = await tmpRoot("maestro-orphan");
|
|
1165
1165
|
mkdirSync(join(root, "config"), { recursive: true });
|
|
1166
1166
|
writeFileSync(join(root, "CLAUDE.md"), "# agent\n");
|
|
@@ -1170,6 +1170,17 @@ async function makeAgentWithOrphans(orphans) {
|
|
|
1170
1170
|
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
1171
1171
|
writeFileSync(join(root, rel), body);
|
|
1172
1172
|
}
|
|
1173
|
+
// Provenance: prune only touches files a PREVIOUS upgrade recorded shipping.
|
|
1174
|
+
// Without this the orphans are indistinguishable from operator-authored work
|
|
1175
|
+
// and prune correctly refuses to act — so every prune test must seed it.
|
|
1176
|
+
if (opts.seedManifest !== false) {
|
|
1177
|
+
mkdirSync(join(root, ".maestro"), { recursive: true });
|
|
1178
|
+
writeFileSync(
|
|
1179
|
+
join(root, ".maestro/shipped-manifest.json"),
|
|
1180
|
+
JSON.stringify({ version: 1, files: Object.keys(orphans).sort() }, null, 2) + "\n",
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1173
1184
|
execFileSync("git", ["init", "-q"], { cwd: root });
|
|
1174
1185
|
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
1175
1186
|
execFileSync(
|
|
@@ -1322,3 +1333,72 @@ test("upgrade prune never removes machine-generated launchd plists", async () =>
|
|
|
1322
1333
|
await fsp.rm(root, { recursive: true, force: true });
|
|
1323
1334
|
}
|
|
1324
1335
|
});
|
|
1336
|
+
|
|
1337
|
+
test("upgrade prune spares operator-authored files the framework never shipped", async () => {
|
|
1338
|
+
// The hazard that forced the provenance manifest. scripts/ is a smart root,
|
|
1339
|
+
// so an operator's own committed script there is tracked, clean and absent
|
|
1340
|
+
// upstream — every signal git can offer says "orphan". On a real agent
|
|
1341
|
+
// machine this set included the pre-commit hook guarding a live org
|
|
1342
|
+
// credential. Provenance, not cleanliness, is what makes prune safe.
|
|
1343
|
+
const mine = "scripts/pre-commit-secret-scan.sh";
|
|
1344
|
+
const root = await makeAgentWithOrphans({
|
|
1345
|
+
[mine]: "#!/bin/sh\nexit 0\n",
|
|
1346
|
+
[ORPHAN]: "export const gone = 1;\n",
|
|
1347
|
+
});
|
|
1348
|
+
try {
|
|
1349
|
+
// Manifest records ONLY the framework file — the operator's script was
|
|
1350
|
+
// never delivered by the SDK, so it must not be a prune candidate.
|
|
1351
|
+
writeFileSync(
|
|
1352
|
+
join(root, ".maestro/shipped-manifest.json"),
|
|
1353
|
+
JSON.stringify({ version: 1, files: [ORPHAN] }, null, 2) + "\n",
|
|
1354
|
+
);
|
|
1355
|
+
execFileSync("git", ["add", "-A"], { cwd: root });
|
|
1356
|
+
execFileSync(
|
|
1357
|
+
"git",
|
|
1358
|
+
["-c", "user.email=test@test", "-c", "user.name=test", "commit", "-q", "-m", "manifest"],
|
|
1359
|
+
{ cwd: root },
|
|
1360
|
+
);
|
|
1361
|
+
|
|
1362
|
+
const r = runCli(["upgrade"], root);
|
|
1363
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1364
|
+
assert.ok(existsSync(join(root, mine)), "a file the framework never shipped is the operator's");
|
|
1365
|
+
assert.ok(!existsSync(join(root, ORPHAN)), "the genuinely-shipped orphan should still prune");
|
|
1366
|
+
} finally {
|
|
1367
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test("upgrade prunes nothing on a machine with no shipped-manifest, then records one", async () => {
|
|
1372
|
+
const root = await makeAgentWithOrphans({ [ORPHAN]: "export const gone = 1;\n" }, { seedManifest: false });
|
|
1373
|
+
try {
|
|
1374
|
+
const first = runCli(["upgrade"], root);
|
|
1375
|
+
assert.equal(first.status, 0, first.stderr);
|
|
1376
|
+
assert.ok(
|
|
1377
|
+
existsSync(join(root, ORPHAN)),
|
|
1378
|
+
"with no provenance record, prune must do nothing rather than guess",
|
|
1379
|
+
);
|
|
1380
|
+
assert.ok(
|
|
1381
|
+
existsSync(join(root, ".maestro/shipped-manifest.json")),
|
|
1382
|
+
"the first run must record provenance so the next one can prune",
|
|
1383
|
+
);
|
|
1384
|
+
const manifest = JSON.parse(readFileSync(join(root, ".maestro/shipped-manifest.json"), "utf-8"));
|
|
1385
|
+
assert.ok(manifest.files.includes("scripts/daemon/responder.mjs"), "manifest lists shipped files");
|
|
1386
|
+
assert.ok(!manifest.files.includes(ORPHAN), "manifest must not list what upstream doesn't ship");
|
|
1387
|
+
} finally {
|
|
1388
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1389
|
+
}
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
test("upgrade --dry-run writes no shipped-manifest", async () => {
|
|
1393
|
+
const root = await makeAgentWithOrphans({}, { seedManifest: false });
|
|
1394
|
+
try {
|
|
1395
|
+
const r = runCli(["upgrade", "--dry-run"], root);
|
|
1396
|
+
assert.equal(r.status, 0, r.stderr);
|
|
1397
|
+
assert.ok(
|
|
1398
|
+
!existsSync(join(root, ".maestro/shipped-manifest.json")),
|
|
1399
|
+
"--dry-run must stay side-effect-free, including provenance",
|
|
1400
|
+
);
|
|
1401
|
+
} finally {
|
|
1402
|
+
await fsp.rm(root, { recursive: true, force: true });
|
|
1403
|
+
}
|
|
1404
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cohortapp/agent-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Cohort Agent SDK \u2014 autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* responder.test.mjs — conversation history behind holding messages / quick replies.
|
|
3
|
+
* Run: node --test scripts/daemon/responder.test.mjs
|
|
4
|
+
*
|
|
5
|
+
* `loadConversationHistory` used to build its candidate directories as
|
|
6
|
+
* `memory/interactions/slack/<id>` unconditionally, and its writer
|
|
7
|
+
* (`logInteraction`) early-returns for any service that is not Slack. A Cohort
|
|
8
|
+
* DM therefore had nothing written and nothing to read, so every holding
|
|
9
|
+
* message for Cohort was generated from the current message alone. A bare "?"
|
|
10
|
+
* produced "What's on your mind?" — indistinguishable from amnesia, and the
|
|
11
|
+
* CEO read it as exactly that (2026-08-13, dm-h001-a016).
|
|
12
|
+
*
|
|
13
|
+
* Cohort has an authoritative `messaging.history`. These tests pin that the
|
|
14
|
+
* cohort branch reads it, orders it oldest-first, and NEVER throws — the
|
|
15
|
+
* holding path must degrade to "no history" rather than take the send down.
|
|
16
|
+
*/
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
import { test } from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { loadConversationHistory } from "./responder.mjs";
|
|
26
|
+
|
|
27
|
+
const cohortItem = {
|
|
28
|
+
sender: "Dana Okafor",
|
|
29
|
+
service: "cohort",
|
|
30
|
+
channel: "dm-h001-a016",
|
|
31
|
+
channel_id: "cmql1802601iihhzpf774vwbk",
|
|
32
|
+
content: "?",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Newest-first, the way messaging.history actually returns it.
|
|
36
|
+
const HISTORY = [
|
|
37
|
+
{ id: "m3", createdAt: "2026-08-13T05:47:51Z", authorName: "Dana Okafor", body: "with that in mind, fix these issues you just noted please" },
|
|
38
|
+
{ id: "m2", createdAt: "2026-08-12T13:52:53Z", authorName: "Isla Roselli", body: "Both are merged. #19 went in as 94f227c4." },
|
|
39
|
+
{ id: "m1", createdAt: "2026-08-12T13:47:30Z", authorName: "Dana Okafor", body: "why haven't you merged? please go ahead" },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
test("cohort: reads messaging.history and renders it oldest-first", async () => {
|
|
43
|
+
const seen = [];
|
|
44
|
+
const text = await loadConversationHistory(cohortItem, {
|
|
45
|
+
fetchHistory: async (params, o) => {
|
|
46
|
+
seen.push({ params, hasCfg: Boolean(o && o.cfg) });
|
|
47
|
+
return HISTORY;
|
|
48
|
+
},
|
|
49
|
+
loadOrgConfig: () => ({ org: { cohort: { enabled: true, base: "https://x", token: "t" } } }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
assert.ok(text, "expected a transcript, got null");
|
|
53
|
+
assert.match(text, /Dana Okafor: why haven't you merged/);
|
|
54
|
+
assert.match(text, /Isla Roselli: Both are merged/);
|
|
55
|
+
assert.match(text, /Dana Okafor: with that in mind/);
|
|
56
|
+
|
|
57
|
+
// Oldest-first: the model reads a conversation, not a reversed one.
|
|
58
|
+
assert.ok(
|
|
59
|
+
text.indexOf("why haven't you merged") < text.indexOf("with that in mind"),
|
|
60
|
+
"history must be rendered oldest-first",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// The channel it asks for is the item's channel_id, and cfg is ALWAYS passed:
|
|
64
|
+
// fetchHistory() fails open to [] when cfg is omitted, which is silently
|
|
65
|
+
// indistinguishable from an empty channel. That is the bug this file exists for.
|
|
66
|
+
assert.equal(seen.length, 1);
|
|
67
|
+
assert.equal(seen[0].params.channel, "cmql1802601iihhzpf774vwbk");
|
|
68
|
+
assert.equal(seen[0].hasCfg, true, "cfg must be passed or fetchHistory returns [] silently");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("cohort: an empty channel is null, not an empty transcript", async () => {
|
|
72
|
+
const text = await loadConversationHistory(cohortItem, {
|
|
73
|
+
fetchHistory: async () => [],
|
|
74
|
+
loadOrgConfig: () => ({}),
|
|
75
|
+
});
|
|
76
|
+
assert.equal(text, null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("cohort: a failing history call degrades to null and never throws", async () => {
|
|
80
|
+
const text = await loadConversationHistory(cohortItem, {
|
|
81
|
+
fetchHistory: async () => { throw new Error("cohort unreachable"); },
|
|
82
|
+
loadOrgConfig: () => ({}),
|
|
83
|
+
});
|
|
84
|
+
assert.equal(text, null);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("cohort: the current message is not fed back as its own history", async () => {
|
|
88
|
+
const text = await loadConversationHistory(
|
|
89
|
+
{ ...cohortItem, message_id: "m3" },
|
|
90
|
+
{
|
|
91
|
+
fetchHistory: async () => HISTORY,
|
|
92
|
+
loadOrgConfig: () => ({}),
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
assert.ok(text);
|
|
96
|
+
assert.doesNotMatch(text, /with that in mind/, "the inbound message itself must be excluded");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("slack: still reads the local interaction mirror", async () => {
|
|
100
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
|
|
101
|
+
const dir = join(root, "memory", "interactions", "slack", "D099N1JGKRQ");
|
|
102
|
+
mkdirSync(dir, { recursive: true });
|
|
103
|
+
writeFileSync(
|
|
104
|
+
join(dir, "2026-08-13.jsonl"),
|
|
105
|
+
[
|
|
106
|
+
JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "ship it" }),
|
|
107
|
+
JSON.stringify({ ts: "2026-08-13T05:01:00Z", from: "agent", content: "shipped" }),
|
|
108
|
+
].join("\n"),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const text = await loadConversationHistory(
|
|
112
|
+
{ sender: "Dana Okafor", service: "slack", channel: "D099N1JGKRQ", content: "?" },
|
|
113
|
+
{ agentRepoDir: root },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
assert.ok(text, "expected the slack mirror to still be read");
|
|
117
|
+
assert.match(text, /Dana Okafor: ship it/);
|
|
118
|
+
assert.match(text, /agent: shipped/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("cohort: falls back to the local mirror when the server is unreachable", async () => {
|
|
122
|
+
// Isla's original returned null outright for Cohort when messaging.history
|
|
123
|
+
// failed. Falling through to the mirror means a transient API blip degrades
|
|
124
|
+
// to cached context instead of to none — the mirror is a poor source of
|
|
125
|
+
// truth but a perfectly good backstop.
|
|
126
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
|
|
127
|
+
const dir = join(root, "memory", "interactions", "cohort", "chan-1");
|
|
128
|
+
mkdirSync(dir, { recursive: true });
|
|
129
|
+
writeFileSync(
|
|
130
|
+
join(dir, "2026-08-13.jsonl"),
|
|
131
|
+
JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "Dana Okafor", content: "cached line" }),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const text = await loadConversationHistory(
|
|
135
|
+
{ sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
|
|
136
|
+
{
|
|
137
|
+
agentRepoDir: root,
|
|
138
|
+
loadOrgConfig: () => ({}),
|
|
139
|
+
fetchHistory: async () => { throw new Error("network down"); },
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
assert.ok(text, "a server failure must not erase locally cached context");
|
|
144
|
+
assert.match(text, /Dana Okafor: cached line/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("cohort: server history wins over the local mirror", async () => {
|
|
148
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-responder-"));
|
|
149
|
+
const dir = join(root, "memory", "interactions", "cohort", "chan-1");
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
writeFileSync(
|
|
152
|
+
join(dir, "2026-08-13.jsonl"),
|
|
153
|
+
JSON.stringify({ ts: "2026-08-13T05:00:00Z", from: "stale", content: "outdated mirror line" }),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const text = await loadConversationHistory(
|
|
157
|
+
{ sender: "Dana Okafor", service: "cohort", channel: "chan-1", content: "?" },
|
|
158
|
+
{
|
|
159
|
+
agentRepoDir: root,
|
|
160
|
+
loadOrgConfig: () => ({}),
|
|
161
|
+
fetchHistory: async () => ([
|
|
162
|
+
{ id: "m2", authorName: "Dana Okafor", body: "authoritative line", createdAt: "2026-08-13T06:00:00Z" },
|
|
163
|
+
]),
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
assert.match(text, /authoritative line/, "the server is the source of truth");
|
|
168
|
+
assert.doesNotMatch(text, /outdated mirror line/, "the stale mirror must not be mixed in");
|
|
169
|
+
});
|
|
@@ -523,23 +523,105 @@ function loadUserProfile(sender) {
|
|
|
523
523
|
// Load recent conversation history for context
|
|
524
524
|
// ---------------------------------------------------------------------------
|
|
525
525
|
|
|
526
|
+
/** Cohort's message ids as this item carries them, so history can drop itself. */
|
|
527
|
+
function inboundMessageIds(item) {
|
|
528
|
+
const ids = new Set();
|
|
529
|
+
const add = (v) => { if (v) ids.add(String(v)); };
|
|
530
|
+
add(item.message_id);
|
|
531
|
+
// Inbox filenames prefix the service: "cohort-cmsr45u91…" → "cmsr45u91…".
|
|
532
|
+
if (item.id) { add(item.id); add(String(item.id).replace(/^[a-z]+-/, "")); }
|
|
533
|
+
// raw_ref is "cohort:dm:<messageId>:<seq>".
|
|
534
|
+
const m = typeof item.raw_ref === "string" ? item.raw_ref.match(/^cohort:[^:]+:([^:]+)/) : null;
|
|
535
|
+
if (m) add(m[1]);
|
|
536
|
+
return ids;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const COHORT_HISTORY_LIMIT = 12;
|
|
540
|
+
const COHORT_HISTORY_CHARS = 400;
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Cohort history straight from the server, via the same `messaging.history` the
|
|
544
|
+
* replies are posted to.
|
|
545
|
+
*
|
|
546
|
+
* The local mirror below is a cache the agent populates itself, so it carries
|
|
547
|
+
* context only if the agent has already been talking AND logging correctly.
|
|
548
|
+
* When the write half broke (logInteraction early-returned for every non-Slack
|
|
549
|
+
* service), the read half silently returned nothing, memory/interactions/ was
|
|
550
|
+
* never even created, and holding messages were composed from the inbound
|
|
551
|
+
* message alone — a bare "?" came back as "What's on your mind?" mid-thread.
|
|
552
|
+
*
|
|
553
|
+
* Asking the authority that stores the message removes that coupling: a fresh
|
|
554
|
+
* agent, or one whose mirror was wiped, still answers with full context.
|
|
555
|
+
*
|
|
556
|
+
* Fails open to null on every path. No amount of history is worth failing a send.
|
|
557
|
+
*/
|
|
558
|
+
async function loadCohortHistory(item, deps = {}) {
|
|
559
|
+
const channel = item.channel_id || item.channel || "";
|
|
560
|
+
if (!channel) return null;
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
const fetchHistory = deps.fetchHistory
|
|
564
|
+
|| (await import("../../lib/org/messaging.mjs")).fetchHistory;
|
|
565
|
+
const loadOrgConfig = deps.loadOrgConfig
|
|
566
|
+
|| (await import("../../lib/org/client.mjs")).loadOrgConfig;
|
|
567
|
+
const agentRoot = deps.agentRepoDir || process.env.AGENT_ROOT || process.env.AGENT_DIR || AGENT_REPO_DIR;
|
|
568
|
+
|
|
569
|
+
// cfg is mandatory. configFromAgent() reads base+token ONLY from the object
|
|
570
|
+
// handed in — it never loads config/org.yaml — so omitting it returns a
|
|
571
|
+
// fail-open [] that looks exactly like an empty channel.
|
|
572
|
+
const messages = await fetchHistory(
|
|
573
|
+
{ channel, limit: COHORT_HISTORY_LIMIT + 1 },
|
|
574
|
+
{ cfg: loadOrgConfig(agentRoot), agentRoot },
|
|
575
|
+
);
|
|
576
|
+
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
577
|
+
|
|
578
|
+
const self = inboundMessageIds(item);
|
|
579
|
+
const rows = messages
|
|
580
|
+
.filter((m) => m && !self.has(String(m.id)))
|
|
581
|
+
.slice(0, COHORT_HISTORY_LIMIT)
|
|
582
|
+
.reverse() // messaging.history is newest-first; a conversation reads the other way
|
|
583
|
+
.map((m) => {
|
|
584
|
+
const from = m.authorName || m.author?.name || m.authorId || "unknown";
|
|
585
|
+
const text = String(m.body ?? m.content ?? "").trim();
|
|
586
|
+
return `[${m.createdAt || ""}] ${from}: ${text.substring(0, COHORT_HISTORY_CHARS)}`;
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
return rows.length > 0 ? rows.join("\n") : null;
|
|
590
|
+
} catch (err) {
|
|
591
|
+
console.warn(`[responder] cohort history unavailable for ${item.sender}: ${err.message}`);
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
526
596
|
/**
|
|
527
597
|
* The reader half of `logInteraction`. It looked ONLY under
|
|
528
598
|
* `memory/interactions/slack/…`, so even once a Cohort exchange was written it
|
|
529
599
|
* would not have been found. Both halves are now keyed on the item's service.
|
|
600
|
+
*
|
|
601
|
+
* Cohort asks the server first (see loadCohortHistory) and falls back to the
|
|
602
|
+
* local mirror, so a transient API failure degrades to cached context rather
|
|
603
|
+
* than to none.
|
|
530
604
|
*/
|
|
531
|
-
function loadConversationHistory(item) {
|
|
605
|
+
export async function loadConversationHistory(item, deps = {}) {
|
|
532
606
|
if (!item || !item.sender || !item.service) return null;
|
|
533
607
|
|
|
534
608
|
const service = String(item.service);
|
|
609
|
+
|
|
610
|
+
if (service === "cohort") {
|
|
611
|
+
const remote = await loadCohortHistory(item, deps);
|
|
612
|
+
if (remote) return remote;
|
|
613
|
+
}
|
|
614
|
+
// Test seam: the mirror's location is a parameter, so a test can point at a
|
|
615
|
+
// tmpdir instead of mutating process.env for the whole worker.
|
|
616
|
+
const repoDir = deps.agentRepoDir || AGENT_REPO_DIR;
|
|
535
617
|
const senderSlug = item.sender.replace(/\s+/g, "-").toLowerCase();
|
|
536
618
|
const channelId = service === "slack"
|
|
537
619
|
? resolveSlackChannel(item)
|
|
538
620
|
: (item.channel_id || item.channel || null);
|
|
539
621
|
|
|
540
622
|
const candidateDirs = [];
|
|
541
|
-
if (channelId) candidateDirs.push(join(
|
|
542
|
-
candidateDirs.push(join(
|
|
623
|
+
if (channelId) candidateDirs.push(join(repoDir, "memory", "interactions", service, String(channelId)));
|
|
624
|
+
candidateDirs.push(join(repoDir, "memory", "interactions", service, `dm-${senderSlug}`));
|
|
543
625
|
|
|
544
626
|
const entries = [];
|
|
545
627
|
for (const dir of candidateDirs) {
|
|
@@ -621,7 +703,7 @@ Keep responses focused — 1-4 sentences for simple items, up to a short paragra
|
|
|
621
703
|
Match the sender's tone and urgency level.
|
|
622
704
|
${profile ? `\nSender profile:\n${profile}` : ""}`;
|
|
623
705
|
|
|
624
|
-
const conversationHistory = loadConversationHistory(item);
|
|
706
|
+
const conversationHistory = await loadConversationHistory(item);
|
|
625
707
|
|
|
626
708
|
const userContent = [
|
|
627
709
|
`From: ${item.sender} (${item.sender_privilege || "unknown"})`,
|