diff --git a/packages/core/src/agent/agent-tools.ts b/packages/core/src/agent/agent-tools.ts index 7d05c0c..c16b17b 100644 --- a/packages/core/src/agent/agent-tools.ts +++ b/packages/core/src/agent/agent-tools.ts @@ -17,10 +17,11 @@ export const QUERY_PROJECT_TOOL: ToolDecl = { properties: { view: { type: 'string', - enum: ['focus', 'board', 'calibration', 'issue', 'search'], + enum: ['focus', 'board', 'calibration', 'issue', 'search', 'standup'], description: 'focus = Now/Next/Later; board = issues by lifecycle column; calibration = estimate-vs-actual; ' + - 'issue = one issue (needs filters.issueId); search = issues matching filters.query.', + 'issue = one issue (needs filters.issueId); search = issues matching filters.query; ' + + "standup = today's plan + overnight drift + the stalest blocker.", }, filters: { type: 'object', diff --git a/packages/core/src/agent/agent.test.ts b/packages/core/src/agent/agent.test.ts index dda1602..ae4af98 100644 --- a/packages/core/src/agent/agent.test.ts +++ b/packages/core/src/agent/agent.test.ts @@ -276,6 +276,7 @@ describe('buildProjectView', () => { }) it('unbuilt views return a notImplemented marker, not fabricated data', () => { - expect(buildProjectView('standup', undefined, snap, asOf)).toEqual({ notImplemented: 'standup' }) + expect(buildProjectView('milestone', undefined, snap, asOf)).toEqual({ notImplemented: 'milestone' }) + expect(buildProjectView('runway', undefined, snap, asOf)).toEqual({ notImplemented: 'runway' }) }) }) diff --git a/packages/core/src/agent/memory-v0.test.ts b/packages/core/src/agent/memory-v0.test.ts new file mode 100644 index 0000000..0100110 --- /dev/null +++ b/packages/core/src/agent/memory-v0.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import type { DirectiveEntry } from '../directives/record-directive-v0.js' +import type { Focus, ScheduledItem } from '../scheduler/scheduler-v0.js' +import { + activeDirectives, + assembleHotContext, + estimateTokens, + HOT_CONTEXT_BUDGET_TOKENS, +} from './memory-v0.js' + +const item = (number: number, title: string): ScheduledItem => + ({ number, title, labels: [], order: 0, startDay: 0, endDay: 1, durationDays: 1, blockedBy: [], blocks: [], critical: false, rationale: '' }) + +const focus: Focus = { now: item(7, 'Fix lifecycle inference'), next: item(8, 'Webhook listener'), later: null } + +const directive = (over: Partial): DirectiveEntry => ({ + id: 'd1', + ts: '2026-02-10T09:00:00Z', + status: 'accepted', + kind: 'note', + quote: 'pilots come first', + ...over, +}) + +describe('memory-v0 (#27)', () => { + it('estimateTokens is a slight over-estimate (~4 chars/token)', () => { + expect(estimateTokens('')).toBe(0) + expect(estimateTokens('abcd')).toBe(1) + expect(estimateTokens('a'.repeat(4001))).toBe(1001) + }) + + it('activeDirectives keeps accepted/amended, most-recent-first', () => { + const ds = [ + directive({ id: 'a', ts: '2026-02-01T00:00:00Z', status: 'accepted', quote: 'old' }), + directive({ id: 'b', ts: '2026-02-11T00:00:00Z', status: 'amended', quote: 'new' }), + directive({ id: 'c', ts: '2026-02-12T00:00:00Z', status: 'withdrawn', quote: 'gone' }), + directive({ id: 'd', ts: '2026-02-09T00:00:00Z', status: 'proposed', quote: 'maybe' }), + ] + expect(activeDirectives(ds).map((d) => d.quote)).toEqual(['new', 'old']) + }) + + it('assembles hot context under the 2k budget even with a huge charter', () => { + const huge = 'Charter line that goes on and on. '.repeat(2000) // ~34k tokens + const ds = Array.from({ length: 50 }, (_, i) => + directive({ id: `d${i}`, ts: `2026-02-${String((i % 27) + 1).padStart(2, '0')}T00:00:00Z`, quote: `directive number ${i}` }), + ) + const out = assembleHotContext({ charter: huge, directives: ds, focus }) + + expect(estimateTokens(out)).toBeLessThanOrEqual(HOT_CONTEXT_BUDGET_TOKENS) + // focus (tiny) is always kept; the charter is the part that gets truncated + expect(out).toContain('## Focus') + expect(out).toContain('#7 Fix lifecycle inference') + expect(out).toContain('…') // charter was clamped + // at least some recent directives survived + expect(out).toContain('## Active directives') + }) + + it('never inlines ticket bodies — only numbers + titles appear for focus', () => { + // The assembler takes no issue bodies by construction; focus shows #number title only. + const out = assembleHotContext({ charter: 'Ship the beta.', directives: [directive({})], focus }) + expect(out).toContain('Now: #7 Fix lifecycle inference') + expect(out).toContain('[note] pilots come first') + expect(out).not.toMatch(/body|description|comment/i) + }) + + it('degrades to just focus when there is no charter or directives', () => { + const out = assembleHotContext({ charter: '', directives: [], focus }) + expect(out).toBe(['## Focus', 'Now: #7 Fix lifecycle inference', 'Next: #8 Webhook listener', 'Later: —'].join('\n')) + }) +}) diff --git a/packages/core/src/agent/memory-v0.ts b/packages/core/src/agent/memory-v0.ts new file mode 100644 index 0000000..61d7306 --- /dev/null +++ b/packages/core/src/agent/memory-v0.ts @@ -0,0 +1,102 @@ +/** + * Memory layers, v0 (#27). Reginald's context is tiered so the model always sees + * what matters without ever copying ticket data into the prompt: + * + * - HOT (this module) — charter + active directives + the focus snapshot, packed + * under a hard token budget. Assembled fresh each turn; it's the system-prompt seed. + * - WARM — the append-only directive/event ledger + periodic digest, in pm-state. + * Not inlined; summarized on demand. + * - COLD — gitea + the sidecar, reached through `query_project` tools. Ticket bodies, + * comments, and per-issue detail live here and are NEVER copied into memory — + * the model fetches them by number when it needs them. + * + * The invariant: HOT stays under budget, and nothing ticket-shaped is inlined. + */ + +import type { DirectiveEntry } from '../directives/record-directive-v0.js' +import type { Focus } from '../scheduler/scheduler-v0.js' + +/** The hot layer's hard ceiling (#27: hot context assembles under 2k tokens). */ +export const HOT_CONTEXT_BUDGET_TOKENS = 2000 + +/** + * Tokenizer-free estimate (~4 chars/token). Deliberately a slight over-estimate so + * a real tokenizer never exceeds what this predicts — the budget stays safe. + */ +export function estimateTokens(text: string): number { + return Math.ceil(text.length / 4) +} + +/** Directives that still bind: accepted or amended, most-recent-first. */ +export function activeDirectives(all: DirectiveEntry[]): DirectiveEntry[] { + return all + .filter((d) => d.status === 'accepted' || d.status === 'amended') + .slice() + .sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0)) +} + +export interface HotContextInputs { + /** The project charter markdown (hot-memory seed). */ + charter: string + /** The directive ledger (any status — filtered to active here). */ + directives: DirectiveEntry[] + /** The current Now/Next/Later focus, or null when nothing is scheduled. */ + focus: Focus | null +} + +function focusBlock(focus: Focus | null): string { + if (!focus) return '' + const slot = (label: string, item: Focus['now']) => (item ? `${label}: #${item.number} ${item.title}` : `${label}: —`) + return ['## Focus', slot('Now', focus.now), slot('Next', focus.next), slot('Later', focus.later)].join('\n') +} + +function directivesBlock(directives: DirectiveEntry[]): string[] { + // one compact line each; the verbatim quote is the payload, kind is the tag + return directives.map((d) => `- [${d.kind}] ${d.quote}`) +} + +/** Truncate to a token budget on a whitespace boundary, with an ellipsis marker. */ +function clampToTokens(text: string, budgetTokens: number): string { + if (estimateTokens(text) <= budgetTokens) return text + const maxChars = Math.max(0, budgetTokens * 4 - 1) + const cut = text.slice(0, maxChars) + const lastBreak = cut.lastIndexOf('\n') + return `${(lastBreak > maxChars * 0.6 ? cut.slice(0, lastBreak) : cut).trimEnd()}\n…` +} + +/** + * Assemble the HOT context under `budget` tokens. Priority when space is tight: + * the focus snapshot (tiny, always kept) → the most recent active directives + * (each while they fit) → the charter fills whatever budget remains (truncated). + * Never inlines ticket bodies — only charter text, directive quotes, and focus + * titles, all authored/short. Returns a single prompt-ready block. + */ +export function assembleHotContext(inputs: HotContextInputs, budget = HOT_CONTEXT_BUDGET_TOKENS): string { + const focus = focusBlock(inputs.focus) + const focusTokens = focus ? estimateTokens(focus) : 0 + + // fit the most recent active directives into ~⅔ of what's left after focus + const active = activeDirectives(inputs.directives) + const directiveCap = Math.max(0, Math.floor((budget - focusTokens) * (2 / 3))) + const keptDirectives: string[] = [] + let directiveTokens = 0 + for (const line of directivesBlock(active)) { + const t = estimateTokens(line) + 1 + if (directiveTokens + t > directiveCap) break + keptDirectives.push(line) + directiveTokens += t + } + const directives = keptDirectives.length ? ['## Active directives', ...keptDirectives].join('\n') : '' + + // Measure the fixed tail (directives + focus, with their joiner) exactly, then + // give the charter the true remainder — reserving for the "## Charter" header, + // the block joiner, and the truncation ellipsis so the total never exceeds budget. + const tail = [directives, focus].filter(Boolean).join('\n\n') + const tailTokens = tail ? estimateTokens(tail) : 0 + const reserve = estimateTokens(`## Charter\n${tail ? '\n\n' : ''}\n…`) + const charterBudget = Math.max(0, budget - tailTokens - reserve) + const charterBody = inputs.charter.trim() ? clampToTokens(inputs.charter.trim(), charterBudget) : '' + const charter = charterBody ? `## Charter\n${charterBody}` : '' + + return [charter, tail].filter(Boolean).join('\n\n') +} diff --git a/packages/core/src/agent/query-project.test.ts b/packages/core/src/agent/query-project.test.ts new file mode 100644 index 0000000..9848214 --- /dev/null +++ b/packages/core/src/agent/query-project.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' + +import { extractLabelFacts } from '../labels/label-schema.js' +import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js' +import type { GiteaIssue } from '../gitea/types.js' +import { buildProjectView, type ProjectSnapshot } from './query-project.js' + +const asOf = new Date('2026-02-12T00:00:00Z') + +function issue(over: Partial): GiteaIssue { + const labels = over.labels ?? ['est/2d'] + return { + number: 1, + title: '#1', + body: '', + state: 'open', + labels, + facts: extractLabelFacts(labels), + milestone: null, + assignee: null, + assignees: [], + createdAt: '2026-02-02T09:00:00Z', + updatedAt: '2026-02-02T09:00:00Z', + closedAt: null, + url: '', + ...over, + } +} + +describe('buildProjectView: standup (#28)', () => { + it('surfaces schedule drift + a stale blocker, with a per-person plan', () => { + // #7 started work 6 working days ago (steeping) and blocks #8; est is 2d → past estimate. + const steeping = issue({ number: 7, labels: ['est/2d', 'p/1'], assignee: 'christian' }) + const blocked = issue({ number: 8, labels: ['est/3d', 'p/2'], assignee: 'stephen' }) + const timelines: Record = { + 7: [{ type: 'commit', at: '2026-02-04T09:00:00Z' }], + } + const snap: ProjectSnapshot = { issues: [steeping, blocked], timelines, deps: [{ issue: 8, dependsOn: 7 }] } + + const v = buildProjectView('standup', undefined, snap, asOf) as { + plan: { who: string; issue: number; why: string }[] + drift: { issue: number; note: string }[] + nag: { issue: number; steepingDays: number; blocks: number[] } | null + } + + // a stale blocker is nagged, and it's the steeping one that blocks another + expect(v.nag).not.toBeNull() + expect(v.nag!.issue).toBe(7) + expect(v.nag!.steepingDays).toBeGreaterThan(0) + expect(v.nag!.blocks).toContain(8) + + // drift caught the past-estimate steep + expect(v.drift.some((d) => d.issue === 7)).toBe(true) + + // plan gives an earliest pick per person (both assignees represented) + expect(v.plan.map((p) => p.who)).toEqual(expect.arrayContaining(['christian', 'stephen'])) + // #7 leads its person's plan on the critical path + expect(v.plan.find((p) => p.issue === 7)?.why).toContain('critical') + }) + + it('is calm when nothing is steeping (no nag, empty drift)', () => { + const snap: ProjectSnapshot = { + issues: [issue({ number: 1, labels: ['est/2d'] })], + timelines: {}, + deps: [], + } + const v = buildProjectView('standup', undefined, snap, asOf) as { + drift: unknown[] + nag: unknown | null + } + expect(v.nag).toBeNull() + expect(v.drift).toEqual([]) + }) +}) diff --git a/packages/core/src/agent/query-project.ts b/packages/core/src/agent/query-project.ts index aa488c7..41bfaf7 100644 --- a/packages/core/src/agent/query-project.ts +++ b/packages/core/src/agent/query-project.ts @@ -4,9 +4,9 @@ * deterministic code (scheduler, lifecycle inference, calibration); the model * only requests a shape and narrates it — it never computes (decisions.md). * - * v0 serves focus / board / calibration / issue. The remaining views - * (milestone / runway / standup / search) return a `notImplemented` marker so - * the model degrades honestly instead of inventing data. + * Serves focus / board / calibration / issue / search / standup. The remaining + * views (milestone / runway) return a `notImplemented` marker so the model + * degrades honestly instead of inventing data. */ import { fitCalibration, calibrationSamples } from '../calibration/calibration-v0.js' @@ -121,6 +121,62 @@ function searchView(snap: ProjectSnapshot, filters: QueryFilters) { } } +/** + * Standup — the morning ritual as a compact, model-narratable payload: today's + * plan (the scheduler's earliest pick per person), overnight drift (real + * anomalies — issues sitting in review or steeping past their estimate), and the + * single stalest blocker to nag about. All from deterministic code; the model + * narrates it. Satisfies #28's "surfaces schedule drift + at least one stale blocker". + */ +function standupView(snap: ProjectSnapshot, asOf: Date) { + const plan = schedule(toSchedulable(snap.issues), snap.deps) + const open = snap.issues.filter((i) => i.state === 'open') + const infOf = (i: GiteaIssue) => inferLifecycle(i, snap.timelines[i.number] ?? [], asOf) + + // plan: the earliest scheduled pick per assignee (dependency + priority order) + const seen = new Set() + const planPicks: { who: string; issue: number; title: string; why: string }[] = [] + for (const item of plan.items) { + const who = open.find((i) => i.number === item.number)?.assignee ?? 'unassigned' + if (seen.has(who)) continue + seen.add(who) + planPicks.push({ + who, + issue: item.number, + title: item.title, + why: item.critical + ? 'on the critical path' + : item.blocks.length + ? `blocks ${item.blocks.map((b) => `#${b}`).join(', ')}` + : 'next by dependency + priority', + }) + } + + // drift: real overnight anomalies (review-sitting, steeping past estimate) + const drift: { issue: number; note: string }[] = [] + for (const i of open) { + const inf = infOf(i) + if (inf.column === 'review') drift.push({ issue: i.number, note: `#${i.number} is sitting in review` }) + else if (inf.steepingDays != null && inf.steepingDays > (i.facts.estimateDays ?? 2)) + drift.push({ + issue: i.number, + note: `#${i.number} has steeped ${inf.steepingDays}d past its ${i.facts.estimateDays ?? 2}d estimate`, + }) + } + + // nag: the single longest-steeping open issue + what it blocks + let nag: { issue: number; steepingDays: number; blocks: number[] } | null = null + for (const i of open) { + const inf = infOf(i) + if (inf.steepingDays == null) continue + if (!nag || inf.steepingDays > nag.steepingDays) { + nag = { issue: i.number, steepingDays: inf.steepingDays, blocks: plan.items.find((it) => it.number === i.number)?.blocks ?? [] } + } + } + + return { date: asOf.toISOString().slice(0, 10), plan: planPicks.slice(0, 5), drift: drift.slice(0, 5), nag } +} + /** Build the compact payload for one view. Unknown/unbuilt views return a marker. */ export function buildProjectView( view: ProjectView, @@ -140,6 +196,8 @@ export function buildProjectView( return issueView(snap, f, asOf) case 'search': return searchView(snap, f) + case 'standup': + return standupView(snap, asOf) default: return { notImplemented: view } } diff --git a/packages/core/src/cache/cache-v0.test.ts b/packages/core/src/cache/cache-v0.test.ts new file mode 100644 index 0000000..36510b3 --- /dev/null +++ b/packages/core/src/cache/cache-v0.test.ts @@ -0,0 +1,92 @@ +import { DatabaseSync } from 'node:sqlite' + +import { describe, expect, it } from 'vitest' + +import type { GiteaIssue } from '../gitea/types.js' +import { extractLabelFacts } from '../labels/label-schema.js' +import { type CacheDriver, initCache, readIssue, upsertIssue } from './cache-v0.js' + +/** Adapt node:sqlite's DatabaseSync to the CacheDriver seam (main uses better-sqlite3). */ +function memoryDriver(): CacheDriver { + const db = new DatabaseSync(':memory:') + return { + exec: (sql) => db.exec(sql), + run: (sql, params = []) => { + db.prepare(sql).run(...(params as never[])) + }, + get: (sql, params = []) => db.prepare(sql).get(...(params as never[])) as Record | undefined, + all: (sql, params = []) => db.prepare(sql).all(...(params as never[])) as Record[], + } +} + +function issue(over: Partial = {}): GiteaIssue { + const labels = over.labels ?? ['est/5d', 'p/1', 'deadline/hard'] + return { + number: 42, + title: 'Monte Carlo engine', + body: 'percentile bands', + state: 'open', + labels, + facts: extractLabelFacts(labels), + milestone: { id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' }, + assignee: 'christian', + assignees: ['christian'], + createdAt: '2026-07-08T00:00:00Z', + updatedAt: '2026-07-08T01:00:00Z', + closedAt: null, + url: 'https://gitea/christian/commitea/issues/42', + ...over, + } +} + +describe('cache-v0', () => { + it('mirrors one issue and reads its facts back through extractLabelFacts (acceptance)', () => { + const d = memoryDriver() + initCache(d) + upsertIssue(d, issue({ labels: ['est/5d', 'p/1', 'deadline/hard'] })) + + const back = readIssue(d, 42)! + expect(back.labels).toEqual(['est/5d', 'p/1', 'deadline/hard']) + // facts are re-derived on read, not stored + expect(back.facts.estimateDays).toBe(5) + expect(back.facts.priority).toBe(1) + expect(back.facts.hardDeadline).toBe(true) + // the rest of the domain shape round-trips + expect(back.milestone).toEqual({ id: 7, title: 'P2 — Scheduler', dueOn: '2026-09-01T00:00:00Z' }) + expect(back.assignee).toBe('christian') + expect(back.state).toBe('open') + }) + + it('re-derives facts from the current labels after a re-reconcile (upsert in place, no dup)', () => { + const d = memoryDriver() + initCache(d) + upsertIssue(d, issue({ labels: ['est/2d', 'p/3'] })) + // reconcile again with changed labels + closed + upsertIssue(d, issue({ labels: ['est/8d', 'p/1'], state: 'closed', closedAt: '2026-07-09T00:00:00Z' })) + + expect(d.all('SELECT number FROM issues')).toHaveLength(1) // upsert by number, not a second row + const back = readIssue(d, 42)! + expect(back.facts.estimateDays).toBe(8) + expect(back.facts.priority).toBe(1) + expect(back.facts.hardDeadline).toBe(false) // deadline/hard dropped + expect(back.state).toBe('closed') + expect(back.closedAt).toBe('2026-07-09T00:00:00Z') + }) + + it('reads an issue with no milestone / empty labels', () => { + const d = memoryDriver() + initCache(d) + upsertIssue(d, issue({ number: 9, labels: [], milestone: null, assignee: null, assignees: [] })) + const back = readIssue(d, 9)! + expect(back.milestone).toBeNull() + expect(back.labels).toEqual([]) + expect(back.facts.estimateDays).toBeNull() + expect(back.assignee).toBeNull() + }) + + it('returns null for an uncached issue', () => { + const d = memoryDriver() + initCache(d) + expect(readIssue(d, 999)).toBeNull() + }) +}) diff --git a/packages/core/src/cache/cache-v0.ts b/packages/core/src/cache/cache-v0.ts new file mode 100644 index 0000000..df482b8 --- /dev/null +++ b/packages/core/src/cache/cache-v0.ts @@ -0,0 +1,159 @@ +/** + * SQLite cache, v0 (#3) — a rebuildable local mirror of the reconciled backlog. + * It is an index over the durable truth in gitea, never the source of truth (D4): + * delete it, resync, lose nothing. This module owns the schema + the pure + * row<->domain mappers; the actual SQLite handle is injected as a `CacheDriver`, + * so core stays free of any native driver (better-sqlite3 lives in main; tests + * use node:sqlite). Facts are never stored — they are re-derived from the label + * set on read via `extractLabelFacts`, so the mirror can't drift from the label + * semantics. + */ + +import type { GiteaIssue, GiteaMilestoneRef } from '../gitea/types.js' +import { extractLabelFacts } from '../labels/label-schema.js' + +/** + * The injected IO boundary: a thin synchronous SQL executor. Core writes the SQL; + * the host binds a real driver (better-sqlite3 in the desktop main process, + * node:sqlite in tests). Kept minimal on purpose — no ORM, no query builder. + */ +export interface CacheDriver { + /** Run one or more DDL/utility statements (no params, no result). */ + exec(sql: string): void + /** Execute a single parameterized write. */ + run(sql: string, params?: readonly unknown[]): void + /** First row of a parameterized query, or undefined. */ + get(sql: string, params?: readonly unknown[]): Record | undefined + /** All rows of a parameterized query. */ + all(sql: string, params?: readonly unknown[]): Record[] +} + +/** The cache schema — five tables mirroring gitea's shape. Regenerable; drop and rebuild freely. */ +export const CACHE_SCHEMA = ` +CREATE TABLE IF NOT EXISTS milestones ( + id INTEGER PRIMARY KEY, + title TEXT NOT NULL, + state TEXT, + due_on TEXT +); +CREATE TABLE IF NOT EXISTS issues ( + number INTEGER PRIMARY KEY, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL, + labels TEXT NOT NULL DEFAULT '[]', -- JSON array of label names; facts re-derived on read + milestone_id INTEGER, + assignee TEXT, + assignees TEXT NOT NULL DEFAULT '[]', -- JSON array of logins + created_at TEXT, + updated_at TEXT, + closed_at TEXT, + url TEXT, + FOREIGN KEY (milestone_id) REFERENCES milestones(id) +); +CREATE TABLE IF NOT EXISTS labels ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY, + issue_number INTEGER NOT NULL, + author TEXT, + body TEXT NOT NULL DEFAULT '', + created_at TEXT +); +CREATE TABLE IF NOT EXISTS issue_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + issue_number INTEGER NOT NULL, + type TEXT NOT NULL, + at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_issue_events_number ON issue_events(issue_number); +CREATE INDEX IF NOT EXISTS idx_comments_number ON comments(issue_number); +` + +/** Create the schema if absent. Idempotent. */ +export function initCache(driver: CacheDriver): void { + driver.exec(CACHE_SCHEMA) +} + +const UPSERT_ISSUE = ` +INSERT INTO issues (number, title, body, state, labels, milestone_id, assignee, assignees, created_at, updated_at, closed_at, url) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(number) DO UPDATE SET + title = excluded.title, body = excluded.body, state = excluded.state, labels = excluded.labels, + milestone_id = excluded.milestone_id, assignee = excluded.assignee, assignees = excluded.assignees, + created_at = excluded.created_at, updated_at = excluded.updated_at, closed_at = excluded.closed_at, url = excluded.url +` + +const UPSERT_MILESTONE = ` +INSERT INTO milestones (id, title, state, due_on) VALUES (?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET title = excluded.title, state = excluded.state, due_on = excluded.due_on +` + +/** + * Mirror one reconciled issue into the cache (and its milestone, if any). Upsert + * by `number`, so re-reconciling the same issue updates in place — never duplicates. + */ +export function upsertIssue(driver: CacheDriver, issue: GiteaIssue): void { + if (issue.milestone) { + driver.run(UPSERT_MILESTONE, [issue.milestone.id, issue.milestone.title, null, issue.milestone.dueOn]) + } + driver.run(UPSERT_ISSUE, [ + issue.number, + issue.title, + issue.body, + issue.state, + JSON.stringify(issue.labels), + issue.milestone?.id ?? null, + issue.assignee, + JSON.stringify(issue.assignees), + issue.createdAt, + issue.updatedAt, + issue.closedAt, + issue.url, + ]) +} + +const READ_ISSUE = ` +SELECT i.*, m.title AS m_title, m.due_on AS m_due +FROM issues i LEFT JOIN milestones m ON m.id = i.milestone_id +WHERE i.number = ? +` + +function str(v: unknown): string { + return typeof v === 'string' ? v : '' +} + +/** + * Read one mirrored issue back as a domain object, re-deriving `facts` from the + * stored label set (so the mirror can't disagree with the label semantics). + * Returns null when the issue isn't cached. + */ +export function readIssue(driver: CacheDriver, number: number): GiteaIssue | null { + const row = driver.get(READ_ISSUE, [number]) + if (!row) return null + + const labels = (JSON.parse(str(row.labels) || '[]') as string[]) ?? [] + const assignees = (JSON.parse(str(row.assignees) || '[]') as string[]) ?? [] + const milestone: GiteaMilestoneRef | null = + row.milestone_id != null + ? { id: Number(row.milestone_id), title: str(row.m_title), dueOn: (row.m_due as string | null) ?? null } + : null + + return { + number: Number(row.number), + title: str(row.title), + body: str(row.body), + state: row.state === 'closed' ? 'closed' : 'open', + labels, + facts: extractLabelFacts(labels), + milestone, + assignee: (row.assignee as string | null) ?? null, + assignees, + createdAt: str(row.created_at), + updatedAt: str(row.updated_at), + closedAt: (row.closed_at as string | null) ?? null, + url: str(row.url), + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae13216..8a2160a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,8 @@ export type { GiteaRequestInit, } from './gitea/types.js' +export { CACHE_SCHEMA, initCache, readIssue, upsertIssue } from './cache/cache-v0.js' +export type { CacheDriver } from './cache/cache-v0.js' export { affectedIssues, coalesceKey, enqueueWrite, pendingWrites, replayQueue } from './queue/write-queue-v0.js' export type { QueuedWrite } from './queue/write-queue-v0.js' export { describeChange, isLabelChange, planIssueChange, proposalsFor, summarizeChange } from './changes/apply-changes-v0.js' @@ -111,6 +113,8 @@ export { } from './agent/agent-tools.js' export { buildProjectView } from './agent/query-project.js' export type { ProjectSnapshot, ProjectView, QueryFilters } from './agent/query-project.js' +export { activeDirectives, assembleHotContext, estimateTokens, HOT_CONTEXT_BUDGET_TOKENS } from './agent/memory-v0.js' +export type { HotContextInputs } from './agent/memory-v0.js' export { CAPTURE_SYSTEM, captureWork, parseCaptureArgs, PROPOSE_ISSUES_TOOL } from './agent/capture-work.js' export type { CaptureProposal, ProposedIssue } from './agent/capture-work.js' diff --git a/packages/core/src/perf/perf.test.ts b/packages/core/src/perf/perf.test.ts new file mode 100644 index 0000000..8f3df95 --- /dev/null +++ b/packages/core/src/perf/perf.test.ts @@ -0,0 +1,75 @@ +/** + * Performance pass (#32). The deterministic compute path must stay well under the + * PLAN.md targets on representative fixtures: + * - scheduler + Monte Carlo forecast < 1s @ 200 open issues. + * - scaling stays roughly linear (no accidental O(n²) in the hot path). + * + * Reconcile-<5s@500 is network-bound (~2N gitea calls) and is covered by the live + * reconcile, not here — this file benchmarks the pure compute the app runs each + * turn. Bounds are the actual targets with comfortable headroom so timing jitter + * can't flake the suite; actuals are logged. + */ +import { describe, expect, it } from 'vitest' + +import { forecast } from '../forecast/forecast-v0.js' +import { type DependencyEdge, schedule, type SchedulableIssue } from '../scheduler/scheduler-v0.js' +import { scheduleWithCapacity, type Worker } from '../scheduler/scheduler-capacity-v0.js' + +const EST = [1, 2, 3, 5, 8] +const WORKERS: Worker[] = [ + { person: 'a', speed: 0.8 }, + { person: 'b', speed: 0.6 }, + { person: 'c', speed: 1.0 }, +] + +/** A representative open backlog: varied estimates/priorities/assignees + a light dependency web. */ +function backlog(n: number): { issues: SchedulableIssue[]; edges: DependencyEdge[] } { + const issues: SchedulableIssue[] = Array.from({ length: n }, (_, i) => ({ + number: i + 1, + title: `Issue ${i + 1} with a representative title of some length`, + labels: [`est/${EST[i % EST.length]}d`, `p/${(i % 4) + 1}`], + estimateDays: EST[i % EST.length], + priority: (i % 4) + 1, + assignee: WORKERS[i % WORKERS.length].person, + })) + // ~1 dependency per 3 issues, always on a lower-numbered issue (acyclic) + const edges: DependencyEdge[] = [] + for (let i = 3; i < n; i += 3) edges.push({ issue: i + 1, dependsOn: i - 1 }) + return { issues, edges } +} + +function ms(fn: () => void): number { + const t0 = performance.now() + fn() + return performance.now() - t0 +} + +describe('perf (#32)', () => { + it('scheduler + Monte Carlo forecast < 1s @ 200 open issues', () => { + const { issues, edges } = backlog(200) + const elapsed = ms(() => { + schedule(issues, edges) + scheduleWithCapacity(issues, edges, WORKERS) + forecast(issues, edges, { workers: WORKERS }) // 2000 trials (default) + }) + // eslint-disable-next-line no-console + console.log(`[perf] schedule+capacity+forecast @200 = ${elapsed.toFixed(1)}ms`) + expect(elapsed).toBeLessThan(1000) + }) + + it('scales roughly linearly — 400 issues is well under 4x the 100-issue time', () => { + const small = backlog(100) + const big = backlog(400) + const run = (b: typeof small) => () => { + schedule(b.issues, b.edges) + forecast(b.issues, b.edges, { workers: WORKERS }) + } + // warm up (JIT) so the ratio reflects steady state + run(small)() + const t100 = Math.max(ms(run(small)), 0.1) + const t400 = ms(run(big)) + // eslint-disable-next-line no-console + console.log(`[perf] @100 = ${t100.toFixed(1)}ms · @400 = ${t400.toFixed(1)}ms · ratio ${(t400 / t100).toFixed(1)}x`) + expect(t400).toBeLessThan(t100 * 8) // generous: rules out O(n²), tolerant of jitter + }) +})