Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions starter/slack-internal-processing/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
PORT=3001
PUBLIC_BASE_URL=https://your-public-tunnel.example

# Slack sends interactions, mentions, and DMs to:
# PUBLIC_BASE_URL/api/tag/slack/webhook

# Keep these secret. Do not commit real values.
SLACK_SIGNING_SECRET=
SLACK_BOT_TOKEN=xoxb-...

# Inference: set one provider key.
ANTHROPIC_API_KEY=sk-...
# OPENAI_API_KEY=sk-...
# OPENAI_BASE_URL=
# GOOGLE_API_KEY=

# Optional inference overrides.
# INTX_PROVIDER=anthropic
# INTX_MODEL=claude-sonnet-4-6
7 changes: 7 additions & 0 deletions starter/slack-internal-processing/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules/
tmp/
*.log
.env
.env.*
!.env.example
!.env.*.example
75 changes: 75 additions & 0 deletions starter/slack-internal-processing/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# slack-internal-processing

A Slack thread workflow that turns an uploaded call transcript into a structured
digest with a two-step Corbits workflow:

@app mention → upload transcript.txt → summarize → extract → Slack digest thread

The thread collects a full `.txt` transcript; its filename becomes the call title. The summarize step
produces a concise summary and discussion points. The extract step identifies
companies and discrete claims. Every status and digest card stays in the
original mention thread.

This is a deliberately small example adapted from Scout's
internal-processing workflow. It does not include Granola ingestion, the Scout
knowledge database, artifacts, fact checking, PDFs, or run-diligence actions.

## Setup

1. Copy this directory, install, and create env:

cd slack-internal-processing
bun install
cp .env.example .env

Corbits Tag comes from `github:corbitsdev/corbits-tag` (already listed in
package.json). `bun install` is enough; this starter does not use git
submodules.

2. Expose port 3001 through an HTTPS tunnel.

3. Replace every placeholder URL in manifest.slack.json with:

https://your-public-tunnel.example/api/tag/slack/webhook

4. Create or update a Slack app from manifest.slack.json and reinstall it.
The webhook receives mentions and subscribed thread replies.

5. Set SLACK_SIGNING_SECRET, SLACK_BOT_TOKEN, and one inference provider key
in .env.

6. Start the service:

bun run start

## Use it

1. In a Slack channel, mention the app:

@corbits-internal-processing create a digest

2. In the thread the bot opens, upload the full transcript as a UTF-8 `.txt`
file. There is no modal input limit. Its filename becomes the title:

acme-investor-call.txt

3. The bot posts "Call digest started" and then the structured digest in that
same thread. Re-mention the app to run another digest after the current one
finishes.

## Verify locally

bun run typecheck
bun test
bun build src/cli.ts --target=bun --outdir /tmp/slack-internal-processing-build

## Files

| Path | Purpose |
| --- | --- |
| src/cli.ts | Corbits Tag mount and thread-reply intake handlers |
| src/session.ts | Slack-thread lifecycle and workflow run |
| src/workflow.ts | summarize → extract definition and real step invoker |
| src/parser.ts | Strict validation of the extracted digest |
| src/cards.ts | Status and final digest cards |
| manifest.slack.json | Slack events and scopes |
387 changes: 387 additions & 0 deletions starter/slack-internal-processing/bun.lock

Large diffs are not rendered by default.

48 changes: 48 additions & 0 deletions starter/slack-internal-processing/manifest.slack.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"display_information": {
"name": "corbits-internal-processing",
"description": "Turn a call transcript into a structured Corbits digest",
"background_color": "#000000"
},
"features": {
"app_home": {
"home_tab_enabled": false,
"messages_tab_enabled": true,
"messages_tab_read_only_enabled": false
},
"bot_user": {
"display_name": "corbits-internal-processing",
"always_online": true
}
},
"oauth_config": {
"scopes": {
"bot": [
"app_mentions:read",
"channels:history",
"chat:write",
"files:read",
"groups:history",
"im:history",
"reactions:write",
"users:read",
"users:read.email"
]
}
},
"settings": {
"event_subscriptions": {
"request_url": "https://your-public-tunnel.example/api/tag/slack/webhook",
"bot_events": [
"app_mention",
"message.channels",
"message.groups",
"message.im"
]
},
"org_deploy_enabled": false,
"socket_mode_enabled": false,
"is_hosted": false,
"token_rotation_enabled": false
}
}
31 changes: 31 additions & 0 deletions starter/slack-internal-processing/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "@corbits/example-slack-internal-processing",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "bun@1.3.14",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"start": "bun run src/cli.ts",
"test": "bun test src/parser.test.ts src/session.test.ts src/cards.test.ts src/workflow.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@chat-adapter/state-memory": "^4.34.0",
"@intx/agent": "0.2.2",
"@intx/storage-isogit": "0.2.2",
"@intx/workflow": "0.2.2",
"chat": "^4.34.0",
"corbits-tag": "github:corbitsdev/corbits-tag",
"hono": "^4.12.31"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"typescript": "^5.9.3"
}
}
40 changes: 40 additions & 0 deletions starter/slack-internal-processing/src/cards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test";

import { callDigestCards, callDigestIntakeCard } from "./cards";
import type { CallDigestResult } from "./types";

