Files
agent-loop/async-agent-lab/live/evidence.ts
2026-11-12 15:29:12 +08:00

207 lines
9.1 KiB
TypeScript

import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, isAbsolute, join, relative } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'node:url';
const home = homedir();
const project = fileURLToPath(new URL('../..', import.meta.url));
export const aiwork = process.env.AIWORK_REPO || join(home, 'Desktop/EMdev/dongdong-cowork');
const discoveryRoots = [project, join(home, 'Desktop/EMdev'), join(home, '.codex/worktrees'), aiwork];
const day = 86_400_000;
const shanghai = 8 * 3_600_000;
export function weekWindow(now = Date.now()) {
const today = new Date(now + shanghai);
const midnight = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
const start = midnight - ((today.getUTCDay() + 6) % 7) * day - shanghai;
const end = Math.min(now, start + 5 * day - 1);
const local = (t: number) => new Date(t + shanghai).toISOString().replace('Z', '+08:00');
return { start, end, from: local(start), through: local(end), timezone: 'Asia/Shanghai' };
}
export function within(root: string, path: string) {
const rel = relative(root, path);
return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
}
export function childEnvironment() {
// Git, rg and the evidence command only need local paths and locale.
return {
HOME: home, PATH: process.env.PATH, LANG: process.env.LANG,
CODEX_HOME: process.env.CODEX_HOME, XDG_DATA_HOME: process.env.XDG_DATA_HOME,
AIWORK_REPO: process.env.AIWORK_REPO, WEEKLY_AUTHOR_EMAILS: process.env.WEEKLY_AUTHOR_EMAILS,
GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0',
};
}
export function git(cwd: string, ...args: string[]) {
return execFileSync('git', ['--no-optional-locks', '-c', 'core.fsmonitor=false', '-C', cwd, ...args], {
encoding: 'utf8', env: childEnvironment(), timeout: 8000, maxBuffer: 24 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}
type Window = ReturnType<typeof weekWindow>;
type Session = { cwd: string; time: number };
type Source = { source: string; status: string; sessions: Session[] };
function fromDatabase(source: string, path: string, sql: string, window: Window): Source {
if (!existsSync(path)) return { source, status: '本机未找到会话库', sessions: [] };
try {
const db = new DatabaseSync(path, { readOnly: true, timeout: 300 });
try {
const sessions = db.prepare(sql).all(window.start, window.end) as unknown as Session[];
return { source, status: '已查询', sessions };
} finally {
db.close();
}
} catch {
return { source, status: '会话库无法读取', sessions: [] };
}
}
function fromDSH(window: Window): Source {
const source = 'DSH';
const dir = join(home, '.dsh/storages/session_projcache/sessions');
if (!existsSync(dir)) return { source, status: '本机未找到会话缓存', sessions: [] };
const sessions: Session[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
try {
const path = join(dir, entry.name);
if (statSync(path).size > 2_000_000) continue;
const record = JSON.parse(readFileSync(path, 'utf8')).record;
const cwd = record?.identity?.cwd;
const time = Number(record?.rows?.sessionListMetadata?.val?.lastPromptAt);
if (typeof cwd === 'string' && time >= window.start && time <= window.end) sessions.push({ cwd, time });
} catch {
// A cache entry may be replaced while DSH is running.
}
}
return { source, status: '已查询', sessions };
}
function sessionSources(window: Window): Source[] {
const codexHome = process.env.CODEX_HOME || join(home, '.codex');
const codexDB = existsSync(codexHome)
? readdirSync(codexHome).filter((file) => /^state_\d+\.sqlite$/.test(file)).sort((a, b) =>
Number(b.match(/\d+/)?.[0]) - Number(a.match(/\d+/)?.[0]))[0]
: undefined;
return [
fromDatabase('Codex', join(codexHome, codexDB || 'state_5.sqlite'),
'SELECT cwd, COALESCE(updated_at_ms, updated_at * 1000) AS time FROM threads WHERE COALESCE(updated_at_ms, updated_at * 1000) BETWEEN ? AND ?', window),
fromDSH(window),
fromDatabase('OpenCode', join(process.env.XDG_DATA_HOME || join(home, '.local/share'), 'opencode/opencode.db'),
'SELECT directory AS cwd, time_updated AS time FROM session WHERE parent_id IS NULL AND time_updated BETWEEN ? AND ?', window),
];
}
export function verifiedEmails() {
const configured = process.env.WEEKLY_AUTHOR_EMAILS;
if (configured) return configured.split(',').map((part) => part.trim().toLowerCase()).filter(Boolean);
try {
return git(aiwork, 'config', '--get', 'user.email').toLowerCase().split(/\r?\n/).filter(Boolean);
} catch {
return [];
}
}
function commits(cwd: string, window: Window, emails: string[]) {
if (!emails.length) return [];
const detachedHeads = git(cwd, 'worktree', 'list', '--porcelain').split('\n\n')
.filter((entry) => entry.split('\n').includes('detached'))
.flatMap((entry) => entry.match(/^HEAD ([0-9a-f]{40,64})$/m)?.[1] || []);
let raw: string;
try {
raw = git(cwd, 'log', '--all', ...detachedHeads, '--no-merges', '--fixed-strings',
...emails.map((email) => `--author=<${email}>`), '--format=%H%x1f%aI%x1f%ae%x1f%s%x00', '--');
} catch {
if (!git(cwd, 'for-each-ref', '--count=1', '--format=%(refname)') && !detachedHeads.length) return [];
throw new Error(`无法读取 ${basename(cwd)} 的 Git 历史`);
}
const seen = new Set<string>();
return raw.split('\0').flatMap((line) => {
const [sha, authored, email, subject] = line.trim().split('\x1f');
const timestamp = Date.parse(authored);
if (!emails.includes(email?.toLowerCase()) || !(timestamp >= window.start && timestamp <= window.end)) return [];
// Same author, author time and subject typically identify a replayed commit.
const identity = `${email}\0${authored}\0${subject}`;
if (seen.has(identity)) return [];
seen.add(identity);
return [{ sha: sha.slice(0, 12), authored, subject: subject.slice(0, 250) }];
}).sort((a, b) => Date.parse(b.authored) - Date.parse(a.authored)).slice(0, 20);
}
export function repositoryLog(cwd: string) {
const window = weekWindow();
const emails = verifiedEmails();
return { repository: cwd, window, verifiedAuthors: emails.length, commits: commits(cwd, window, emails) };
}
function artifacts(cwd: string, window: Window) {
const paths: { path: string; modified: string }[] = [];
for (const folder of ['research', 'explainers', 'docs']) {
try {
for (const entry of readdirSync(join(cwd, folder), { withFileTypes: true })) {
if (!entry.isFile() || !/^[\w\u4e00-\u9fa5-]+\.(md|html)$/i.test(entry.name)) continue;
const path = join(cwd, folder, entry.name);
const stat = statSync(path);
if (stat.mtimeMs >= window.start && stat.mtimeMs <= window.end && stat.size < 300_000) {
paths.push({ path: `${folder}/${entry.name}`, modified: stat.mtime.toISOString() });
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
return paths.sort((a, b) => b.modified.localeCompare(a.modified)).slice(0, 8);
}
export function collectEvidence() {
const window = weekWindow();
const sources = sessionSources(window);
const groups = new Map<string, { path: string; worktrees: Set<string>; clues: Set<string> }>();
const candidates = new Map<string, Set<string>>();
for (const source of sources) {
for (const { cwd } of source.sessions) {
if (!candidates.has(cwd)) candidates.set(cwd, new Set());
candidates.get(cwd)?.add(source.source);
}
}
for (const [cwd, clues] of candidates) {
try {
if (!isAbsolute(cwd)) continue;
const real = realpathSync(cwd);
if (!discoveryRoots.some((root) => within(root, real))) continue;
const root = git(real, 'rev-parse', '--show-toplevel');
if (!discoveryRoots.some((allowed) => within(allowed, root))) continue;
const common = git(root, 'rev-parse', '--path-format=absolute', '--git-common-dir');
const group = groups.get(common) ?? { path: root, worktrees: new Set(), clues: new Set() };
group.worktrees.add(root);
for (const clue of clues) group.clues.add(clue);
groups.set(common, group);
} catch {
// Some recent sessions have no Git repo or refer to a removed worktree.
}
}
const emails = verifiedEmails();
return {
window,
sources: sources.map(({ source, status, sessions }) => ({ source, status, sessions: sessions.length })),
verifiedAuthors: emails.length,
repositories: [...groups.values()].map(({ path, worktrees, clues }) => ({
path, sources: [...clues], worktrees: [...worktrees],
commits: commits(path, window, emails),
artifacts: [...worktrees].flatMap((cwd) => artifacts(cwd, window).map((entry) => ({ cwd, ...entry }))),
})),
};
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const repo = process.argv[2];
const evidence = repo ? repositoryLog(repo) : collectEvidence();
process.stdout.write(JSON.stringify(evidence) + '\n');
}