diff --git a/LifeOS/install/LIFEOS/PULSE/Conduit/BuildInsight.ts b/LifeOS/install/LIFEOS/PULSE/Conduit/BuildInsight.ts index edbbf85f9e..6890c3447f 100755 --- a/LifeOS/install/LIFEOS/PULSE/Conduit/BuildInsight.ts +++ b/LifeOS/install/LIFEOS/PULSE/Conduit/BuildInsight.ts @@ -56,8 +56,10 @@ function summarize(events: ConduitEvent[]): { text: string; since: string | null const appMin = new Map(); const subjects: string[] = []; const slugs = new Set(); + const prTitles: string[] = []; let commits = 0; let sessions = 0; + let prs = 0; let since: string | null = null; for (const e of events) { @@ -73,6 +75,10 @@ function summarize(events: ConduitEvent[]): { text: string; since: string | null sessions++; const s = e.detail?.lastSlug; if (typeof s === "string") slugs.add(s); + } else if (e.type === "github-pr") { + prs++; + const t = e.detail?.title; + if (typeof t === "string" && t && prTitles.length < MAX_SUBJECTS) prTitles.push(t); } } @@ -85,6 +91,7 @@ function summarize(events: ConduitEvent[]): { text: string; since: string | null `Foreground apps today (minutes): ${apps.join(", ") || "(none)"}`, `LifeOS coding sessions: ${sessions}${slugs.size ? `; recent slugs: ${[...slugs].slice(0, MAX_SLUGS).join(", ")}` : ""}`, `Git commits: ${commits}${subjects.length ? `; subjects: ${subjects.join(" | ")}` : ""}`, + `Pull requests: ${prs}${prTitles.length ? `; titles: ${prTitles.join(" | ")}` : ""}`, ].join("\n"); return { text, since }; diff --git a/LifeOS/install/LIFEOS/PULSE/Conduit/adapters/github.ts b/LifeOS/install/LIFEOS/PULSE/Conduit/adapters/github.ts new file mode 100644 index 0000000000..d076af20db --- /dev/null +++ b/LifeOS/install/LIFEOS/PULSE/Conduit/adapters/github.ts @@ -0,0 +1,99 @@ +/** + * github adapter — YOUR OWN commit + PR activity, from GitHub. + * + * The git adapter only sees commits in a LOCAL `git log`. A lot of real work never + * lands there: PRs opened with `gh`, commits made through the GitHub API/web, work + * on a machine other than this one. Those are invisible to git-log but they ARE + * your activity — this adapter captures them straight from GitHub's per-user event + * feed so BuildInsight reflects the work you actually did. + * + * Auth is delegated entirely to the `gh` CLI (no tokens handled here). `gh` already + * honors GH_CONFIG_DIR, so when the GitHub account lives under a different OS user + * than the one running Conduit, point `githubConfigDir` at that account's gh config. + * + * One-shot, fault-isolated, and cursor-guarded exactly like the git adapter: a + * failed feed read emits nothing and does NOT advance the cursor, so no window is + * ever skipped; the re-scan overlap is de-duped by SHA at rollup. + */ +import { execFileSync } from "node:child_process"; +import type { ConduitConfig } from "../config.ts"; +import { readState, writeState } from "../store.ts"; +import type { ConduitEvent } from "../types.ts"; + +/** One `gh api` call → parsed JSON, or null on any failure (offline, unauthed, bad JSON). */ +function ghApi(bin: string, path: string, cfgDir?: string): unknown { + try { + const out = execFileSync(bin, ["api", "-H", "Accept: application/vnd.github+json", path], { + encoding: "utf8", + timeout: 15_000, + env: cfgDir ? { ...process.env, GH_CONFIG_DIR: cfgDir } : process.env, + }); + return JSON.parse(out); + } catch { + return null; + } +} + +export function capture(config: ConduitConfig): ConduitEvent[] { + const cfgDir = config.githubConfigDir; + const bin = config.githubCliPath || "gh"; + + // Resolve the login: explicit config wins, else ask gh who it's authed as. + let login = config.githubUser; + if (!login) { + const me = ghApi(bin, "user", cfgDir) as { login?: string } | null; + login = me?.login; + } + if (!login) return []; // gh missing / not authed / offline — silent no-op + + const state = readState(); + const since = + (state.lastGithubPollTs as string) || + new Date(Date.now() - config.pollIntervalSec * 2000).toISOString(); + + const feed = ghApi(bin, `users/${login}/events?per_page=100`, cfgDir); + if (!Array.isArray(feed)) return []; // failed read — do NOT advance the cursor + + const events: ConduitEvent[] = []; + for (const ev of feed as Array>) { + const ts: string | undefined = ev.created_at; + if (!ts || ts <= since) continue; // only strictly-new events + const repo: string = ev.repo?.name ?? ""; + + if (ev.type === "PushEvent") { + for (const c of ev.payload?.commits ?? []) { + if (c.distinct === false) continue; // skip commits already counted on another ref + events.push({ + ts, + type: "git-commit", + source: "github", + repo, + detail: { sha: String(c.sha ?? "").slice(0, 10), subject: String(c.message ?? "").split("\n")[0] }, + }); + } + } else if (ev.type === "PullRequestEvent") { + const action: string = ev.payload?.action ?? ""; + if (action !== "opened" && action !== "reopened" && action !== "closed") continue; + const pr = ev.payload?.pull_request ?? {}; + // The per-user events feed ships a MINIMAL pull_request (no title). The head + // branch name is present and just as descriptive, so use it as the label + // rather than spending an extra API call per PR to fetch the title. + const branch = String(pr.head?.ref ?? ""); + events.push({ + ts, + type: "github-pr", + source: "github", + repo, + detail: { + number: ev.payload?.number, + action, + branch, + title: String(pr.title ?? branch), // `title` is what BuildInsight reads + }, + }); + } + } + + writeState({ lastGithubPollTs: new Date().toISOString() }); // advance only after a clean read + return events; +} diff --git a/LifeOS/install/LIFEOS/PULSE/Conduit/conduit.ts b/LifeOS/install/LIFEOS/PULSE/Conduit/conduit.ts index db4981fc18..0317daec25 100755 --- a/LifeOS/install/LIFEOS/PULSE/Conduit/conduit.ts +++ b/LifeOS/install/LIFEOS/PULSE/Conduit/conduit.ts @@ -16,6 +16,7 @@ import { capture as captureAppFocus } from "./adapters/appFocus.ts"; import { capture as captureClaude } from "./adapters/claudeSession.ts"; import { capture as captureGit } from "./adapters/git.ts"; +import { capture as captureGithub } from "./adapters/github.ts"; import { DEFAULT_CONFIG, loadConfig } from "./config.ts"; import { CONFIG_PATH, DATA_ROOT } from "./paths.ts"; import { buildDailyRecord, renderMarkdown, writeDailyRecord } from "./rollup.ts"; @@ -34,6 +35,7 @@ function runCapture(): number { [config.sources.appFocus, () => captureAppFocus(config)], [config.sources.git, () => captureGit(config)], [config.sources.claudeSession, () => captureClaude()], + [config.sources.github, () => captureGithub(config)], ]; for (const [enabled, run] of runners) { if (!enabled) continue; diff --git a/LifeOS/install/LIFEOS/PULSE/Conduit/config.ts b/LifeOS/install/LIFEOS/PULSE/Conduit/config.ts index 9c8d89fb44..a8c88f04ff 100644 --- a/LifeOS/install/LIFEOS/PULSE/Conduit/config.ts +++ b/LifeOS/install/LIFEOS/PULSE/Conduit/config.ts @@ -18,9 +18,24 @@ export interface ConduitConfig { appFocus: boolean; git: boolean; claudeSession: boolean; + /** GitHub activity (remote commits + PRs authored by you). Off by default. */ + github: boolean; }; /** Absolute repo paths watched for new commits. Empty by default. */ repos: string[]; + /** + * GitHub login whose commit/PR activity the github source captures. Empty → + * the adapter resolves it from `gh api user` (the authenticated account). + */ + githubUser?: string; + /** + * Optional GH_CONFIG_DIR for the `gh` calls the github source makes — set this + * when the account that owns the GitHub auth differs from the OS user running + * Conduit (e.g. a service account). Empty → ambient `gh` auth. + */ + githubConfigDir?: string; + /** Path to the `gh` binary. Empty → resolve `gh` from PATH. */ + githubCliPath?: string; /** Days of raw event logs retained after rollup. */ retentionDays: number; } @@ -28,7 +43,7 @@ export interface ConduitConfig { export const DEFAULT_CONFIG: ConduitConfig = { enabled: true, pollIntervalSec: 120, - sources: { appFocus: true, git: true, claudeSession: true }, + sources: { appFocus: true, git: true, claudeSession: true, github: false }, repos: [], retentionDays: 30, }; @@ -47,8 +62,12 @@ export function clampConfig(raw: Partial): ConduitConfig { appFocus: raw.sources?.appFocus !== false, git: raw.sources?.git !== false, claudeSession: raw.sources?.claudeSession !== false, + github: raw.sources?.github === true, // opt-in: off unless explicitly enabled }, repos: Array.isArray(raw.repos) ? raw.repos.filter((r) => typeof r === "string") : [], + githubUser: typeof raw.githubUser === "string" && raw.githubUser.trim() ? raw.githubUser.trim() : undefined, + githubConfigDir: typeof raw.githubConfigDir === "string" && raw.githubConfigDir.trim() ? raw.githubConfigDir.trim() : undefined, + githubCliPath: typeof raw.githubCliPath === "string" && raw.githubCliPath.trim() ? raw.githubCliPath.trim() : undefined, retentionDays: Math.max(0, Math.floor(num(raw.retentionDays, DEFAULT_CONFIG.retentionDays))), }; } diff --git a/LifeOS/install/LIFEOS/PULSE/Conduit/sources.ts b/LifeOS/install/LIFEOS/PULSE/Conduit/sources.ts index 515336b1fb..1c66cfa300 100644 --- a/LifeOS/install/LIFEOS/PULSE/Conduit/sources.ts +++ b/LifeOS/install/LIFEOS/PULSE/Conduit/sources.ts @@ -11,7 +11,7 @@ import { loadConfig } from "./config.ts"; import { localDate, readDayEvents } from "./store.ts"; import type { ConduitEventType } from "./types.ts"; -type SourceId = "appFocus" | "git" | "claudeSession"; +type SourceId = "appFocus" | "git" | "claudeSession" | "github"; export interface SourceDescriptor { id: SourceId; @@ -43,6 +43,12 @@ export const SOURCE_DESCRIPTORS: readonly SourceDescriptor[] = [ captures: "Algorithm & session activity from the work-events log", eventType: "claude-session", }, + { + id: "github", + label: "GitHub activity", + captures: "Your commits & pull requests on GitHub — including remote/API work", + eventType: "github-pr", + }, ] as const; export interface SourceStatus extends SourceDescriptor { diff --git a/LifeOS/install/LIFEOS/PULSE/Conduit/types.ts b/LifeOS/install/LIFEOS/PULSE/Conduit/types.ts index 81b3fa68bf..dcd595e961 100644 --- a/LifeOS/install/LIFEOS/PULSE/Conduit/types.ts +++ b/LifeOS/install/LIFEOS/PULSE/Conduit/types.ts @@ -5,7 +5,7 @@ * Capture stores SPANS and METADATA, never keystrokes or content. */ -export type ConduitEventType = "app-focus" | "git-commit" | "claude-session"; +export type ConduitEventType = "app-focus" | "git-commit" | "claude-session" | "github-pr"; /** One captured signal. Appended as a single JSONL line. */ export interface ConduitEvent { diff --git a/LifeOS/install/LIFEOS/PULSE/Observability/src/app/conduit/page.tsx b/LifeOS/install/LIFEOS/PULSE/Observability/src/app/conduit/page.tsx index b580b6ac3f..a4c21d44d7 100644 --- a/LifeOS/install/LIFEOS/PULSE/Observability/src/app/conduit/page.tsx +++ b/LifeOS/install/LIFEOS/PULSE/Observability/src/app/conduit/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { Radar, Circle, GitCommit, Cpu, MonitorSmartphone, Sparkles } from "lucide-react"; +import { Radar, Circle, GitCommit, Cpu, MonitorSmartphone, Sparkles, Github } from "lucide-react"; import { PageShell, PageHeader, Panel, PanelHeader, StatTile, EmptyState } from "@/components/ui/chrome"; /** @@ -50,6 +50,7 @@ const sourceIcon: Record = { appFocus: , git: , claudeSession: , + github: , }; // Cool→warm cycle for content-type bars, drawn from the life-dimension tokens.