describe("call digest cards", () => {
test("intake card does not mention Scout", () => {
expect(JSON.stringify(callDigestIntakeCard())).not.toContain("Scout");
});

test("escapes model markdown while keeping intentional bold wrappers", () => {
const digest: CallDigestResult = {
callTitle: "Numbers_~draft~",
summary: "Spend was ~$420K last quarter.",
discussionPoints: [],
companies: [{ name: "Star*Corp", context: "Vendor with *emphasis*" }],
claims: [],
};
const blob = JSON.stringify(callDigestCards(digest));
expect(blob).toContain("\\\\~$420K");
expect(blob).toContain("*Star\\\\*Corp*");
expect(blob).toContain("Vendor with \\\\*emphasis\\\\*");
expect(blob).toContain("Numbers\\\\_\\\\~draft\\\\~");
});

test("chunks oversized sections across multiple cards", () => {
const longPoint = "p".repeat(2_000);
const digest: CallDigestResult = {
callTitle: "Long",
summary: "s",
discussionPoints: [longPoint, longPoint],
companies: [],
claims: [],
};
const discussionCards = callDigestCards(digest).filter((card) =>
JSON.stringify(card).includes("Key discussion points"),
);
expect(discussionCards.length).toBeGreaterThan(1);
});
});
127 changes: 127 additions & 0 deletions starter/slack-internal-processing/src/cards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { CallDigestResult } from "./types";

const MAX_CARD_TITLE_LENGTH = 150;
const MAX_CARD_BODY_LENGTH = 2_700;

export function callDigestIntakeCard(): string {
return [
"*Call digest ready*",
"",
"*Next step*",
"Upload the full call transcript as a `.txt` file in this thread. The bot will turn it into a concise digest, named companies, and follow-up claims.",
"",
"_The filename becomes the call title. Long transcripts are welcome._",
].join("\n");
}

export function statusCard(title: string, text: string): string {
return `*${clampTitle(title)}*\n\n${escapeGeneratedMrkdwn(truncateBody(text))}`;
}

export function callDigestCards(digest: CallDigestResult): string[] {
return [
...sectionCards(`Call digest: ${escapeGeneratedMrkdwn(digest.callTitle)}`, "Summary", [
escapeGeneratedMrkdwn(digest.summary),
]),
...sectionCards(
"Key discussion points",
"Key discussion points",
digest.discussionPoints.length === 0
? ["None identified."]
: digest.discussionPoints.map(
(point) => `• ${escapeGeneratedMrkdwn(point)}`,
),
),
...sectionCards(
"Companies mentioned",
"Companies mentioned",
digest.companies.length === 0
? ["None identified."]
: digest.companies.map((company) => {
const website =
company.website === undefined
? ""
: ` — ${escapeGeneratedMrkdwn(company.website)}`;
return `• *${escapeGeneratedMrkdwn(company.name)}*${website}\n${escapeGeneratedMrkdwn(company.context)}`;
}),
),
...sectionCards(
"Claims to follow up",
"Claims to follow up",
digest.claims.length === 0
? ["None identified."]
: digest.claims.map((claim) => {
const subject =
claim.subjectCompany === undefined
? ""
: ` — *${escapeGeneratedMrkdwn(claim.subjectCompany)}*`;
return `• ${escapeGeneratedMrkdwn(claim.text)}${subject}`;
}),
),
];
}

function clampTitle(text: string): string {
return text.length > MAX_CARD_TITLE_LENGTH
? `${text.slice(0, MAX_CARD_TITLE_LENGTH - 1)}…`
: text;
}

function truncateBody(text: string): string {
return text.length > MAX_CARD_BODY_LENGTH
? `${text.slice(0, MAX_CARD_BODY_LENGTH - 1)}…`
: text;
}

function sectionCards(title: string, heading: string, items: string[]): string[] {
const chunks = chunkItems(items);
return chunks.map((chunk, index) => {
const page = chunks.length > 1 ? ` (${index + 1}/${chunks.length})` : "";
return `*${clampTitle(`${title}${page}`)}*\n\n*${heading}*\n${chunk}`;
});
}

function escapeGeneratedMrkdwn(text: string): string {
// TagThread posts these strings as Slack mrkdwn. Escape model text at the
// leaf so intentional *bold* wrappers around company names still work.
return text
.replaceAll("\\", "\\\\")
.replaceAll("~", "\\~")
.replaceAll("*", "\\*")
.replaceAll("_", "\\_");
}

function chunkItems(items: string[]): string[] {
const chunks: string[] = [];
let current = "";

for (const item of items) {
for (const part of splitLongItem(item)) {
const next = current === "" ? part : `${current}\n\n${part}`;
if (next.length > MAX_CARD_BODY_LENGTH && current !== "") {
chunks.push(current);
current = part;
} else {
current = next;
}
}
}

if (current !== "") chunks.push(current);
return chunks;
}

function splitLongItem(item: string): string[] {
const parts: string[] = [];
let remaining = item;

while (remaining.length > MAX_CARD_BODY_LENGTH) {
const boundary = remaining.lastIndexOf(" ", MAX_CARD_BODY_LENGTH);
const end = boundary > 0 ? boundary : MAX_CARD_BODY_LENGTH;
parts.push(remaining.slice(0, end));
remaining = remaining.slice(end).trimStart();
}

parts.push(remaining);
return parts;
}
Loading