Skip to content
Open
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
7 changes: 7 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/Conduit/BuildInsight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ function summarize(events: ConduitEvent[]): { text: string; since: string | null
const appMin = new Map<string, number>();
const subjects: string[] = [];
const slugs = new Set<string>();
const prTitles: string[] = [];
let commits = 0;
let sessions = 0;
let prs = 0;
let since: string | null = null;

for (const e of events) {
Expand All @@ -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);
}
}

Expand All @@ -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 };
Expand Down
99 changes: 99 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/Conduit/adapters/github.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, any>>) {
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;
}
2 changes: 2 additions & 0 deletions LifeOS/install/LIFEOS/PULSE/Conduit/conduit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down
21 changes: 20 additions & 1 deletion LifeOS/install/LIFEOS/PULSE/Conduit/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,32 @@ 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;
}

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,
};
Expand All @@ -47,8 +62,12 @@ export function clampConfig(raw: Partial<ConduitConfig>): 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))),
};
}
Expand Down
8 changes: 7 additions & 1 deletion LifeOS/install/LIFEOS/PULSE/Conduit/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion LifeOS/install/LIFEOS/PULSE/Conduit/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -50,6 +50,7 @@ const sourceIcon: Record<string, React.ReactNode> = {
appFocus: <MonitorSmartphone className="w-4 h-4" />,
git: <GitCommit className="w-4 h-4" />,
claudeSession: <Cpu className="w-4 h-4" />,
github: <Github className="w-4 h-4" />,
};

// Cool→warm cycle for content-type bars, drawn from the life-dimension tokens.
Expand Down