Skip to content

Commit a585109

Browse files
kapaleshreyasclaude
andcommitted
feat(slack-bot): daily company-knowledge distiller → PR (no PII)
Adds a harness-side pipeline that distills GENERIC company knowledge from the Slack bot's agent_logs and opens a daily PR to the bot's GAP repo, with a deterministic PII scrub + dedup so no user-specific/personal data is learned. - examples/knowledge-distiller.ts: gather agent_logs since watermark → Anthropic distill (forced-tool JSON) → PII scrub → dedup vs knowledge_index → open/append one PR/day via GitHub Contents API → advance watermark. Scheduler + pure helpers. - examples/agent-log-store.ts: listSince() + close(). - examples/slack-bot.ts: opt-in scheduler start (LYRA_LEARN=1) in createSlackBotsApp. - examples/distill-now.ts: --dry-run CLI. - examples/knowledge-distiller.test.ts: PII-scrub + dedup + repo-parse unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 40f7970 commit a585109

5 files changed

Lines changed: 698 additions & 8 deletions

File tree

examples/agent-log-store.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,26 @@ export class AgentLogStore {
9090
return (await this.coll()).findOne({ _id: id });
9191
}
9292

93+
/**
94+
* Successful entries for a bot newer than `since`, oldest-first — the input
95+
* for the knowledge distiller's daily batch. Only `ok` turns are returned
96+
* (errored turns carry no learnable content). `max` caps the batch size.
97+
*/
98+
async listSince(bot: string, since: Date, max = 5000): Promise<AgentLogEntry[]> {
99+
return (await this.coll())
100+
.find({ bot, ok: true, ts: { $gt: since } })
101+
.sort({ ts: 1 })
102+
.limit(Math.min(Math.max(max, 1), 20000))
103+
.toArray();
104+
}
105+
93106
/** Count entries for an agent (used for the agents-list stats). */
94107
async count(bot: string): Promise<number> {
95108
return (await this.coll()).countDocuments({ bot });
96109
}
110+
111+
/** Close the underlying Mongo connection (for one-shot CLIs). */
112+
async close(): Promise<void> {
113+
if (this.connected) await this.client.close();
114+
}
97115
}

