eve-hub 0.16.25 → 0.16.26
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/execution-context.mjs +1 -1
- package/onboarding/SKILL.md +8 -6
- package/onboarding/files.gen.mjs +1 -1
- package/onboarding/github-facts.mjs +22 -10
- package/package.json +3 -3
- package/prepare.mjs +14 -67
- package/skills/onboarding-profile.gen.mjs +1 -1
- package/tools/onboarding_scripts.d.ts +2 -0
- package/tools/onboarding_scripts.js +14 -3
package/execution-context.mjs
CHANGED
|
@@ -63,7 +63,7 @@ export function hostedContext({ bot, coordinator, kit, removed, deployment: d, g
|
|
|
63
63
|
? "- **Changing it:** commit to that repository; Vercel builds and deploys the commit."
|
|
64
64
|
: "- **Changing it:** there is no repository to commit to. The hub replaces these files on its next deploy, so an edit made only in this deployment does not last.",
|
|
65
65
|
git
|
|
66
|
-
? "- **GitHub:** goes through the `git__gh` tool (`git`, `gh`, `gh-signed-commit`), which runs each command with the human's own GitHub token, swapped in at the sandbox firewall for that command, so pushes, pull requests and commits are theirs. The shell itself holds no credential, so plain `git` and `gh` in bash cannot authenticate.
|
|
66
|
+
? "- **GitHub:** goes through the `git__gh` tool (`git`, `gh`, `gh-signed-commit`), which runs each command with the human's own GitHub token, swapped in at the sandbox firewall for that command, so pushes, pull requests and commits are theirs. The shell itself holds no credential, so plain `git` and `gh` in bash cannot authenticate. Set a command's `repository` to the `owner/name` it acts on, or omit it for a command that acts on none, such as `gh api user` or `gh search repos`."
|
|
67
67
|
: "- **GitHub:** your shell has no GitHub credential: `git` cannot authenticate, so a private repository won't clone and nothing can be pushed. When a `vercel-kit` connection is listed, reach GitHub through it (eve__search, then eve__tool); otherwise tell the human this deployment has no GitHub access.",
|
|
68
68
|
"- **Memory:** the standing decisions you recall are the `memory/` and `agents/<bot>/memory/` files, built into each deployment. An entry takes effect when the deployment that contains it is live, not on your next turn; with no repository it cannot last.",
|
|
69
69
|
"- **Limits:** there is no `hub` CLI, you cannot restart yourself or any bot, and there are no host paths: your shell is a Vercel Sandbox with its own filesystem, not the owner's machine.",
|
package/onboarding/SKILL.md
CHANGED
|
@@ -6,7 +6,7 @@ license: MIT
|
|
|
6
6
|
|
|
7
7
|
# Profile the user from Slack and GitHub
|
|
8
8
|
|
|
9
|
-
Two scripts gather facts (GitHub in your sandbox
|
|
9
|
+
Two scripts gather facts (GitHub in your sandbox through `git__gh`, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.
|
|
10
10
|
|
|
11
11
|
## 0. Hand the Mac half to the bot on the Mac
|
|
12
12
|
|
|
@@ -27,17 +27,19 @@ request_access({ service: "slack", reads: true, reason: "Read your recent channe
|
|
|
27
27
|
- `approved`: go on.
|
|
28
28
|
- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.
|
|
29
29
|
|
|
30
|
-
GitHub does not use Kit: it uses `
|
|
30
|
+
GitHub does not use Kit: it uses your `git__gh` tool (step 2). If that command fails, the final message says GitHub was not read.
|
|
31
31
|
|
|
32
32
|
## 2. GitHub facts
|
|
33
33
|
|
|
34
|
-
Call the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and
|
|
34
|
+
Call the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and installs the `gh onboarding-facts` extension, which runs the GitHub facts script. Plain `gh` in your shell holds no credential, so run the extension with your `git__gh` tool, one command, with no `repository`:
|
|
35
35
|
|
|
36
|
-
```sh
|
|
37
|
-
node /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json
|
|
38
36
|
```
|
|
37
|
+
gh onboarding-facts
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
It prints nothing and writes `/tmp/onboarding-skill/github-facts.json`. A failed command means GitHub was not read.
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
The file holds one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.
|
|
41
43
|
|
|
42
44
|
## 3. Slack facts
|
|
43
45
|
|
package/onboarding/files.gen.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/build-onboarding.mjs. Do not edit.
|
|
2
|
-
export default {"github-facts.mjs":"#!/usr/bin/env node\n// Facts about the signed-in `gh` user's last 90 days on GitHub, as one JSON document on stdout.\n// Counts, titles (<=200 chars), URLs and CODEOWNERS lines only; no judgment: the model reads this and profiles.\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { pathToFileURL } from \"node:url\";\nimport { snippet } from \"./redact.mjs\";\n\nconst exec = promisify(execFile);\nconst DAYS = 90;\nconst LIST_CAP = 150;\nconst CODEOWNERS_REPOS = 10;\nconst STATEFUL = new Set([\"open\", \"reviewRequested\"]);\nconst CODEOWNERS_PATHS = [\".github/CODEOWNERS\", \"CODEOWNERS\", \"docs/CODEOWNERS\"];\n\nconst BASE = `\n __typename\n ... on PullRequest { number title url createdAt updatedAt mergedAt isDraft additions deletions changedFiles repository { nameWithOwner } author { login } @STATE@ }\n ... on Issue { number title url createdAt updatedAt repository { nameWithOwner } author { login } }`;\nconst STATE = \"reviewDecision mergeable reviewRequests { totalCount } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }\";\nconst searchQuery = (withState) => `query($q: String!, $endCursor: String) {\n search(query: $q, type: ISSUE, first: 50, after: $endCursor) {\n issueCount\n pageInfo { hasNextPage endCursor }\n nodes {${BASE.replace(\"@STATE@\", withState ? STATE : \"\")}}\n }\n}`;\n\nexport const realRun = async (args) => (await exec(\"gh\", args, { maxBuffer: 1 << 28 })).stdout;\n\nconst ATTEMPTS = 3;\n\nasync function search(run, q, withState = false) {\n const args = [\"api\", \"graphql\", \"--paginate\", \"--jq\", \".data.search\", \"-f\", `query=${searchQuery(withState)}`, \"-f\", `q=${q}`];\n let out;\n for (let attempt = 1; ; attempt++) {\n try {\n out = await run(args);\n break;\n } catch (e) {\n if (attempt === ATTEMPTS) throw e;\n await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));\n }\n }\n const pages = out.split(\"\\n\").filter(Boolean).map((l) => JSON.parse(l));\n const nodes = pages.flatMap((p) => p.nodes).filter((n) => n?.url);\n return { total: pages[0].issueCount, nodes };\n}\n\nconst item = (n) => ({\n repo: n.repository.nameWithOwner,\n number: n.number,\n type: n.__typename,\n title: snippet(n.title),\n url: n.url,\n author: n.author?.login ?? null,\n createdAt: n.createdAt,\n updatedAt: n.updatedAt,\n ...(n.__typename === \"PullRequest\"\n ? {\n mergedAt: n.mergedAt,\n draft: n.isDraft,\n additions: n.additions,\n deletions: n.deletions,\n changedFiles: n.changedFiles,\n reviewDecision: n.reviewDecision ?? null,\n mergeable: n.mergeable ?? null,\n checks: n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null,\n reviewRequests: n.reviewRequests?.totalCount ?? null,\n }\n : {}),\n});\n\nconst byRecent = (a, b) => (b.mergedAt ?? b.updatedAt).localeCompare(a.mergedAt ?? a.updatedAt);\nconst listed = ({ total, nodes }) => ({ total, listed: Math.min(nodes.length, LIST_CAP), items: nodes.map(item).sort(byRecent).slice(0, LIST_CAP) });\n\nfunction perRepo(sets) {\n const repos = {};\n for (const [key, nodes] of Object.entries(sets)) {\n for (const n of nodes) {\n const r = (repos[n.repository.nameWithOwner] ??= { repo: n.repository.nameWithOwner, merged: 0, reviewed: 0, open: 0, additions: 0, deletions: 0 });\n r[key] += 1;\n if (key === \"merged\") { r.additions += n.additions ?? 0; r.deletions += n.deletions ?? 0; }\n }\n }\n return Object.values(repos).sort((a, b) => b.merged + b.reviewed + b.open - (a.merged + a.reviewed + a.open));\n}\n\nasync function codeownersLines(run, repo, login, teams) {\n const owners = [`@${login}`, ...teams.map((t) => `@${t}`)].map((o) => o.toLowerCase());\n for (const path of CODEOWNERS_PATHS) {\n let text;\n try {\n text = await run([\"api\", `repos/${repo}/contents/${path}`, \"-H\", \"Accept: application/vnd.github.raw+json\"]);\n } catch (e) {\n if (/HTTP 404/.test(e.stderr ?? e.message)) continue;\n throw e;\n }\n return text.split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l && !l.startsWith(\"#\") && l.toLowerCase().split(/\\s+/).some((tok) => owners.includes(tok)))\n .map((line) => ({ repo, path, line: snippet(line) }));\n }\n return [];\n}\n\nexport async function collect({ run = realRun, now = new Date() } = {}) {\n const since = new Date(now.getTime() - DAYS * 86400_000).toISOString().slice(0, 10);\n const unreadable = [];\n const attempt = async (what, fn, fallback) => {\n try {\n return await fn();\n } catch (e) {\n unreadable.push({ what, error: snippet(e.stderr || e.message) });\n return fallback;\n }\n };\n const empty = { total: 0, nodes: [] };\n const login = JSON.parse(await run([\"api\", \"user\"])).login;\n const teams = await attempt(\"user teams\", async () => (await run([\"api\", \"user/teams\", \"--paginate\", \"--jq\", '.[] | .organization.login + \"/\" + .slug'])).split(\"\\n\").filter(Boolean), []);\n const q = {\n merged: `is:pr author:@me is:merged merged:>=${since} archived:false`,\n open: \"is:pr author:@me is:open archived:false\",\n reviewRequested: \"is:pr is:open review-requested:@me archived:false\",\n assignedIssues: \"is:issue is:open assignee:@me archived:false\",\n unansweredMentions: `is:open mentions:@me -commenter:@me -author:@me updated:>=${since} archived:false`,\n reviewed: `is:pr reviewed-by:@me -author:@me merged:>=${since} archived:false`,\n };\n const got = Object.fromEntries(await Promise.all(Object.entries(q).map(async ([k, v]) => [k, await attempt(`search ${k}`, () => search(run, v, STATEFUL.has(k)), empty)])));\n const repos = perRepo({ merged: got.merged.nodes, reviewed: got.reviewed.nodes, open: got.open.nodes });\n const owned = (await Promise.all(repos.slice(0, CODEOWNERS_REPOS).map((r) => attempt(`CODEOWNERS ${r.repo}`, () => codeownersLines(run, r.repo, login, teams), [])))).flat();\n return {\n login,\n window: { since, until: now.toISOString().slice(0, 10), days: DAYS },\n counts: Object.fromEntries(Object.entries(got).map(([k, v]) => [k, v.total])),\n repos: repos.slice(0, 30),\n merged: listed(got.merged),\n open: listed(got.open),\n reviewRequested: listed(got.reviewRequested),\n assignedIssues: listed(got.assignedIssues),\n unansweredMentions: listed(got.unansweredMentions),\n teams,\n codeowners: owned,\n unreadable,\n };\n}\n\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n process.stdout.write(JSON.stringify(await collect(), null, 1) + \"\\n\");\n}\n","redact.mjs":"const TOKEN = /\\b(?:xox[a-z]-[A-Za-z0-9-]{8,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|vck_[A-Za-z0-9]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]*)/g;\n\nexport const MAX_TEXT = 200;\n\nexport function snippet(text) {\n const flat = String(text ?? \"\").replace(/\\s+/g, \" \").trim().replace(TOKEN, \"[redacted]\");\n return flat.length > MAX_TEXT ? flat.slice(0, MAX_TEXT - 1) + \"…\" : flat;\n}\n","check-report.mjs":"#!/usr/bin/env node\n// Checks the model-written profile against the facts it came from:\n// --profile p.md --facts a.json [b.json...] every URL in the profile is in the facts, and every list item cites one\n// --post post.md the first bubble (everything above the first `---` line) is at most two sentences\n// Exits 1 and prints one line per problem.\nimport { readFileSync } from \"node:fs\";\nimport { pathToFileURL } from \"node:url\";\n\nconst URL_RE = /https?:\\/\\/[^\\s)>\\]\"'`]+/g;\nconst trimUrl = (u) => u.replace(/[.,;:!?]+$/, \"\");\n\nexport function factUrls(facts) {\n const urls = new Set();\n const walk = (v) => {\n if (typeof v === \"string\") { if (/^https?:\\/\\//.test(v)) urls.add(v); }\n else if (v && typeof v === \"object\") Object.values(v).forEach(walk);\n };\n facts.forEach(walk);\n return urls;\n}\n\nexport function citationProblems(profile, facts) {\n const known = factUrls(facts);\n const problems = [];\n profile.split(\"\\n\").forEach((line, i) => {\n const urls = (line.match(URL_RE) ?? []).map(trimUrl);\n for (const u of urls) if (!known.has(u)) problems.push(`line ${i + 1}: ${u} is not in the facts`);\n if (/^\\s*(?:[-*]|\\d+\\.)\\s/.test(line) && !urls.length) problems.push(`line ${i + 1}: list item cites no permalink: ${line.trim().slice(0, 60)}`);\n });\n return problems;\n}\n\nconst MAX_SENTENCES = 2;\n\nexport function firstBubble(post) {\n const rule = post.search(/^[ \\t]*---[ \\t]*$/m);\n return (rule < 0 ? post : post.slice(0, rule)).trim();\n}\n\n// Each line is at least one sentence; within a line, a sentence ends at . ! or ?, plus any closing emphasis,\n// quote or paren, before a space. Code, link targets and common abbreviations are blanked first, so their dots\n// don't count; `etc.` before a capital still ends its sentence.\nexport function sentenceCount(text) {\n const prose = text\n .replace(/```[\\s\\S]*?```/g, \" \")\n .replace(/`[^`]*`/g, \"code\")\n .replace(/\\]\\([^)]*\\)/g, \"]\")\n .replace(URL_RE, \"link\")\n .replace(/\\b(e\\.g|i\\.e|vs)\\./gi, \"$1\")\n .replace(/\\b([Ee]tc)\\.(?!\\s+[A-Z])/g, \"$1\");\n return prose.split(\"\\n\").flatMap((line) => line.split(/(?<=[.!?][*_~\"'”’)]*)\\s+/)).filter((s) => /\\w/.test(s)).length;\n}\n\nexport function postProblems(post) {\n const bubble = firstBubble(post);\n if (!bubble) return [\"the post has no first bubble\"];\n const n = sentenceCount(bubble);\n return n > MAX_SENTENCES ? [`the first bubble has ${n} sentences; keep it to ${MAX_SENTENCES} and move the rest below a --- line`] : [];\n}\n\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n const args = process.argv.slice(2);\n const after = (flag) => { const i = args.indexOf(flag); if (i < 0) return []; const out = []; for (let j = i + 1; j < args.length && !args[j].startsWith(\"--\"); j++) out.push(args[j]); return out; };\n const problems = [];\n const [profile] = after(\"--profile\");\n if (profile) problems.push(...citationProblems(readFileSync(profile, \"utf8\"), after(\"--facts\").map((f) => JSON.parse(readFileSync(f, \"utf8\")))));\n const [post] = after(\"--post\");\n if (post) problems.push(...postProblems(readFileSync(post, \"utf8\")));\n problems.forEach((p) => console.log(p));\n process.exit(problems.length ? 1 : 0);\n}\n"};
|
|
2
|
+
export default {"github-facts.mjs":"#!/usr/bin/env node\n// Facts about the signed-in `gh` user's last 90 days on GitHub, as one JSON document on stdout.\n// Counts, titles (<=200 chars), URLs and CODEOWNERS lines only; no judgment: the model reads this and profiles.\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { pathToFileURL } from \"node:url\";\nimport { snippet } from \"./redact.mjs\";\n\nconst exec = promisify(execFile);\nconst DAYS = 90;\nconst LIST_CAP = 150;\nconst CODEOWNERS_REPOS = 10;\nconst STATEFUL = new Set([\"open\", \"reviewRequested\"]);\nconst CODEOWNERS_PATHS = [\".github/CODEOWNERS\", \"CODEOWNERS\", \"docs/CODEOWNERS\"];\n\nconst BASE = `\n __typename\n ... on PullRequest { number title url createdAt updatedAt mergedAt isDraft additions deletions changedFiles repository { nameWithOwner } author { login } @STATE@ }\n ... on Issue { number title url createdAt updatedAt repository { nameWithOwner } author { login } }`;\nconst STATE = \"reviewDecision mergeable reviewRequests { totalCount } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }\";\nconst searchQuery = (withState, pageSize) => `query($q: String!, $endCursor: String) {\n search(query: $q, type: ISSUE, first: ${pageSize}, after: $endCursor) {\n issueCount\n pageInfo { hasNextPage endCursor }\n nodes {${BASE.replace(\"@STATE@\", withState ? STATE : \"\")}}\n }\n}`;\n\nexport const realRun = async (args) => (await exec(\"gh\", args, { maxBuffer: 1 << 28 })).stdout;\n\nconst ATTEMPTS = 3;\n// GitHub answers a page of 50 nodes with the state fields (mergeable, check rollup) with an HTML error once an account has hundreds of PRs, so those searches read 25 at a time.\nconst PAGE_SIZE = 50;\nconst STATE_PAGE_SIZE = 25;\n\nasync function searchPage(run, q, withState, endCursor) {\n const args = [\"api\", \"graphql\", \"--jq\", \".data.search\", \"-f\", `query=${searchQuery(withState, withState ? STATE_PAGE_SIZE : PAGE_SIZE)}`, \"-f\", `q=${q}`, ...(endCursor ? [\"-f\", `endCursor=${endCursor}`] : [])];\n for (let attempt = 1; ; attempt++) {\n try {\n return JSON.parse(await run(args));\n } catch (e) {\n if (attempt === ATTEMPTS) throw e;\n await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));\n }\n }\n}\n\n// A stateful search lists at most LIST_CAP items, which is all the output shows, so it stops there; the other searches read every page, since the per-repo counts use them all.\nasync function search(run, q, withState = false) {\n const nodes = [];\n let total;\n let page = await searchPage(run, q, withState);\n for (;;) {\n total ??= page.issueCount;\n nodes.push(...page.nodes.filter((n) => n?.url));\n if (!page.pageInfo.hasNextPage || (withState && nodes.length >= LIST_CAP)) break;\n page = await searchPage(run, q, withState, page.pageInfo.endCursor);\n }\n return { total, nodes };\n}\n\nconst item = (n) => ({\n repo: n.repository.nameWithOwner,\n number: n.number,\n type: n.__typename,\n title: snippet(n.title),\n url: n.url,\n author: n.author?.login ?? null,\n createdAt: n.createdAt,\n updatedAt: n.updatedAt,\n ...(n.__typename === \"PullRequest\"\n ? {\n mergedAt: n.mergedAt,\n draft: n.isDraft,\n additions: n.additions,\n deletions: n.deletions,\n changedFiles: n.changedFiles,\n reviewDecision: n.reviewDecision ?? null,\n mergeable: n.mergeable ?? null,\n checks: n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null,\n reviewRequests: n.reviewRequests?.totalCount ?? null,\n }\n : {}),\n});\n\nconst byRecent = (a, b) => (b.mergedAt ?? b.updatedAt).localeCompare(a.mergedAt ?? a.updatedAt);\nconst listed = ({ total, nodes }) => ({ total, listed: Math.min(nodes.length, LIST_CAP), items: nodes.map(item).sort(byRecent).slice(0, LIST_CAP) });\n\nfunction perRepo(sets) {\n const repos = {};\n for (const [key, nodes] of Object.entries(sets)) {\n for (const n of nodes) {\n const r = (repos[n.repository.nameWithOwner] ??= { repo: n.repository.nameWithOwner, merged: 0, reviewed: 0, open: 0, additions: 0, deletions: 0 });\n r[key] += 1;\n if (key === \"merged\") { r.additions += n.additions ?? 0; r.deletions += n.deletions ?? 0; }\n }\n }\n return Object.values(repos).sort((a, b) => b.merged + b.reviewed + b.open - (a.merged + a.reviewed + a.open));\n}\n\nasync function codeownersLines(run, repo, login, teams) {\n const owners = [`@${login}`, ...teams.map((t) => `@${t}`)].map((o) => o.toLowerCase());\n for (const path of CODEOWNERS_PATHS) {\n let text;\n try {\n text = await run([\"api\", `repos/${repo}/contents/${path}`, \"-H\", \"Accept: application/vnd.github.raw+json\"]);\n } catch (e) {\n if (/HTTP 404/.test(e.stderr ?? e.message)) continue;\n throw e;\n }\n return text.split(\"\\n\")\n .map((l) => l.trim())\n .filter((l) => l && !l.startsWith(\"#\") && l.toLowerCase().split(/\\s+/).some((tok) => owners.includes(tok)))\n .map((line) => ({ repo, path, line: snippet(line) }));\n }\n return [];\n}\n\nexport async function collect({ run = realRun, now = new Date() } = {}) {\n const since = new Date(now.getTime() - DAYS * 86400_000).toISOString().slice(0, 10);\n const unreadable = [];\n const attempt = async (what, fn, fallback) => {\n try {\n return await fn();\n } catch (e) {\n unreadable.push({ what, error: snippet(e.stderr || e.message) });\n return fallback;\n }\n };\n const empty = { total: 0, nodes: [] };\n const login = JSON.parse(await run([\"api\", \"user\"])).login;\n const teams = await attempt(\"user teams\", async () => (await run([\"api\", \"user/teams\", \"--paginate\", \"--jq\", '.[] | .organization.login + \"/\" + .slug'])).split(\"\\n\").filter(Boolean), []);\n const q = {\n merged: `is:pr author:@me is:merged merged:>=${since} archived:false`,\n open: \"is:pr author:@me is:open archived:false\",\n reviewRequested: \"is:pr is:open review-requested:@me archived:false\",\n assignedIssues: \"is:issue is:open assignee:@me archived:false\",\n unansweredMentions: `is:open mentions:@me -commenter:@me -author:@me updated:>=${since} archived:false`,\n reviewed: `is:pr reviewed-by:@me -author:@me merged:>=${since} archived:false`,\n };\n const got = Object.fromEntries(await Promise.all(Object.entries(q).map(async ([k, v]) => [k, await attempt(`search ${k}`, () => search(run, v, STATEFUL.has(k)), empty)])));\n const repos = perRepo({ merged: got.merged.nodes, reviewed: got.reviewed.nodes, open: got.open.nodes });\n const owned = (await Promise.all(repos.slice(0, CODEOWNERS_REPOS).map((r) => attempt(`CODEOWNERS ${r.repo}`, () => codeownersLines(run, r.repo, login, teams), [])))).flat();\n return {\n login,\n window: { since, until: now.toISOString().slice(0, 10), days: DAYS },\n counts: Object.fromEntries(Object.entries(got).map(([k, v]) => [k, v.total])),\n repos: repos.slice(0, 30),\n merged: listed(got.merged),\n open: listed(got.open),\n reviewRequested: listed(got.reviewRequested),\n assignedIssues: listed(got.assignedIssues),\n unansweredMentions: listed(got.unansweredMentions),\n teams,\n codeowners: owned,\n unreadable,\n };\n}\n\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n process.stdout.write(JSON.stringify(await collect(), null, 1) + \"\\n\");\n}\n","redact.mjs":"const TOKEN = /\\b(?:xox[a-z]-[A-Za-z0-9-]{8,}|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{16,}|vck_[A-Za-z0-9]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]*)/g;\n\nexport const MAX_TEXT = 200;\n\nexport function snippet(text) {\n const flat = String(text ?? \"\").replace(/\\s+/g, \" \").trim().replace(TOKEN, \"[redacted]\");\n return flat.length > MAX_TEXT ? flat.slice(0, MAX_TEXT - 1) + \"…\" : flat;\n}\n","check-report.mjs":"#!/usr/bin/env node\n// Checks the model-written profile against the facts it came from:\n// --profile p.md --facts a.json [b.json...] every URL in the profile is in the facts, and every list item cites one\n// --post post.md the first bubble (everything above the first `---` line) is at most two sentences\n// Exits 1 and prints one line per problem.\nimport { readFileSync } from \"node:fs\";\nimport { pathToFileURL } from \"node:url\";\n\nconst URL_RE = /https?:\\/\\/[^\\s)>\\]\"'`]+/g;\nconst trimUrl = (u) => u.replace(/[.,;:!?]+$/, \"\");\n\nexport function factUrls(facts) {\n const urls = new Set();\n const walk = (v) => {\n if (typeof v === \"string\") { if (/^https?:\\/\\//.test(v)) urls.add(v); }\n else if (v && typeof v === \"object\") Object.values(v).forEach(walk);\n };\n facts.forEach(walk);\n return urls;\n}\n\nexport function citationProblems(profile, facts) {\n const known = factUrls(facts);\n const problems = [];\n profile.split(\"\\n\").forEach((line, i) => {\n const urls = (line.match(URL_RE) ?? []).map(trimUrl);\n for (const u of urls) if (!known.has(u)) problems.push(`line ${i + 1}: ${u} is not in the facts`);\n if (/^\\s*(?:[-*]|\\d+\\.)\\s/.test(line) && !urls.length) problems.push(`line ${i + 1}: list item cites no permalink: ${line.trim().slice(0, 60)}`);\n });\n return problems;\n}\n\nconst MAX_SENTENCES = 2;\n\nexport function firstBubble(post) {\n const rule = post.search(/^[ \\t]*---[ \\t]*$/m);\n return (rule < 0 ? post : post.slice(0, rule)).trim();\n}\n\n// Each line is at least one sentence; within a line, a sentence ends at . ! or ?, plus any closing emphasis,\n// quote or paren, before a space. Code, link targets and common abbreviations are blanked first, so their dots\n// don't count; `etc.` before a capital still ends its sentence.\nexport function sentenceCount(text) {\n const prose = text\n .replace(/```[\\s\\S]*?```/g, \" \")\n .replace(/`[^`]*`/g, \"code\")\n .replace(/\\]\\([^)]*\\)/g, \"]\")\n .replace(URL_RE, \"link\")\n .replace(/\\b(e\\.g|i\\.e|vs)\\./gi, \"$1\")\n .replace(/\\b([Ee]tc)\\.(?!\\s+[A-Z])/g, \"$1\");\n return prose.split(\"\\n\").flatMap((line) => line.split(/(?<=[.!?][*_~\"'”’)]*)\\s+/)).filter((s) => /\\w/.test(s)).length;\n}\n\nexport function postProblems(post) {\n const bubble = firstBubble(post);\n if (!bubble) return [\"the post has no first bubble\"];\n const n = sentenceCount(bubble);\n return n > MAX_SENTENCES ? [`the first bubble has ${n} sentences; keep it to ${MAX_SENTENCES} and move the rest below a --- line`] : [];\n}\n\nif (import.meta.url === pathToFileURL(process.argv[1] ?? \"\").href) {\n const args = process.argv.slice(2);\n const after = (flag) => { const i = args.indexOf(flag); if (i < 0) return []; const out = []; for (let j = i + 1; j < args.length && !args[j].startsWith(\"--\"); j++) out.push(args[j]); return out; };\n const problems = [];\n const [profile] = after(\"--profile\");\n if (profile) problems.push(...citationProblems(readFileSync(profile, \"utf8\"), after(\"--facts\").map((f) => JSON.parse(readFileSync(f, \"utf8\")))));\n const [post] = after(\"--post\");\n if (post) problems.push(...postProblems(readFileSync(post, \"utf8\")));\n problems.forEach((p) => console.log(p));\n process.exit(problems.length ? 1 : 0);\n}\n"};
|
|
@@ -18,8 +18,8 @@ const BASE = `
|
|
|
18
18
|
... on PullRequest { number title url createdAt updatedAt mergedAt isDraft additions deletions changedFiles repository { nameWithOwner } author { login } @STATE@ }
|
|
19
19
|
... on Issue { number title url createdAt updatedAt repository { nameWithOwner } author { login } }`;
|
|
20
20
|
const STATE = "reviewDecision mergeable reviewRequests { totalCount } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
|
|
21
|
-
const searchQuery = (withState) => `query($q: String!, $endCursor: String) {
|
|
22
|
-
search(query: $q, type: ISSUE, first:
|
|
21
|
+
const searchQuery = (withState, pageSize) => `query($q: String!, $endCursor: String) {
|
|
22
|
+
search(query: $q, type: ISSUE, first: ${pageSize}, after: $endCursor) {
|
|
23
23
|
issueCount
|
|
24
24
|
pageInfo { hasNextPage endCursor }
|
|
25
25
|
nodes {${BASE.replace("@STATE@", withState ? STATE : "")}}
|
|
@@ -29,22 +29,34 @@ const searchQuery = (withState) => `query($q: String!, $endCursor: String) {
|
|
|
29
29
|
export const realRun = async (args) => (await exec("gh", args, { maxBuffer: 1 << 28 })).stdout;
|
|
30
30
|
|
|
31
31
|
const ATTEMPTS = 3;
|
|
32
|
+
// GitHub answers a page of 50 nodes with the state fields (mergeable, check rollup) with an HTML error once an account has hundreds of PRs, so those searches read 25 at a time.
|
|
33
|
+
const PAGE_SIZE = 50;
|
|
34
|
+
const STATE_PAGE_SIZE = 25;
|
|
32
35
|
|
|
33
|
-
async function
|
|
34
|
-
const args = ["api", "graphql", "--
|
|
35
|
-
let out;
|
|
36
|
+
async function searchPage(run, q, withState, endCursor) {
|
|
37
|
+
const args = ["api", "graphql", "--jq", ".data.search", "-f", `query=${searchQuery(withState, withState ? STATE_PAGE_SIZE : PAGE_SIZE)}`, "-f", `q=${q}`, ...(endCursor ? ["-f", `endCursor=${endCursor}`] : [])];
|
|
36
38
|
for (let attempt = 1; ; attempt++) {
|
|
37
39
|
try {
|
|
38
|
-
|
|
39
|
-
break;
|
|
40
|
+
return JSON.parse(await run(args));
|
|
40
41
|
} catch (e) {
|
|
41
42
|
if (attempt === ATTEMPTS) throw e;
|
|
42
43
|
await new Promise((r) => setTimeout(r, 1000 * attempt + Math.random() * 1000));
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A stateful search lists at most LIST_CAP items, which is all the output shows, so it stops there; the other searches read every page, since the per-repo counts use them all.
|
|
49
|
+
async function search(run, q, withState = false) {
|
|
50
|
+
const nodes = [];
|
|
51
|
+
let total;
|
|
52
|
+
let page = await searchPage(run, q, withState);
|
|
53
|
+
for (;;) {
|
|
54
|
+
total ??= page.issueCount;
|
|
55
|
+
nodes.push(...page.nodes.filter((n) => n?.url));
|
|
56
|
+
if (!page.pageInfo.hasNextPage || (withState && nodes.length >= LIST_CAP)) break;
|
|
57
|
+
page = await searchPage(run, q, withState, page.pageInfo.endCursor);
|
|
58
|
+
}
|
|
59
|
+
return { total, nodes };
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
const item = (n) => ({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eve-hub",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.26",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
|
|
6
6
|
"type": "module",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"@vercel/connect": "^2.7.0",
|
|
43
43
|
"@vercel/oidc": "4.0.0",
|
|
44
|
-
"eve": "~1.0.0-rc.
|
|
44
|
+
"eve": "~1.0.0-rc.4",
|
|
45
45
|
"zod": "4.5.4"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
@@ -55,6 +55,6 @@
|
|
|
55
55
|
"web-tree-sitter": "0.27.1"
|
|
56
56
|
},
|
|
57
57
|
"hubPins": {
|
|
58
|
-
"eve": "1.0.0-rc.
|
|
58
|
+
"eve": "1.0.0-rc.4"
|
|
59
59
|
}
|
|
60
60
|
}
|
package/prepare.mjs
CHANGED
|
@@ -32,18 +32,18 @@ import { GITHUB_CONNECTOR } from "./github-connector.mjs";
|
|
|
32
32
|
export { GITHUB_CONNECTOR };
|
|
33
33
|
|
|
34
34
|
// eve's git extension, for each bot of a build from a workspace repo that edits files: its `git__gh` tool runs
|
|
35
|
-
// git, gh and gh-signed-commit
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// mounts none.
|
|
35
|
+
// git, gh and gh-signed-commit with the human's own GitHub token, resolved by Vercel Connect
|
|
36
|
+
// through the GITHUB_CONNECTOR app (the hub links the connector to the bots project at onboarding)
|
|
37
|
+
// and swapped in at the sandbox firewall for that command alone. What the token can reach is what
|
|
38
|
+
// the human and the App's installations can reach, so the extension names no org. Every hub turn
|
|
39
|
+
// runs as the workspace's human (channels/eve-roster-user.ts), so pushes, PRs and commits are
|
|
40
|
+
// theirs. The shell holds no credential. A build without a repo (an uploaded deployment) mounts none.
|
|
41
41
|
export const GIT_EXTENSION_FILE = "extensions/git.ts";
|
|
42
42
|
// The read-only preset's stub (lib/agent-template.mjs): eve's write_file, disabled.
|
|
43
43
|
const READ_ONLY_FILE = "tools/write_file.ts";
|
|
44
44
|
// The stub an earlier kit wrote, with no `github` option; two git extensions would collide.
|
|
45
45
|
export const LEGACY_GIT_EXTENSION_FILE = "extensions/git/extension.ts";
|
|
46
|
-
export const gitExtensionFiles = (
|
|
46
|
+
export const gitExtensionFiles = () => ({ [GIT_EXTENSION_FILE]: `import git from "eve/extensions/git";
|
|
47
47
|
import type { GitHubLeaseBroker } from "eve/extensions/git";
|
|
48
48
|
import type { SandboxNetworkPolicy } from "eve/sandbox";
|
|
49
49
|
import type { VercelSandboxSession } from "eve/sandbox/vercel";
|
|
@@ -51,72 +51,19 @@ import type { VercelSandboxSession } from "eve/sandbox/vercel";
|
|
|
51
51
|
// Each \`git__gh\` command runs with the caller's own GitHub token, resolved by Vercel Connect
|
|
52
52
|
// through the eve-hub-github App, so pushes, PRs and commits are the human's. Every hub turn,
|
|
53
53
|
// wake-ups and bot handoffs included, runs as the workspace's human (channels/eve-roster-user).
|
|
54
|
-
// The sandbox only sees a placeholder; this swaps the real token in at the sandbox firewall
|
|
55
|
-
// that command and removes it afterwards. All other egress stays open.
|
|
56
|
-
const ORG = ${JSON.stringify(org.toLowerCase())};
|
|
57
|
-
|
|
58
|
-
// The firewall sees the request Git really sends, after any \`insteadOf\` or \`-c\` rewrite that
|
|
59
|
-
// \`authorize\` can't see, so it swaps the token in only for paths under the workspace's org.
|
|
60
|
-
// Anywhere else the placeholder goes out and GitHub answers 401. api.github.com keeps eve's rule:
|
|
61
|
-
// \`/graphql\` and \`/user\` carry no owner.
|
|
62
|
-
const ORG_PATHS: Record<string, string> = {
|
|
63
|
-
"github.com": \`(?i)^/\${ORG}/\`,
|
|
64
|
-
"uploads.github.com": \`(?i)^/repos/\${ORG}/\`,
|
|
65
|
-
};
|
|
66
|
-
const scoped = (rules: Parameters<GitHubLeaseBroker>[1]) =>
|
|
67
|
-
rules && Object.fromEntries(Object.entries(rules).map(([host, list]) => [
|
|
68
|
-
host,
|
|
69
|
-
host in ORG_PATHS ? list.map((rule) => ({ ...rule, match: { ...rule.match, path: { regex: ORG_PATHS[host] } } })) : list,
|
|
70
|
-
]));
|
|
54
|
+
// The sandbox only sees a placeholder; this swaps the real token in at the sandbox firewall with
|
|
55
|
+
// eve's own rules for that command and removes it afterwards. All other egress stays open.
|
|
71
56
|
const broker: GitHubLeaseBroker = async (sandbox, rules) => {
|
|
72
57
|
if (!("setNetworkPolicy" in sandbox)) throw new Error("GitHub credential leases need a Vercel Sandbox");
|
|
73
|
-
await (sandbox as VercelSandboxSession).setNetworkPolicy({ allow: { "*": [], ...
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
// From eve 1.0, \`authorize\` is the only check: eve no longer refuses a command whose \`--repo\`,
|
|
77
|
-
// \`-R\` or \`gh repo clone|view|fork\` target differs from the declared repository, nor a \`git\`
|
|
78
|
-
// remote outside it, so this does. \`authorize\` can't read the sandbox, so a remote name such as
|
|
79
|
-
// \`origin\` can't be resolved: authenticated git names the declared repository's URL itself.
|
|
80
|
-
// \`repository\` may be null: eve's tool text tells bots to omit it for \`gh api user\`,
|
|
81
|
-
// \`gh repo list\` and \`gh search\`, and a user token isn't narrowed by it anyway.
|
|
82
|
-
const GIT_REMOTE_COMMANDS = ["fetch", "ls-remote", "pull", "push"];
|
|
83
|
-
const GIT_GLOBAL_VALUE_OPTIONS = ["-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"];
|
|
84
|
-
const GIT_REMOTE_VALUE_OPTIONS = ["--depth", "--deepen", "--shallow-since", "--shallow-exclude", "--upload-pack", "--receive-pack", "--exec", "--refmap", "--negotiation-tip", "-o", "--push-option", "--server-option", "-j", "--jobs", "--filter", "--recurse-submodules-default"];
|
|
85
|
-
const targets = (argv: readonly string[]) => [
|
|
86
|
-
...argv.flatMap((a, i) => (a === "--repo" || a === "-R" ? [argv[i + 1] ?? ""] : a.startsWith("--repo=") ? [a.slice(7)] : [])),
|
|
87
|
-
...(argv[0] === "gh" && argv[1] === "repo" && ["clone", "view", "fork"].includes(argv[2] ?? "") && argv[3] && !argv[3].startsWith("-") ? [argv[3]] : []),
|
|
88
|
-
];
|
|
89
|
-
const GITHUB_URL = /^(?:https:\\/\\/github\\.com\\/|git@github\\.com:|ssh:\\/\\/git@github\\.com\\/)/i;
|
|
90
|
-
const canonical = (repo: string) => repo.toLowerCase().replace(GITHUB_URL, "").replace(/\\/$/, "").replace(/\\.git$/, "");
|
|
91
|
-
const operands = (args: readonly string[], valued: readonly string[]) => {
|
|
92
|
-
const found: string[] = [];
|
|
93
|
-
for (let i = 0; i < args.length; i++) {
|
|
94
|
-
if (valued.includes(args[i])) i++;
|
|
95
|
-
else if (!args[i].startsWith("-")) found.push(args[i]);
|
|
96
|
-
}
|
|
97
|
-
return found;
|
|
98
|
-
};
|
|
99
|
-
const gitRemote = (argv: readonly string[]) => {
|
|
100
|
-
let i = 1;
|
|
101
|
-
while (argv[i]?.startsWith("-")) i += GIT_GLOBAL_VALUE_OPTIONS.includes(argv[i]) ? 2 : 1;
|
|
102
|
-
const remote = operands(argv.slice(i + 1), GIT_REMOTE_VALUE_OPTIONS)[0] ?? "";
|
|
103
|
-
return GIT_REMOTE_COMMANDS.includes(argv[i] ?? "") && GITHUB_URL.test(remote) ? [remote] : null;
|
|
58
|
+
await (sandbox as VercelSandboxSession).setNetworkPolicy({ allow: { "*": [], ...rules } } as SandboxNetworkPolicy);
|
|
104
59
|
};
|
|
105
|
-
const commandTargets = (argv: readonly string[]) =>
|
|
106
|
-
argv[0] === "git" ? gitRemote(argv)
|
|
107
|
-
: argv[0] === "gh-signed-commit" ? (targets(argv).length ? targets(argv) : null)
|
|
108
|
-
: targets(argv);
|
|
109
|
-
const inOrg = (repo: string) => canonical(repo).startsWith(\`\${ORG}/\`);
|
|
110
60
|
|
|
61
|
+
// eve passes a user token's \`authorize\` no repository, so it decides nothing here: the token's reach is the human's.
|
|
111
62
|
export default git({
|
|
112
63
|
github: {
|
|
113
64
|
connector: ${JSON.stringify(GITHUB_CONNECTOR)},
|
|
114
65
|
broker,
|
|
115
|
-
authorize: (
|
|
116
|
-
const found = commandTargets(argv);
|
|
117
|
-
if (found === null || !found.every(inOrg)) return false;
|
|
118
|
-
return repository === null || (inOrg(repository) && found.every((t) => canonical(t) === canonical(repository)));
|
|
119
|
-
},
|
|
66
|
+
authorize: () => true,
|
|
120
67
|
principalType: "user",
|
|
121
68
|
},
|
|
122
69
|
});
|
|
@@ -231,9 +178,9 @@ export function prepareDeployment(dir) {
|
|
|
231
178
|
if (!existsSync(join(bot, "agent.ts"))) continue;
|
|
232
179
|
const code = CODE_BOTS.includes(name);
|
|
233
180
|
// A user token carries all of the human's write access and can't be narrowed, so a read-only bot
|
|
234
|
-
// gets none. A bot's own committed git.ts
|
|
181
|
+
// gets none. A bot's own committed git.ts is the workspace's and stays.
|
|
235
182
|
const git = deployment.repoOwner && !existsSync(join(bot, READ_ONLY_FILE)) && !existsSync(join(bot, GIT_EXTENSION_FILE));
|
|
236
|
-
for (const [stub, content] of Object.entries({ ...(git ? gitExtensionFiles(
|
|
183
|
+
for (const [stub, content] of Object.entries({ ...(git ? gitExtensionFiles() : {}), ...(code ? codeExtensionFiles() : {}) })) {
|
|
237
184
|
mkdirSync(dirname(join(bot, stub)), { recursive: true });
|
|
238
185
|
writeFileSync(join(bot, stub), content);
|
|
239
186
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/build-onboarding.mjs from onboarding/SKILL.md. Do not edit.
|
|
2
|
-
export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","content":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `eve__tool({ name: \"vercel-kit__request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses `gh` in your sandbox (step 2). If `gh auth status` fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and returns that directory.\n\n```sh\nnode /tmp/onboarding-skill/github-facts.mjs > /tmp/onboarding-skill/github-facts.json\n```\n\nIt prints one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
|
|
2
|
+
export default {"description":"Right after sign-in: ask for the Kit access you need, then work out who the user is and what to focus on from their recent Slack and GitHub, and draft schedules that keep them on track. Reads only; the one write is a pull request the user approves by merging.","content":"# Profile the user from Slack and GitHub\n\nTwo scripts gather facts (GitHub in your sandbox through `git__gh`, Slack inside Kit); you write the profile and the schedule drafts from those facts alone. Nothing is posted to Slack and nothing is committed; the only write is the pull request in step 6, which the user approves by merging. The user sees one short message from you at the end (step 7), not one per step. If a step cannot finish, that message says which one and what the user must do; never write the profile as if every source had been read.\n\n## 0. Hand the Mac half to the bot on the Mac\n\nYou run on Vercel and can't see the user's Mac. If `list_agents` shows a bot that runs `local`, post one message in this thread before step 1 that mentions it (the first one, if there are several) and asks it to run its local-onboarding skill. Name the workspace repo for its sandbox PR, or say there is none. Then go on with step 1 without waiting: its half arrives in this thread as its own posts.\n\nWith no bot that runs `local`, skip this step and say nothing about a Mac.\n\n## 1. Ask for Kit access, once\n\nKit's tools are `request_access`, `request_status` and `execute`. Hosted, you reach them through the `vercel-kit` connection: `eve__tool({ name: \"vercel-kit__request_access\", input: { ... } })`, and the same shape for the others. Everywhere below, \"call `request_access`\" means that.\n\nSlack goes through the user's Kit connection. Before any fetch, ask for everything the run reads in one request:\n\n```\nrequest_access({ service: \"slack\", reads: true, reason: \"Read your recent channel messages, mentions and threads to write your onboarding profile\" })\n```\n\n- `approved`: go on.\n- `pending` (or `connection_required`): tell the user, in one sentence, that Slack access is waiting, with the `approveUrl`; then `request_status({ request })` until they decide. Do not start step 3 before it is approved. If they decline, the final message says the Slack half was not read.\n\nGitHub does not use Kit: it uses your `git__gh` tool (step 2). If that command fails, the final message says GitHub was not read.\n\n## 2. GitHub facts\n\nCall the `onboarding_scripts` tool once: it writes the skill's scripts into your sandbox at `/tmp/onboarding-skill/` and installs the `gh onboarding-facts` extension, which runs the GitHub facts script. Plain `gh` in your shell holds no credential, so run the extension with your `git__gh` tool, one command, with no `repository`:\n\n```\ngh onboarding-facts\n```\n\nIt prints nothing and writes `/tmp/onboarding-skill/github-facts.json`. A failed command means GitHub was not read.\n\nThe file holds one JSON document: `counts` (merged PRs, open PRs, review requests, assigned issues, mentions never replied to, PRs reviewed), `repos` (merged, reviewed, open and lines changed per repo), item lists (`merged`, `open`, `reviewRequested`, `assignedIssues`, `unansweredMentions`; `total` is the full count, `items` the newest 150), `teams`, and `codeowners` lines naming the user or their teams. Open PRs carry `draft`, `reviewDecision`, `mergeable` and `checks`. `author:@me` includes PRs the user's bots open under their login. `unreadable` lists a source that could not be read.\n\n## 3. Slack facts\n\nRun the script in \"The Slack script\" below with Kit's `execute`: pass it as the script, unchanged. It runs inside Kit, so no raw message reaches you; it returns one JSON document in about 10 seconds.\n\n- A `ratelimited` error means Slack's search limit (about 20 a minute; the script makes at most 19). Wait 70 seconds (`sleep 70`) and run it again unchanged, up to five times.\n- A `suspended` result whose output says `consent_required` is a pause, not an error: tell the user in one sentence that the Slack read is waiting, with its `approveUrl`; `request_status({ request })` until they decide; then `execute({ run })` continues the same run (settled steps are not repeated).\n- An `output` with `refused` means Slack answered `missing_scope` (or another refusal) to a call, so no facts were read: `refused.needed` names the scope the user's Slack connection lacks (`refused.provided` what it has). Do not continue. Call `request_access({ service: \"slack\", capability: \"search_messages\", reason })`: if it returns an `approveUrl`, give it to the user; otherwise tell them to reconnect Slack in Kit allowing `refused.needed`. The final message says the Slack half was not read and names the scope.\n- Any other error goes to the user in the final message. Never continue with fewer facts, and never retry around a refusal.\n- Save the run's `output` as `/tmp/onboarding-skill/slack-facts.json` (the checker reads it).\n- `window`: the last 30 days. Slack search allows 15 pages of 100 here, so a very active account is covered only back to `own.oldest` (`newestOnly: true`); state that window in the profile.\n- `own`: messages per channel, thread replies, recent snippets. `mentions`: `replied` means the user posted later in the same thread; `unanswered` means they did not. A mention older than what was read with no reply among it (`notJudged`) is unknown, not unanswered. `mentions.capped` means the search found more mentions (`searchTotal`) than the three pages read: say so in \"Could not tell\". Reactions are not read (the Slack grant has no `reactions:read`): say so in \"Could not tell\". `memberships`: channels the user is in.\n- DMs and group DMs are never searched. Every list item carries a permalink (`url`); a snippet that looked like a secret is `[withheld]`.\n\n## 4. Write the profile and drafts\n\nWrite the profile to `/tmp/onboarding-skill/profile.md`. It names the user's repos and colleagues, so it is posted only in this thread and never committed.\n\nAnswer, in the user's terms:\n\n- What they have shipped, what they own, what they are responsible for.\n- What people tag them for, and what they write about.\n- What they have missed: unanswered mentions, requested reviews, stale or conflicted PRs.\n- Which open GitHub work-streams to close (stale, conflicted, superseded) and which to focus on.\n\nRules:\n\n- Judge; the scripts only counted. No scoring formula and no thresholds from code: say why in a sentence.\n- Every list item cites at least one permalink that appears in the facts, and no URL appears that isn't in the facts. State counts as the facts give them, with the window.\n- Don't quote messages beyond the snippets, and don't name people beyond what a claim needs.\n- Add a \"Could not tell\" section: every `unreadable` source, `notJudged` mentions, that reactions were not read, the Slack window, a Kit grant the user declined.\n\nFor each recurring need you found (a morning pass over unanswered mentions, a weekly stale-PR pass), write one draft to `/tmp/onboarding-skill/schedules/<bot>/<id>.md` for an existing bot of your workspace:\n\n```md\n---\ncron: \"0 9 * * 1-5\"\n---\n\nThe prompt the bot runs.\n```\n\n`cron` is five fields in UTC on a hosted deploy. Each draft says in its prompt which facts it reads and what it reports.\n\n## 5. Check\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --profile /tmp/onboarding-skill/profile.md --facts /tmp/onboarding-skill/github-facts.json /tmp/onboarding-skill/slack-facts.json\n```\n\nFix every line it prints and rerun until it prints nothing. Then delete the two facts files.\n\n## 6. Open the pull request, if the workspace has a repo\n\nWhen the workspace is backed by a GitHub repo, open one PR on it that adds only the schedule drafts, as `agents/<bot>/agent/schedules/<id>.md`, and nothing from the profile (`gh pr create`). The user merging it is their approval; the deploy that follows is what makes the schedules run. The PR's checks include the deploy's build, which rejects a bad cron or a bot that does not exist: watch them (`gh pr checks`), fix the draft and push again until they pass, and say in the message if they do not. Without a repo the drafts stay in the thread and the message says so.\n\n## 7. Post one short message to the user\n\nThe user reads it cold, often only its first line (your writing rules). Write it to `/tmp/onboarding-skill/post.md` in this shape, load the forms skill for the tables, and check it:\n\n```md\nOne line on who they are and what they own, then the one thing to do first.\n\n---\n\n| Act on first | Why | Link |\n|---|---|---|\n(three rows, each linking a permalink from the facts)\n\n| Schedule | Bot | When | What it reports |\n|---|---|---|---|\n(one row per draft)\n\nThe drafts are in [this PR](...): merging it turns them on.\n(one line per source not read or grant declined: what was not read and what to do)\n```\n\n```sh\nnode /tmp/onboarding-skill/check-report.mjs --post /tmp/onboarding-skill/post.md\n```\n\nFix every line it prints and rerun until it prints nothing, then post the file's text in this thread.\n\n- The first bubble, everything above `---`, is at most two sentences.\n- Below it, the two tables, then the lines under them and nothing else:\n - The PR line. Without a repo, it says the drafts stay in the thread. If the PR's checks still fail after step 6, it says so and what failed, in place of \"merging it turns them on\".\n - One line per source you could not read or Kit grant the user declined: what was not read and what they must do.\n - If you handed the Mac half over in step 0 and that bot hasn't posted yet, one line: its half hasn't reported yet, and if the Hub app is closed on their Mac, it runs once they open it.\n- Nothing else goes in: not the profile, the \"Could not tell\" section, counts, windows, or how the run went. Keep `profile.md`, and post it, or the part asked for, only when the user asks.\n\nOnly the schedule files ever leave the thread.\n\n## The Slack script\n\n```js\n// Facts about the signed-in user's recent Slack channel activity. Counts, permalinks and short snippets only.\n// Searches exclude DMs and group DMs (-is:dm) so none is ever read. Slack allows about 20 searches a minute: this\n// script makes at most 19, so on a ratelimited error wait a minute and run it again unchanged.\n// The .slice(0, n) before each .map is not redundant: callscript counts worst-case calls from it (200 max).\nconst DAYS = 30;\nconst after = new Date(Date.now() - (DAYS + 1) * 86400000).slice(0, 10);\nconst auth = await slack.auth_test();\nconst me = auth.user_id;\nconst ownPages = await Promise.all([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].slice(0, 15).map(page => slack.search_messages({ query: `from:<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst mentionPages = await Promise.all([1, 2, 3].slice(0, 3).map(page => slack.search_messages({ query: `<@${me}> after:${after} -is:dm`, count: 100, page, sort: \"timestamp\", sort_dir: \"desc\" })));\nconst member = await slack.users_conversations({ types: \"public_channel,private_channel\", limit: 200, exclude_archived: true });\nconst refused = [auth, ...ownPages, ...mentionPages, member].filter(r => r.ok === false);\nif (refused.length > 0) {\n return { refused: { error: refused[0].error, needed: refused[0].needed ?? null, provided: refused[0].provided ?? null, calls: refused.length } };\n}\nconst channels = Object.entries(Object.groupBy(ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim), m => m.channel.id)).map(e => ({ id: e[0], channel: e[1][0].channel.name, count: e[1].length, threadReplies: e[1].filter(m => m.permalink.includes(\"thread_ts=\") && !m.permalink.includes(`thread_ts=${m.ts}`)).length, oldest: e[1][e[1].length - 1].ts, recent: e[1].slice(0, 2).map(m => ({ url: m.permalink, ts: m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) })) })).sort((a, b) => b.count - a.count);\nconst total = channels.reduce((n, c) => n + c.count, 0);\nconst threadReplies = channels.reduce((n, c) => n + c.threadReplies, 0);\nconst mentioned = mentionPages.flatMap(r => r.messages.matches).filter(m => m.user !== me && !m.channel.is_im && !m.channel.is_mpim).map(m => ({ url: m.permalink, ts: m.ts, author: m.username, channel: m.channel.id, name: m.channel.name, thread: m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts, text: m.text.split(\"\\n\").join(\" \").split(\" \").some(t => t.length >= 20 && [\"xoxb-\", \"xoxp-\", \"xoxa-\", \"xoxs-\", \"xoxe\", \"sk-\", \"ghp_\", \"gho_\", \"ghu_\", \"ghs_\", \"ghr_\", \"github_pat_\", \"vck_\", \"AKIA\", \"eyJ\"].some(p => t.includes(p))) ? \"[withheld]\" : m.text.split(\"\\n\").join(\" \").slice(0, 160) }));\nconst ownMessages = ownPages.flatMap(r => r.messages.matches).filter(m => m.user === me && !m.channel.is_im && !m.channel.is_mpim);\nconst lastOwn = Object.fromEntries(Object.entries(Object.groupBy(ownMessages, m => `${m.channel.id}:${m.permalink.includes(\"thread_ts=\") ? m.permalink.split(\"thread_ts=\")[1].split(\"&\")[0] : m.ts}`)).map(e => [e[0], e[1].reduce((t, m) => Math.max(t, Number(m.ts)), 0)]));\nconst oldestOwn = channels.length ? channels.reduce((t, c) => Math.min(t, Number(c.oldest)), Number(channels[0].oldest)) : 0;\nconst newestOnly = ownPages[0].messages.paging.pages > ownPages.length;\nconst known = mentioned.map(m => ({ ...m, replied: (lastOwn[`${m.channel}:${m.thread}`] ?? 0) > Number(m.ts) }));\nconst judged = known.filter(m => m.replied || !newestOnly || Number(m.ts) >= oldestOwn);\nconst open = judged.filter(m => !m.replied);\nreturn {\n me: { id: me, name: auth.user },\n window: { days: DAYS, after, searchTotal: ownPages[0].messages.paging.total, pagesTotal: ownPages[0].messages.paging.pages, pagesRead: ownPages.length, newestOnly },\n scope: \"public and private channels; DMs and group DMs are dropped before counting\",\n own: {\n total,\n threadReplies,\n topLevel: total - threadReplies,\n oldest: channels.length ? oldestOwn : null,\n channelCount: channels.length,\n channels: channels.slice(0, 25).map(c => ({ id: c.id, channel: c.channel, count: c.count, threadReplies: c.threadReplies, recent: c.recent }))\n },\n mentions: {\n searchTotal: mentionPages[0].messages.paging.total,\n pagesTotal: mentionPages[0].messages.paging.pages,\n pagesRead: mentionPages.length,\n capped: mentionPages[0].messages.paging.pages > mentionPages.length,\n found: mentioned.length,\n judged: judged.length,\n notJudged: mentioned.length - judged.length,\n replied: judged.length - open.length,\n unanswered: open.length,\n unansweredItems: open.slice(0, 20).map(m => ({ url: m.url, channel: m.name, author: m.author, ts: m.ts, text: m.text })),\n basis: \"replied = the user posted later in the same thread; reactions are not read (the token lacks reactions:read); a mention not yet answered in a thread may have been answered elsewhere\"\n },\n memberships: { count: member.channels.length, names: member.channels.map(c => c.name).sort(), more: Boolean(member.response_metadata?.next_cursor) }\n};\n```\n"};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
declare const _default: import("eve/tools").ToolDefinition<Record<string, never>, {
|
|
2
2
|
dir: string;
|
|
3
3
|
files: string[];
|
|
4
|
+
githubFacts: string;
|
|
4
5
|
}> & {
|
|
5
6
|
execute(input: Record<string, never>, ctx: import("eve/tools").ToolContext): Promise<{
|
|
6
7
|
dir: string;
|
|
7
8
|
files: string[];
|
|
9
|
+
githubFacts: string;
|
|
8
10
|
}>;
|
|
9
11
|
};
|
|
10
12
|
export default _default;
|
|
@@ -4,16 +4,27 @@ import { defineTool } from "eve/tools";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import files from "../onboarding/files.gen.mjs";
|
|
6
6
|
const DIR = "/tmp/onboarding-skill";
|
|
7
|
+
// `gh` runs an extension from a directory named `gh-<name>` holding an executable of that name. A `git__gh` command can only run
|
|
8
|
+
// `gh`, `git` or `gh-signed-commit`, so the facts script runs as the `gh onboarding-facts` extension: inside that one command
|
|
9
|
+
// the firewall swaps the user's token in for the script's own `gh api` calls, and the script's JSON lands in FACTS_FILE.
|
|
10
|
+
const EXTENSION = "onboarding-facts";
|
|
11
|
+
const EXTENSION_DIR = `${DIR}/gh-${EXTENSION}`;
|
|
12
|
+
const FACTS_FILE = `${DIR}/github-facts.json`;
|
|
13
|
+
const LAUNCHER = `#!/bin/sh\nexec node ${DIR}/github-facts.mjs > ${FACTS_FILE}\n`;
|
|
7
14
|
export default defineTool({
|
|
8
|
-
description: `Starts a fresh ${DIR} in your sandbox, writes the onboarding-profile skill's scripts into it and returns the directory. Call it once, before the skill's GitHub step.`,
|
|
15
|
+
description: `Starts a fresh ${DIR} in your sandbox, writes the onboarding-profile skill's scripts into it and installs the \`gh ${EXTENSION}\` extension that gathers the GitHub facts into ${FACTS_FILE}, and returns the directory. Call it once, before the skill's GitHub step.`,
|
|
9
16
|
inputSchema: z.object({}),
|
|
10
17
|
async execute(_input, ctx) {
|
|
11
18
|
const sandbox = await ctx.getSandbox();
|
|
12
|
-
const made = await sandbox.run({ command: `rm -rf ${DIR} && mkdir -p ${
|
|
19
|
+
const made = await sandbox.run({ command: `rm -rf ${DIR} && mkdir -p ${EXTENSION_DIR}` });
|
|
13
20
|
if (made.exitCode !== 0)
|
|
14
21
|
throw new Error(`mkdir failed: ${made.stderr || made.stdout}`);
|
|
15
22
|
for (const [name, content] of Object.entries(files))
|
|
16
23
|
await sandbox.writeTextFile({ path: `${DIR}/${name}`, content });
|
|
17
|
-
|
|
24
|
+
await sandbox.writeTextFile({ path: `${EXTENSION_DIR}/gh-${EXTENSION}`, content: LAUNCHER });
|
|
25
|
+
const installed = await sandbox.run({ command: `chmod +x ${EXTENSION_DIR}/gh-${EXTENSION} && (gh extension remove ${EXTENSION} >/dev/null 2>&1 || true) && cd ${EXTENSION_DIR} && gh extension install .` });
|
|
26
|
+
if (installed.exitCode !== 0)
|
|
27
|
+
throw new Error(`gh extension install failed: ${installed.stderr || installed.stdout}`);
|
|
28
|
+
return { dir: DIR, files: Object.keys(files), githubFacts: FACTS_FILE };
|
|
18
29
|
},
|
|
19
30
|
});
|