diff --git a/docs/guides/cli.md b/docs/guides/cli.md index e72ff37..36fa150 100644 --- a/docs/guides/cli.md +++ b/docs/guides/cli.md @@ -230,6 +230,7 @@ Trigger condition: - Compaction runs once the estimated context usage exceeds `contextWindow - reserveTokens`. - After compaction, glm keeps approximately `keepRecentTokens` worth of recent context. +- The focused compaction summary also pulls in the repo context pack (AGENTS command/change hints plus common package scripts) and the latest loop/handoff state when available. `glm inspect --json` prints both the resolved model context window and the effective compaction settings. @@ -258,6 +259,7 @@ Session memory: - `/memory` - `/memory note ` +- `/memory` shows the latest compaction summary, the latest loop result snapshot, and operator notes stored for the session - `/memory clear-notes` - `/memory path` diff --git a/docs/guides/cli.zh.md b/docs/guides/cli.zh.md index 3916ec7..1e6d8f1 100644 --- a/docs/guides/cli.zh.md +++ b/docs/guides/cli.zh.md @@ -230,6 +230,7 @@ Compaction 配置(写在任一 `settings.json` 中): - 当估算的上下文 token 使用量超过 `contextWindow - reserveTokens` 时,会触发 compaction。 - 压缩后会尽量保留约 `keepRecentTokens` 的近期上下文。 +- 当可用时,focused compaction summary 还会带入 repo context pack(AGENTS 中的命令/变更规则提示,以及常见 package scripts)与最近一次 loop/handoff 状态。 `glm inspect --json` 会同时输出模型的 `contextWindow` 与最终生效的 compaction 配置。 @@ -258,6 +259,7 @@ Token / 成本统计: - `/memory` - `/memory note ` +- `/memory` 会展示该 session 最近一次 compaction 摘要、最近一次 loop 结果快照,以及操作员备注 - `/memory clear-notes` - `/memory path` diff --git a/docs/references/config-surface.md b/docs/references/config-surface.md index e4be546..aea985b 100644 --- a/docs/references/config-surface.md +++ b/docs/references/config-surface.md @@ -203,6 +203,8 @@ The CLI influences runtime behavior via flags. `glm inspect --json` is the easie - Recommended operator flow is: select `provider`, optionally override `api`, then set `model`. - `custom` is the generic path for proxy, local, and unknown models. Start with the requested model name, then refine capabilities with `modelOverrides` when the default generic profile is too conservative. - Loop options are resolved in `src/app/env.ts`. +- Repo context pack assembly lives in `src/runtime/repo-context.ts`. It currently draws from `AGENTS.md` command/change sections and common `package.json` scripts, and the compaction extension reuses the same pack as focused compression input. +- Session memory persistence lives in `src/harness/session-memory.ts` and `resources/extensions/glm-memory/index.ts`. It stores compaction history, operator notes, and the latest loop result snapshot for `/memory`. - Session paths are derived in `src/session/session-paths.ts`. - Packaged prompts/extensions are synced by `src/app/resource-sync.ts`. diff --git a/docs/references/config-surface.zh.md b/docs/references/config-surface.zh.md index 4c8ce4e..d4e8c24 100644 --- a/docs/references/config-surface.zh.md +++ b/docs/references/config-surface.zh.md @@ -203,5 +203,7 @@ CLI 会通过 flags 影响 runtime 行为。排查时建议直接运行 `glm ins - 推荐的操作流程是:先选 `provider`,按需覆盖 `api`,再指定 `model`。 - `custom` 适用于代理网关、本地模型和未知模型。可以先用模型名直接试跑,再通过 `modelOverrides` 细化能力参数;默认 generic 能力是保守兜底,不代表最佳参数。 - Loop options 解析在 `src/app/env.ts`。 +- Repo context pack 的组装逻辑在 `src/runtime/repo-context.ts`。当前会读取 `AGENTS.md` 中的命令/变更规则片段,以及常见 `package.json` scripts;compaction 扩展也会复用同一份 pack 作为 focused compression 的输入。 +- Session memory 持久化逻辑在 `src/harness/session-memory.ts` 与 `resources/extensions/glm-memory/index.ts`。它会为 `/memory` 保留 compaction 历史、操作员备注和最近一次 loop 结果快照。 - Session 路径派生在 `src/session/session-paths.ts`。 - 打包的 prompts/extensions 同步逻辑在 `src/app/resource-sync.ts`。 diff --git a/resources/extensions/glm-compaction/index.ts b/resources/extensions/glm-compaction/index.ts index 1ec2133..dae2fa7 100644 --- a/resources/extensions/glm-compaction/index.ts +++ b/resources/extensions/glm-compaction/index.ts @@ -1,5 +1,6 @@ import type { CompactionResult, ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; import { compact } from "@mariozechner/pi-coding-agent"; +import { buildRepoContextPack } from "../../../src/runtime/repo-context.js"; import { appendRuntimeEvent, getRuntimeStatus } from "../shared/runtime-state.js"; const LOOP_STATE_ENTRY = "glm.loop.state"; @@ -87,7 +88,7 @@ function formatLoopResult(value: unknown): string | undefined { return parts.length ? parts.join(" | ") : undefined; } -function formatCompactionFocus(): string { +async function formatCompactionFocus(cwd?: string): Promise { const runtime = getRuntimeStatus(); const lines: string[] = [ @@ -112,6 +113,13 @@ function formatCompactionFocus(): string { } } + if (cwd) { + const repoContextPack = await buildRepoContextPack(cwd); + if (repoContextPack) { + lines.push(repoContextPack); + } + } + return lines.join("\n"); } @@ -249,7 +257,7 @@ export default function (pi: ExtensionAPI) { readLatestCustomEntry(event.branchEntries, LOOP_RESULT_ENTRY), ); - const focusLines = [formatCompactionFocus()]; + const focusLines = [await formatCompactionFocus(ctx.cwd)]; if (loopState) { focusLines.push(`Loop (persisted): ${loopState}`); } diff --git a/resources/extensions/glm-memory/index.ts b/resources/extensions/glm-memory/index.ts index 89b59ab..76d60c5 100644 --- a/resources/extensions/glm-memory/index.ts +++ b/resources/extensions/glm-memory/index.ts @@ -3,12 +3,14 @@ import { appendRuntimeEvent } from "../shared/runtime-state.js"; import { getSessionMemoryPath, readSessionMemory, + type SessionMemoryLoopResultSnapshot, upsertSessionMemoryCompaction, upsertSessionMemoryOperatorNotes, type SessionMemory, } from "../../../src/harness/session-memory.js"; const MEMORY_WIDGET_KEY = "glm.memory"; +const LOOP_RESULT_ENTRY = "glm.loop.result"; function emitMemoryMessage(pi: ExtensionAPI, lines: string[]): void { pi.sendMessage( @@ -22,6 +24,70 @@ function emitMemoryMessage(pi: ExtensionAPI, lines: string[]): void { ); } +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readLatestLoopResult( + entries: Array<{ type?: string; customType?: string; data?: unknown }>, +): SessionMemoryLoopResultSnapshot | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry?.type !== "custom" || entry.customType !== LOOP_RESULT_ENTRY || !isRecord(entry.data)) { + continue; + } + + const verification = isRecord(entry.data.verification) ? entry.data.verification : undefined; + const status = readString(entry.data.status); + const task = readString(entry.data.task); + const rounds = readNumber(entry.data.rounds); + const completedAt = readString(entry.data.completedAt); + const summary = + readString(entry.data.outcome) ?? + readString(verification?.summary) ?? + readString(entry.data.summary); + + if (!status || !task || rounds === undefined || !summary) { + continue; + } + + return { + status, + task, + rounds, + summary, + ...(completedAt ? { completedAt } : {}), + ...(verification + ? { + verification: { + kind: readString(verification.kind) ?? "unknown", + ...(readString(verification.command) + ? { command: readString(verification.command)! } + : {}), + ...(readNumber(verification.exitCode) === undefined + ? {} + : { exitCode: readNumber(verification.exitCode)! }), + summary: readString(verification.summary) ?? summary, + ...(readString(verification.artifactPath) + ? { artifactPath: readString(verification.artifactPath)! } + : {}), + }, + } + : {}), + }; + } + + return undefined; +} + function formatMemoryLines(args: { memoryPath: string; memory?: SessionMemory }): string[] { if (!args.memory) { return [ @@ -37,12 +103,17 @@ function formatMemoryLines(args: { memoryPath: string; memory?: SessionMemory }) const compactionSummary = latest ? `${latest.summary}${latest.tokensBefore ? ` (tokensBefore=${latest.tokensBefore})` : ""}` : "none"; + const latestLoop = args.memory.latestLoopResult; + const latestLoopSummary = latestLoop + ? `${latestLoop.status} | ${latestLoop.task} | ${latestLoop.verification?.summary ?? latestLoop.summary}` + : "none"; return [ `Session memory: v${args.memory.version}`, `Path: ${args.memoryPath}`, `Updated: ${args.memory.updatedAt}`, `Compactions: ${args.memory.compactions.length} | latest: ${compactionSummary}`, + `Latest loop result: ${latestLoopSummary}`, `Operator notes: ${args.memory.operatorNotes ? "set" : "none"}`, ...(args.memory.operatorNotes ? ["", args.memory.operatorNotes] : []), ]; @@ -169,6 +240,16 @@ export default function (pi: ExtensionAPI) { const sessionDir = ctx.sessionManager.getSessionDir(); const sessionId = ctx.sessionManager.getSessionId(); const sessionFile = ctx.sessionManager.getSessionFile(); + const latestLoopResult = + typeof ctx.sessionManager.getEntries === "function" + ? readLatestLoopResult( + ctx.sessionManager.getEntries() as Array<{ + type?: string; + customType?: string; + data?: unknown; + }>, + ) + : undefined; await upsertSessionMemoryCompaction({ sessionDir, @@ -180,6 +261,7 @@ export default function (pi: ExtensionAPI) { summary: event.compactionEntry.summary, tokensBefore: event.compactionEntry.tokensBefore, }, + ...(latestLoopResult ? { latestLoopResult } : {}), }); appendRuntimeEvent({ diff --git a/src/harness/session-memory.ts b/src/harness/session-memory.ts index 3d17c6a..fc315f2 100644 --- a/src/harness/session-memory.ts +++ b/src/harness/session-memory.ts @@ -8,6 +8,23 @@ export type SessionMemoryCompactionRecord = { tokensBefore: number; }; +export type SessionMemoryVerificationSnapshot = { + kind: string; + command?: string; + exitCode?: number; + summary: string; + artifactPath?: string; +}; + +export type SessionMemoryLoopResultSnapshot = { + status: string; + task: string; + rounds: number; + summary: string; + completedAt?: string; + verification?: SessionMemoryVerificationSnapshot; +}; + export type SessionMemoryV1 = { kind: "glm.sessionMemory"; version: 1; @@ -18,19 +35,93 @@ export type SessionMemoryV1 = { compactions: SessionMemoryCompactionRecord[]; }; -export type SessionMemory = SessionMemoryV1; +export type SessionMemoryV2 = Omit & { + version: 2; + latestLoopResult?: SessionMemoryLoopResultSnapshot; +}; + +export type SessionMemory = SessionMemoryV2; const SESSION_MEMORY_KIND = "glm.sessionMemory"; -const SESSION_MEMORY_VERSION = 1; +const SESSION_MEMORY_VERSION = 2; const DEFAULT_MAX_COMPACTION_HISTORY = 20; export function getSessionMemoryPath(sessionDir: string, sessionId: string): string { return join(sessionDir, "artifacts", `memory-${sessionId}.json`); } -function isSessionMemory(value: unknown): value is SessionMemory { +function isSessionMemoryCompactionRecord(value: unknown): value is SessionMemoryCompactionRecord { + if (!value || typeof value !== "object") return false; + const rec = value as Partial; + return ( + typeof rec.entryId === "string" && + !!rec.entryId.trim() && + typeof rec.at === "string" && + !!rec.at.trim() && + typeof rec.summary === "string" && + typeof rec.tokensBefore === "number" + ); +} + +function isSessionMemoryVerificationSnapshot( + value: unknown, +): value is SessionMemoryVerificationSnapshot { + if (!value || typeof value !== "object") return false; + const verification = value as Partial; + if (typeof verification.kind !== "string" || !verification.kind.trim()) return false; + if (verification.command !== undefined && typeof verification.command !== "string") return false; + if (verification.exitCode !== undefined && typeof verification.exitCode !== "number") return false; + if (typeof verification.summary !== "string") return false; + if (verification.artifactPath !== undefined && typeof verification.artifactPath !== "string") { + return false; + } + return true; +} + +function isSessionMemoryLoopResultSnapshot(value: unknown): value is SessionMemoryLoopResultSnapshot { + if (!value || typeof value !== "object") return false; + const loop = value as Partial; + if (typeof loop.status !== "string" || !loop.status.trim()) return false; + if (typeof loop.task !== "string" || !loop.task.trim()) return false; + if (typeof loop.rounds !== "number") return false; + if (typeof loop.summary !== "string") return false; + if (loop.completedAt !== undefined && typeof loop.completedAt !== "string") return false; + if ( + loop.verification !== undefined && + !isSessionMemoryVerificationSnapshot(loop.verification) + ) { + return false; + } + return true; +} + +function isSessionMemoryV1(value: unknown): value is SessionMemoryV1 { + if (!value || typeof value !== "object") return false; + const maybe = value as Partial; + if (maybe.kind !== SESSION_MEMORY_KIND) return false; + if (maybe.version !== 1) return false; + if (typeof maybe.sessionId !== "string" || !maybe.sessionId.trim()) return false; + if (typeof maybe.updatedAt !== "string" || !maybe.updatedAt.trim()) return false; + if (!Array.isArray(maybe.compactions)) return false; + + for (const record of maybe.compactions) { + if (!isSessionMemoryCompactionRecord(record)) return false; + } + + if (maybe.operatorNotes !== undefined && typeof maybe.operatorNotes !== "string") { + return false; + } + + if (maybe.sessionFile !== undefined && typeof maybe.sessionFile !== "string") { + return false; + } + + return true; +} + +function isSessionMemoryV2(value: unknown): value is SessionMemoryV2 { if (!value || typeof value !== "object") return false; - const maybe = value as Partial; + const maybe = value as Partial; if (maybe.kind !== SESSION_MEMORY_KIND) return false; if (maybe.version !== SESSION_MEMORY_VERSION) return false; if (typeof maybe.sessionId !== "string" || !maybe.sessionId.trim()) return false; @@ -38,12 +129,7 @@ function isSessionMemory(value: unknown): value is SessionMemory { if (!Array.isArray(maybe.compactions)) return false; for (const record of maybe.compactions) { - if (!record || typeof record !== "object") return false; - const rec = record as Partial; - if (typeof rec.entryId !== "string" || !rec.entryId.trim()) return false; - if (typeof rec.at !== "string" || !rec.at.trim()) return false; - if (typeof rec.summary !== "string") return false; - if (typeof rec.tokensBefore !== "number") return false; + if (!isSessionMemoryCompactionRecord(record)) return false; } if (maybe.operatorNotes !== undefined && typeof maybe.operatorNotes !== "string") { @@ -54,9 +140,31 @@ function isSessionMemory(value: unknown): value is SessionMemory { return false; } + if ( + maybe.latestLoopResult !== undefined && + !isSessionMemoryLoopResultSnapshot(maybe.latestLoopResult) + ) { + return false; + } + return true; } +function normalizeSessionMemory(value: unknown): SessionMemory | undefined { + if (isSessionMemoryV2(value)) { + return value; + } + + if (!isSessionMemoryV1(value)) { + return undefined; + } + + return { + ...value, + version: SESSION_MEMORY_VERSION, + }; +} + function createEmptyMemory(args: { sessionId: string; sessionFile?: string }): SessionMemory { return { kind: SESSION_MEMORY_KIND, @@ -81,8 +189,8 @@ export async function readSessionMemory(args: { const path = getSessionMemoryPath(args.sessionDir, args.sessionId); try { const raw = await readFile(path, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (!isSessionMemory(parsed)) { + const parsed = normalizeSessionMemory(JSON.parse(raw) as unknown); + if (!parsed) { return undefined; } if (parsed.sessionId !== args.sessionId) { @@ -144,6 +252,7 @@ export async function upsertSessionMemoryCompaction(args: { sessionId: string; sessionFile?: string; compaction: SessionMemoryCompactionRecord; + latestLoopResult?: SessionMemoryLoopResultSnapshot; maxHistory?: number; }): Promise<{ memory: SessionMemory; path: string }> { const existing = await readSessionMemory({ @@ -160,6 +269,7 @@ export async function upsertSessionMemoryCompaction(args: { ...(args.sessionFile ? { sessionFile: args.sessionFile } : {}), updatedAt: new Date().toISOString(), compactions: nextCompactions, + ...(args.latestLoopResult === undefined ? {} : { latestLoopResult: args.latestLoopResult }), }; const path = await writeSessionMemory({ diff --git a/src/runtime/repo-context.ts b/src/runtime/repo-context.ts index 85b4224..6618f3e 100644 --- a/src/runtime/repo-context.ts +++ b/src/runtime/repo-context.ts @@ -39,6 +39,21 @@ async function readTextFile(path: string): Promise { } } +async function readJsonFile(path: string): Promise { + const raw = await readTextFile(path); + if (!raw) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } +} + +type PackageJson = { + packageManager?: string; + scripts?: Record; +}; + function extractMarkdownSection(markdown: string, headingText: string): string | undefined { const lines = markdown.split(/\r?\n/); const headingRegex = /^(#{1,6})\s+(.*)$/; @@ -101,6 +116,29 @@ function keepUsefulLines(args: { text: string; maxLines: number; maxChars: numbe return out; } +function normalizePackageManager(value: string | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.split("@")[0]?.toLowerCase(); +} + +function formatScriptCommand(packageManager: string | undefined, scriptName: string): string { + if (packageManager === "pnpm") { + return scriptName === "test" ? "pnpm test" : `pnpm ${scriptName}`; + } + if (packageManager === "yarn") { + return scriptName === "test" ? "yarn test" : `yarn ${scriptName}`; + } + if (packageManager === "bun") { + return scriptName === "test" ? "bun test" : `bun run ${scriptName}`; + } + if (packageManager === "npm") { + return scriptName === "test" ? "npm test" : `npm run ${scriptName}`; + } + return `run ${scriptName}`; +} + async function buildAgentsSections(repoRoot: string): Promise { const agentsPath = join(repoRoot, "AGENTS.md"); const raw = await readTextFile(agentsPath); @@ -126,11 +164,30 @@ async function buildAgentsSections(repoRoot: string): Promise { + const packageJson = await readJsonFile(join(repoRoot, "package.json")); + const scripts = packageJson?.scripts; + if (!scripts) return []; + + const preferred = ["test", "lint", "build", "typecheck", "check", "verify"]; + const packageManager = normalizePackageManager(packageJson.packageManager); + const lines = preferred + .filter((name) => typeof scripts[name] === "string" && scripts[name]?.trim()) + .map((name) => `- ${name}: ${formatScriptCommand(packageManager, name)}`); + + if (lines.length === 0) { + return []; + } + + return [{ title: "Repo scripts (auto)", lines }]; +} + export async function buildRepoContextPack(cwd: string): Promise { const repoRoot = await findRepoRoot(cwd); const sections: RepoContextSection[] = []; sections.push(...(await buildAgentsSections(repoRoot))); + sections.push(...(await buildPackageScriptSection(repoRoot))); if (sections.length === 0) { return undefined; diff --git a/tests/extensions/compaction-extension.test.ts b/tests/extensions/compaction-extension.test.ts index ca71c9e..49b6f51 100644 --- a/tests/extensions/compaction-extension.test.ts +++ b/tests/extensions/compaction-extension.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { describe, expect, test, vi } from "vitest"; import { setRuntimeStatus } from "../../src/diagnostics/runtime-status.js"; @@ -32,8 +35,24 @@ describe("glm-compaction extension", () => { }, } as unknown as ExtensionAPI); + const repoDir = mkdtempSync(join(tmpdir(), "glm-compaction-focus-")); + mkdirSync(join(repoDir, ".git"), { recursive: true }); + writeFileSync( + join(repoDir, "AGENTS.md"), + ["# AGENTS", "", "## Command map", "", "- `pnpm test`: run tests"].join("\n"), + "utf8", + ); + writeFileSync( + join(repoDir, "package.json"), + JSON.stringify({ + packageManager: "pnpm@10.33.0", + scripts: { test: "vitest --run" }, + }), + "utf8", + ); + setRuntimeStatus({ - cwd: "/tmp/repo", + cwd: repoDir, provider: "glm", model: "glm-5", resolvedModel: { @@ -159,6 +178,7 @@ describe("glm-compaction extension", () => { signal: new AbortController().signal, }, { + cwd: repoDir, model: { id: "glm-5", provider: "glm" }, modelRegistry: { getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "key", headers: {} }), @@ -169,6 +189,8 @@ describe("glm-compaction extension", () => { expect(compactMock).toHaveBeenCalled(); const [, , , , customInstructions] = compactMock.mock.calls[0]; expect(String(customInstructions)).toContain("Runtime: provider=glm"); + expect(String(customInstructions)).toContain("Repo context pack (auto):"); + expect(String(customInstructions)).toContain("Repo scripts (auto):"); expect(String(customInstructions)).toContain("Loop (persisted): enabled=on"); expect(String(customInstructions)).toContain("Loop result (latest): status=handoff"); diff --git a/tests/extensions/memory-extension.test.ts b/tests/extensions/memory-extension.test.ts index 993c365..551b791 100644 --- a/tests/extensions/memory-extension.test.ts +++ b/tests/extensions/memory-extension.test.ts @@ -101,6 +101,26 @@ describe("glm-memory extension", () => { getSessionDir: () => sessionDir, getSessionId: () => sessionId, getSessionFile: () => sessionFile, + getEntries: () => [ + { + type: "custom", + customType: "glm.loop.result", + data: { + status: "handoff", + task: "fix flaky tests", + rounds: 2, + completedAt: "2026-04-25T00:30:00.000Z", + verification: { + kind: "fail", + command: "pnpm test", + exitCode: 1, + summary: "still failing", + artifactPath: "/tmp/repo/artifacts/verify-1.json", + }, + outcome: "Loop stopped and requires human handoff.", + }, + }, + ], }, }, ); @@ -108,5 +128,7 @@ describe("glm-memory extension", () => { const memoryPath = getSessionMemoryPath(sessionDir, sessionId); expect(existsSync(memoryPath)).toBe(true); expect(readFileSync(memoryPath, "utf8")).toContain("compacted summary"); + expect(readFileSync(memoryPath, "utf8")).toContain('"latestLoopResult"'); + expect(readFileSync(memoryPath, "utf8")).toContain('"task": "fix flaky tests"'); }); }); diff --git a/tests/harness/session-memory.test.ts b/tests/harness/session-memory.test.ts index 4099b22..e0928b3 100644 --- a/tests/harness/session-memory.test.ts +++ b/tests/harness/session-memory.test.ts @@ -7,6 +7,7 @@ import { readSessionMemory, upsertSessionMemoryCompaction, upsertSessionMemoryOperatorNotes, + writeSessionMemory, } from "../../src/harness/session-memory.js"; describe("session memory", () => { @@ -26,7 +27,7 @@ describe("session memory", () => { const memory = await readSessionMemory({ sessionDir, sessionId }); expect(memory).toMatchObject({ kind: "glm.sessionMemory", - version: 1, + version: 2, sessionId, operatorNotes: "prefer pnpm; keep commits atomic", compactions: [], @@ -79,4 +80,62 @@ describe("session memory", () => { const payload = readFileSync(getSessionMemoryPath(sessionDir, sessionId), "utf8"); expect(payload).toContain('"entryId": "c3"'); }); + + test("migrates older memory and persists the latest loop result snapshot", async () => { + const sessionDir = join(tmpdir(), `glm-session-memory-migrate-${Date.now()}`); + const sessionId = "test-session"; + mkdirSync(join(sessionDir, "artifacts"), { recursive: true }); + + await writeSessionMemory({ + sessionDir, + sessionId, + memory: { + kind: "glm.sessionMemory", + version: 1, + sessionId, + updatedAt: "2026-04-25T00:00:00.000Z", + compactions: [], + operatorNotes: "legacy notes", + } as any, + }); + + await upsertSessionMemoryCompaction({ + sessionDir, + sessionId, + compaction: { + entryId: "c1", + at: "2026-04-25T01:00:00.000Z", + summary: "compacted", + tokensBefore: 321, + }, + latestLoopResult: { + status: "handoff", + task: "fix flaky tests", + rounds: 2, + summary: "still failing", + completedAt: "2026-04-25T01:00:00.000Z", + verification: { + kind: "fail", + command: "pnpm test", + exitCode: 1, + summary: "still failing", + artifactPath: "/tmp/repo/artifacts/verify-1.json", + }, + }, + }); + + const memory = await readSessionMemory({ sessionDir, sessionId }); + expect(memory).toMatchObject({ + version: 2, + operatorNotes: "legacy notes", + latestLoopResult: { + status: "handoff", + task: "fix flaky tests", + summary: "still failing", + verification: { + command: "pnpm test", + }, + }, + }); + }); }); diff --git a/tests/runtime/repo-context.test.ts b/tests/runtime/repo-context.test.ts index d1f0617..ec6197d 100644 --- a/tests/runtime/repo-context.test.ts +++ b/tests/runtime/repo-context.test.ts @@ -31,6 +31,22 @@ describe("repo context pack", () => { ].join("\n"), "utf8", ); + writeFileSync( + join(repoRoot, "package.json"), + JSON.stringify( + { + packageManager: "pnpm@10.33.0", + scripts: { + test: "vitest --run", + build: "tsc -p tsconfig.json", + lint: "biome check .", + }, + }, + null, + 2, + ), + "utf8", + ); expect(await findRepoRoot(join(repoRoot, "subdir"))).toBe(repoRoot); @@ -40,6 +56,8 @@ describe("repo context pack", () => { expect(pack).toContain("pnpm test"); expect(pack).toContain("Change rules (from AGENTS.md):"); expect(pack).toContain("Keep changes small and atomic."); + expect(pack).toContain("Repo scripts (auto):"); + expect(pack).toContain("test: pnpm test"); expect(pack).not.toContain("Ignore this section."); }); });