examples/distill-now.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* distill-now — run the company-knowledge distiller once, on demand.
3+
*
4+
* node --experimental-strip-types examples/distill-now.ts [--dry-run]
5+
* bun run examples/distill-now.ts --dry-run
6+
*
7+
* Reads the same env the Slack bot uses: MONGO_URL, MONGO_DATABASE,
8+
* GITAGENT_ANTHROPIC_API_KEY, and (repo/token) LYRA_LEARN_REPO or SLACK_LYRA_SOURCE
9+
* + SLACK_LYRA_GIT_TOKEN / GITHUB_TOKEN. --dry-run distills + scrubs + dedups and
10+
* prints what WOULD be proposed, without touching git or the watermark.
11+
*/
12+
import { KnowledgeDistiller } from "./knowledge-distiller.ts";
13+
14+
const dryRun = process.argv.includes("--dry-run");
15+
16+
const mongoUrl = process.env.MONGO_URL;
17+
const mongoDb = process.env.MONGO_DATABASE ?? "computeragent";
18+
const anthropicKey = process.env.GITAGENT_ANTHROPIC_API_KEY;
19+
const bot = process.env.LYRA_LEARN_BOT ?? "lyra";
20+
const repo = process.env.LYRA_LEARN_REPO ?? process.env.SLACK_LYRA_SOURCE;
21+
const gitToken = process.env.SLACK_LYRA_GIT_TOKEN ?? process.env.GITHUB_TOKEN ?? "";
22+
23+
if (!mongoUrl) {
24+
console.error("MONGO_URL is required");
25+
process.exit(1);
26+
}
27+
if (!anthropicKey) {
28+
console.error("GITAGENT_ANTHROPIC_API_KEY is required");
29+
process.exit(1);
30+
}
31+
if (!repo) {
32+
console.error("Set LYRA_LEARN_REPO (or SLACK_LYRA_SOURCE) to the target repo");
33+
process.exit(1);
34+
}
35+
if (!dryRun && !gitToken) {
36+
console.error("A git token (SLACK_LYRA_GIT_TOKEN / GITHUB_TOKEN) is required for a real run");
37+
process.exit(1);
38+
}
39+
40+
const distiller = new KnowledgeDistiller({
41+
mongoUrl,
42+
mongoDb,
43+
bot,
44+
repo,
45+
gitToken,
46+
anthropicKey,
47+
model: process.env.LYRA_LEARN_MODEL,
48+
});
49+
50+
try {
51+
console.log(`[distill-now] bot=${bot} repo=${repo} dryRun=${dryRun}`);
52+
const r = await distiller.runOnce({ dryRun });
53+
console.log(
54+
`\nthreads=${r.scannedThreads} extracted=${r.extracted} afterScrub=${r.afterScrub} fresh=${r.fresh}`,
55+
);
56+
if (r.freshStatements.length) {
57+
console.log("\nWould propose:");
58+
for (const s of r.freshStatements) console.log(` - ${s}`);
59+
}
60+
if (r.prUrl) console.log(`\nPR: ${r.prUrl}`);
61+
if (r.skippedReason) console.log(`\n(${r.skippedReason})`);
62+
} catch (e) {
63+
console.error("[distill-now] failed:", (e as Error).message);
64+
process.exitCode = 1;
65+
} finally {
66+
await distiller.close();
67+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Unit tests for the knowledge distiller's pure logic: the PII scrub (the hard
3+
* requirement — no personal/user-specific data may survive), dedup, hashing, and
4+
* repo parsing. These need no Mongo or network.
5+
*/
6+
import { describe, expect, it } from "vitest";
7+
import { dedupe, normalizeHash, parseRepo, piiReject } from "./knowledge-distiller.ts";
8+
9+
describe("piiReject", () => {
10+
it("accepts generic company facts", () => {
11+
expect(piiReject("The company deploys agents via a bubblewrap sandbox on EC2.")).toBeNull();
12+
expect(piiReject("The billing service charges per active sandbox-hour.")).toBeNull();
13+
expect(piiReject("Releases are cut from the deploy branch, which rebuilds the ECR images.")).toBeNull();
14+
});
15+
16+
it("drops emails, tokens, and JWTs", () => {
17+
expect(piiReject("Contact the owner at jane.doe@acme.com for access.")).not.toBeNull();
18+
expect(piiReject("The service key is sk-ant-api03-abcdef0123456789ABCDEF.")).not.toBeNull();
19+
expect(piiReject("Deploy token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.")).not.toBeNull();
20+
expect(piiReject("Session eyJhbGciOiJIUzI1NiIsInR5cCI.eyJzdWIiOiIxMjM0NTY.abcdef123456")).not.toBeNull();
21+
expect(piiReject("AWS key AKIAIOSFODNN7EXAMPLE was rotated.")).not.toBeNull();
22+
});
23+
24+
it("drops Slack mentions/user ids and phone numbers", () => {
25+
expect(piiReject("Ping <@U012AB3CD> when the build is green.")).not.toBeNull();
26+
expect(piiReject("The on-call reaches U08ZZ12QKP for escalation.")).not.toBeNull();
27+
expect(piiReject("Call the support line at +1 (415) 555-0198 for urgent issues.")).not.toBeNull();
28+
});
29+
30+
it("drops personal attribution ('X said/wants/prefers')", () => {
31+
expect(piiReject("Shreyas prefers the reports in a table format.")).not.toBeNull();
32+
expect(piiReject("Abhishek asked for the migration to run overnight.")).not.toBeNull();
33+
expect(piiReject("Sarah said the onboarding flow is confusing.")).not.toBeNull();
34+
expect(piiReject("My email is on file for the newsletter.")).not.toBeNull();
35+
});
36+
37+
it("drops trivially short statements", () => {
38+
expect(piiReject("Yes.")).toBe("too-short");
39+
expect(piiReject("ok thanks")).toBe("too-short");
40+
});
41+
});
42+
43+
describe("normalizeHash + dedupe", () => {
44+
it("hashes semantically-equal statements to the same value", () => {
45+
const a = normalizeHash("The company uses MongoDB Atlas.");
46+
const b = normalizeHash("the company uses mongodb atlas!!!");
47+
expect(a).toBe(b);
48+
});
49+
50+
it("skips statements already in the seen set and within the batch", () => {
51+
const seen = new Set([normalizeHash("The platform runs on EKS.")]);
52+
const { fresh, hashes } = dedupe(
53+
[
54+
"The platform runs on EKS.", // already seen
55+
"The API is rate-limited to 4 concurrent runs.", // new
56+
"the api is rate limited to 4 concurrent runs", // dup of the above within-batch
57+
],
58+
seen,
59+
);
60+
expect(fresh).toEqual(["The API is rate-limited to 4 concurrent runs."]);
61+
expect(hashes).toHaveLength(1);
62+
});
63+
});
64+
65+
describe("parseRepo", () => {
66+
it("parses bare, https, and .git forms", () => {
67+
expect(parseRepo("github.com/example-org/agent-repo")).toEqual({ owner: "example-org", name: "agent-repo" });
68+
expect(parseRepo("https://github.com/example-org/agent-repo.git")).toEqual({ owner: "example-org", name: "agent-repo" });
69+
expect(parseRepo("git@github.com:example-org/agent-repo.git")).toEqual({ owner: "example-org", name: "agent-repo" });
70+
});
71+
72+
it("throws on unparseable input", () => {
73+
expect(() => parseRepo("not-a-repo")).toThrow();
74+
});
75+
});

0 commit comments

Comments
 (0)