207 lines
9.1 KiB
TypeScript
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');
|
|
}
|