xab 11.0.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +228 -66
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -201,7 +201,7 @@ function buildRepoContext(repoPath, config) {
201
201
  const docPaths = discoverDocPaths(repoPath);
202
202
  return { structure, instructions, docPaths };
203
203
  }
204
- function buildCommitContext(repoPath, repoCtx, config, touchedPaths, commitMessage) {
204
+ function buildCommitContext(repoPath, repoCtx, config, touchedPaths, commitMessage, memoryBlock) {
205
205
  const includedFiles = [];
206
206
  const sections = [];
207
207
  const rs = repoCtx.structure;
@@ -214,6 +214,9 @@ function buildCommitContext(repoPath, repoCtx, config, touchedPaths, commitMessa
214
214
  structLines.push(`Packages: ${rs.packages.join(", ")}`);
215
215
  sections.push(structLines.join(`
216
216
  `));
217
+ if (memoryBlock) {
218
+ sections.push(memoryBlock);
219
+ }
217
220
  for (const [name, content] of repoCtx.instructions) {
218
221
  sections.push(`--- ${name} ---
219
222
  ${content}`);
@@ -476,7 +479,7 @@ async function runStreamedWithProgress(thread, prompt, onProgress, turnOpts) {
476
479
  const lines = output.split(`
477
480
  `).filter(Boolean);
478
481
  for (const line of lines.slice(-3)) {
479
- onProgress("exec", ` ${line.slice(0, 120)}`);
482
+ onProgress("output", ` ${line.slice(0, 120)}`);
480
483
  }
481
484
  }
482
485
  }
@@ -511,8 +514,10 @@ async function runStreamedWithProgress(thread, prompt, onProgress, turnOpts) {
511
514
  if (text) {
512
515
  const lines = text.split(`
513
516
  `).filter(Boolean);
514
- for (const line of lines.slice(0, 3)) {
515
- onProgress("think", line.slice(0, 150));
517
+ if (lines[0])
518
+ onProgress("think", lines[0].slice(0, 150));
519
+ for (const line of lines.slice(1, 3)) {
520
+ onProgress("output", ` ${line.slice(0, 150)}`);
516
521
  }
517
522
  }
518
523
  break;
@@ -583,7 +588,18 @@ You are looking at a worktree based on the TARGET branch "${opts.targetBranch}".
583
588
  - New services, containers, or infrastructure to deploy? \u2192 note what
584
589
  - Config files that need manual updates on servers? \u2192 note which
585
590
  - Dependencies on external services being added or removed? \u2192 note what
586
- - If the commit is just normal code changes that only need a deploy+restart, leave opsNotes as []`;
591
+ - If the commit is just normal code changes that only need a deploy+restart, leave opsNotes as []
592
+ 8. Record any non-obvious discoveries that would help analyze FUTURE commits:
593
+ - Path mappings between source and target branches (e.g. "frontend/ in source = apps/frontend/ in target")
594
+ - Key functions, helpers, or patterns you found (e.g. "betExitValueLocal() is the shared P&L helper")
595
+ - Architectural facts (e.g. "store exports are at the bottom of fastMarkets.ts")
596
+ - Only include genuinely useful, non-obvious facts. Do NOT repeat things already in the merge memory or repo docs.
597
+ - Leave discoveries as [] if nothing new and non-obvious was found.
598
+ 9. If merge memory entries were provided in the context above, evaluate each one:
599
+ - List the KEYS of entries you want to KEEP in keepMemoryKeys
600
+ - Drop entries that are stale, obvious from repo docs, or no longer relevant
601
+ - Keep entries that saved you time or would save time on similar future commits
602
+ - If no memory was provided, return keepMemoryKeys as []`;
587
603
  let response;
588
604
  if (diffChunks.length > 1) {
589
605
  await thread.run(firstPrompt);
@@ -611,7 +627,9 @@ You now have the complete diff. Analyze and produce your structured response.`,
611
627
  reasoning: "Could not parse structured output",
612
628
  applicationStrategy: "Manual review recommended",
613
629
  affectedComponents: [],
614
- opsNotes: []
630
+ opsNotes: [],
631
+ discoveries: [],
632
+ keepMemoryKeys: []
615
633
  });
616
634
  }
617
635
  async function applyCommit(opts) {
@@ -776,9 +794,43 @@ var init_codex = __esm(() => {
776
794
  type: "array",
777
795
  items: { type: "string" },
778
796
  description: "Operator action items ONLY if this commit requires something beyond a standard code deploy+restart. Examples: new env vars to add, database migrations to run, new services to deploy, infrastructure changes, config file updates on servers. Leave as empty array [] if no operator action is needed \u2014 a normal code deploy does NOT count."
797
+ },
798
+ discoveries: {
799
+ type: "array",
800
+ items: {
801
+ type: "object",
802
+ properties: {
803
+ type: {
804
+ type: "string",
805
+ enum: ["path_mapping", "pattern", "codebase", "architecture", "convention", "warning"],
806
+ description: "Category of discovery"
807
+ },
808
+ key: {
809
+ type: "string",
810
+ description: "Short unique key for dedup (e.g. 'frontend_path_prefix', 'pnl_shared_helpers')"
811
+ },
812
+ value: { type: "string", description: "The reusable fact (1-2 sentences max)" }
813
+ },
814
+ required: ["type", "key", "value"]
815
+ },
816
+ description: "Reusable learnings that would help analyze FUTURE commits. Only include genuinely useful, non-obvious facts. Examples: path mappings between source and target ('frontend/' in source = 'apps/frontend/' in target), key shared functions ('betExitValueLocal() is the canonical P&L helper'), architectural patterns ('store exports are at the bottom of the file'), conventions ('pt-BR locale used in all user-facing text'). Leave as [] if nothing non-obvious was discovered."
817
+ },
818
+ keepMemoryKeys: {
819
+ type: "array",
820
+ items: { type: "string" },
821
+ description: "From the merge memory provided in context, list the KEYS of entries that are still useful for future commits. Entries whose keys are NOT listed here will be garbage-collected. If no merge memory was provided, return []. Be selective \u2014 only keep entries that are genuinely useful going forward, not stale or obvious facts."
779
822
  }
780
823
  },
781
- required: ["summary", "alreadyInTarget", "reasoning", "applicationStrategy", "affectedComponents", "opsNotes"],
824
+ required: [
825
+ "summary",
826
+ "alreadyInTarget",
827
+ "reasoning",
828
+ "applicationStrategy",
829
+ "affectedComponents",
830
+ "opsNotes",
831
+ "discoveries",
832
+ "keepMemoryKeys"
833
+ ],
782
834
  additionalProperties: false
783
835
  };
784
836
  applyResultSchema = {
@@ -1198,9 +1250,114 @@ function findResumableRun(baseDir, workBranch) {
1198
1250
  }
1199
1251
  var init_audit = () => {};
1200
1252
 
1253
+ // src/memory.ts
1254
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "fs";
1255
+ import { join as join4 } from "path";
1256
+
1257
+ class MergeMemory {
1258
+ entries = [];
1259
+ filePath;
1260
+ maxEntries = 50;
1261
+ constructor(repoPath) {
1262
+ this.filePath = join4(repoPath, ".backmerge", "memory.jsonl");
1263
+ this.load();
1264
+ }
1265
+ load() {
1266
+ if (!existsSync4(this.filePath))
1267
+ return;
1268
+ try {
1269
+ const raw = readFileSync4(this.filePath, "utf-8");
1270
+ this.entries = raw.split(`
1271
+ `).filter(Boolean).map((line) => {
1272
+ try {
1273
+ return JSON.parse(line);
1274
+ } catch {
1275
+ return null;
1276
+ }
1277
+ }).filter((e) => e !== null);
1278
+ } catch {}
1279
+ }
1280
+ save() {
1281
+ const dir = join4(this.filePath, "..");
1282
+ mkdirSync2(dir, { recursive: true });
1283
+ writeFileSync2(this.filePath, this.entries.map((e) => JSON.stringify(e)).join(`
1284
+ `) + `
1285
+ `);
1286
+ }
1287
+ get all() {
1288
+ return this.entries;
1289
+ }
1290
+ get count() {
1291
+ return this.entries.length;
1292
+ }
1293
+ addDiscoveries(discoveries, commitHash) {
1294
+ let added = 0;
1295
+ for (const d of discoveries) {
1296
+ const existing = this.entries.find((e) => e.key === d.key);
1297
+ if (existing) {
1298
+ if (existing.value !== d.value) {
1299
+ existing.value = d.value;
1300
+ existing.source = commitHash;
1301
+ existing.ts = new Date().toISOString();
1302
+ }
1303
+ existing.useCount++;
1304
+ } else {
1305
+ this.entries.push({
1306
+ type: d.type,
1307
+ key: d.key,
1308
+ value: d.value,
1309
+ source: commitHash,
1310
+ ts: new Date().toISOString(),
1311
+ useCount: 1
1312
+ });
1313
+ added++;
1314
+ }
1315
+ }
1316
+ if (this.entries.length > this.maxEntries) {
1317
+ this.entries = this.entries.slice(-this.maxEntries);
1318
+ }
1319
+ this.save();
1320
+ return added;
1321
+ }
1322
+ applyGC(kept) {
1323
+ const keptKeys = new Set(kept.map((k) => k.key));
1324
+ const before = this.entries.length;
1325
+ const newEntries = [];
1326
+ for (const k of kept) {
1327
+ const existing = this.entries.find((e) => e.key === k.key);
1328
+ if (existing) {
1329
+ existing.value = k.value;
1330
+ existing.useCount++;
1331
+ newEntries.push(existing);
1332
+ } else {
1333
+ newEntries.push({
1334
+ type: "pattern",
1335
+ key: k.key,
1336
+ value: k.value,
1337
+ source: "gc",
1338
+ ts: new Date().toISOString(),
1339
+ useCount: 1
1340
+ });
1341
+ }
1342
+ }
1343
+ this.entries = newEntries;
1344
+ this.save();
1345
+ return before - this.entries.length;
1346
+ }
1347
+ toPromptBlock() {
1348
+ if (this.entries.length === 0)
1349
+ return "";
1350
+ const lines = this.entries.map((e) => `[${e.type}] ${e.key}: ${e.value}`);
1351
+ return `--- Merge memory (${this.entries.length} learnings from previous commits) ---
1352
+ ${lines.join(`
1353
+ `)}`;
1354
+ }
1355
+ }
1356
+ var init_memory = () => {};
1357
+
1201
1358
  // src/git.ts
1202
1359
  import simpleGit from "simple-git";
1203
- import { join as join4 } from "path";
1360
+ import { join as join5 } from "path";
1204
1361
  import { tmpdir } from "os";
1205
1362
  function createGit(cwd) {
1206
1363
  return simpleGit(cwd);
@@ -1260,7 +1417,7 @@ async function getDescendantCommitsSince(git, since, ref) {
1260
1417
  }
1261
1418
  function generateWorktreePath(repoName) {
1262
1419
  const id = Math.random().toString(36).slice(2, 8);
1263
- return join4(tmpdir(), `backmerge-${repoName}-${id}`);
1420
+ return join5(tmpdir(), `backmerge-${repoName}-${id}`);
1264
1421
  }
1265
1422
  async function createDetachedWorktree(git, path, ref) {
1266
1423
  await git.raw(["worktree", "add", "--detach", path, ref]);
@@ -1454,6 +1611,10 @@ async function runEngine(opts, cb) {
1454
1611
  for (const l of logs)
1455
1612
  cb.onLog(` ${l}`, "gray");
1456
1613
  }
1614
+ const memory = new MergeMemory(repoPath);
1615
+ if (memory.count > 0) {
1616
+ cb.onLog(`Merge memory: ${memory.count} entries from previous commits`, "cyan");
1617
+ }
1457
1618
  if (config.promptHints && config.promptHints.length > 0) {
1458
1619
  cb.onLog(`Active hints (${config.promptHints.length}):`, "cyan");
1459
1620
  for (const h of config.promptHints) {
@@ -1547,28 +1708,8 @@ async function runEngine(opts, cb) {
1547
1708
  await createDetachedWorktree(git, wtPath, wbHead);
1548
1709
  cb.onLog(`Eval worktree (detached): ${wtPath}`, "green");
1549
1710
  const wtGit = createGit(wtPath);
1550
- const runId = `run-${ts}`;
1551
- const audit = new AuditLog(repoPath, runId);
1552
- const runMeta = {
1553
- runId,
1554
- startedAt: new Date().toISOString(),
1555
- sourceRef,
1556
- targetRef,
1557
- workBranch: wbName,
1558
- mergeBase,
1559
- worktreePath: wtPath,
1560
- totalCandidates: commitsToProcess.length,
1561
- cherrySkipped: cherrySkipped.size,
1562
- dryRun,
1563
- repoPath
1564
- };
1565
- audit.runStart(runMeta);
1566
- for (const [hash, reason] of cherrySkipped) {
1567
- const c = allSourceCommits.find((x) => x.hash === hash);
1568
- if (c)
1569
- audit.cherrySkip(hash, c.message, reason);
1570
- }
1571
1711
  let resumeFromIndex = 0;
1712
+ let resumedRunId;
1572
1713
  if (opts.resume && effectiveWorkBranch) {
1573
1714
  const resumeInfo = findResumableRun(repoPath, effectiveWorkBranch);
1574
1715
  if (resumeInfo && resumeInfo.lastCommitIndex >= 0) {
@@ -1580,7 +1721,31 @@ async function runEngine(opts, cb) {
1580
1721
  resumeFromIndex = commitsToProcess.findIndex((c) => !prevHashes.has(c.hash));
1581
1722
  if (resumeFromIndex < 0)
1582
1723
  resumeFromIndex = commitsToProcess.length;
1583
- cb.onLog(`Resuming from commit ${resumeFromIndex + 1}/${commitsToProcess.length} (${resumeInfo.decisions.length} already decided)`, "green");
1724
+ resumedRunId = resumeInfo.runId;
1725
+ cb.onLog(`Resuming run ${resumeInfo.runId} from commit ${resumeFromIndex + 1}/${commitsToProcess.length} (${resumeInfo.decisions.length} already decided)`, "green");
1726
+ }
1727
+ }
1728
+ const runId = resumedRunId ?? `run-${ts}`;
1729
+ const audit = new AuditLog(repoPath, runId);
1730
+ if (!resumedRunId) {
1731
+ const runMeta = {
1732
+ runId,
1733
+ startedAt: new Date().toISOString(),
1734
+ sourceRef,
1735
+ targetRef,
1736
+ workBranch: wbName,
1737
+ mergeBase,
1738
+ worktreePath: wtPath,
1739
+ totalCandidates: commitsToProcess.length,
1740
+ cherrySkipped: cherrySkipped.size,
1741
+ dryRun,
1742
+ repoPath
1743
+ };
1744
+ audit.runStart(runMeta);
1745
+ for (const [hash, reason] of cherrySkipped) {
1746
+ const c = allSourceCommits.find((x) => x.hash === hash);
1747
+ if (c)
1748
+ audit.cherrySkip(hash, c.message, reason);
1584
1749
  }
1585
1750
  }
1586
1751
  const sourceLatestDiff = await getLatestCommitDiff(git, resolvedSource);
@@ -1610,6 +1775,7 @@ async function runEngine(opts, cb) {
1610
1775
  maxAttempts: effectiveMaxAttempts,
1611
1776
  commitPrefix,
1612
1777
  workBranch: wbName,
1778
+ memory,
1613
1779
  workBranchHead: currentBranchHead
1614
1780
  });
1615
1781
  if (decision.kind === "applied" && decision.newCommitHash) {
@@ -1651,7 +1817,7 @@ async function processOneCommit(o) {
1651
1817
  try {
1652
1818
  touchedPaths = await getCommitFiles(o.git, commit.hash);
1653
1819
  } catch {}
1654
- const commitCtx = buildCommitContext(o.repoPath, o.repoCtx, o.config, touchedPaths, commit.message);
1820
+ const commitCtx = buildCommitContext(o.repoPath, o.repoCtx, o.config, touchedPaths, commit.message, o.memory.toPromptBlock());
1655
1821
  if (commitCtx.includedFiles.length > 0) {
1656
1822
  audit.writeRelevantDocs(commit.hash, 0, commitCtx.includedFiles.join(`
1657
1823
  `));
@@ -1679,6 +1845,19 @@ async function processOneCommit(o) {
1679
1845
  });
1680
1846
  audit.writeAnalysis(commit.hash, 1, analysis);
1681
1847
  cb.onAnalysis(commit, analysis);
1848
+ if (analysis.discoveries && analysis.discoveries.length > 0) {
1849
+ const added = o.memory.addDiscoveries(analysis.discoveries, commit.hash);
1850
+ if (added > 0) {
1851
+ cb.onLog(`Memory: +${added} new discoveries (${o.memory.count} total)`, "cyan");
1852
+ }
1853
+ }
1854
+ if (o.memory.count > 0 && analysis.keepMemoryKeys) {
1855
+ const kept = o.memory.all.filter((e) => analysis.keepMemoryKeys.includes(e.key));
1856
+ const gcCount = o.memory.applyGC(kept.map((e) => ({ key: e.key, value: e.value })));
1857
+ if (gcCount > 0) {
1858
+ cb.onLog(`Memory: GC'd ${gcCount} stale entries (${o.memory.count} remaining)`, "gray");
1859
+ }
1860
+ }
1682
1861
  } catch (e) {
1683
1862
  audit.error("analysis", commit.hash, commit.message, e.message);
1684
1863
  return mkFailed(commit, "analysis", e.message, start);
@@ -1968,6 +2147,7 @@ var init_engine = __esm(() => {
1968
2147
  init_codex();
1969
2148
  init_review();
1970
2149
  init_audit();
2150
+ init_memory();
1971
2151
  init_git();
1972
2152
  });
1973
2153
 
@@ -1977,11 +2157,11 @@ __export(exports_batch, {
1977
2157
  runBatch: () => runBatch
1978
2158
  });
1979
2159
  import chalk from "chalk";
1980
- import { readFileSync as readFileSync5 } from "fs";
1981
- import { join as join6 } from "path";
2160
+ import { readFileSync as readFileSync6 } from "fs";
2161
+ import { join as join7 } from "path";
1982
2162
  function getVersion() {
1983
2163
  try {
1984
- const pkg = JSON.parse(readFileSync5(join6(import.meta.dir, "..", "package.json"), "utf-8"));
2164
+ const pkg = JSON.parse(readFileSync6(join7(import.meta.dir, "..", "package.json"), "utf-8"));
1985
2165
  return pkg.version ?? "?";
1986
2166
  } catch {
1987
2167
  return "?";
@@ -2059,43 +2239,20 @@ async function runBatch(opts) {
2059
2239
  const subMatch = msg.match(/^\[(\w+)\]\s*(.*)/);
2060
2240
  const sub = subMatch ? subMatch[1] : phase;
2061
2241
  const text = subMatch ? subMatch[2] : msg;
2062
- let icon;
2063
2242
  switch (sub) {
2064
- case "read":
2065
- icon = chalk.cyan("\uD83D\uDCD6");
2066
- break;
2067
- case "grep":
2068
- icon = chalk.cyan("\uD83D\uDD0D");
2069
- break;
2070
- case "glob":
2071
- icon = chalk.cyan("\uD83D\uDCC2");
2072
- break;
2073
- case "exec":
2074
- icon = chalk.yellow("\u26A1");
2075
- break;
2076
2243
  case "file":
2077
- icon = chalk.green("\u270F\uFE0F");
2244
+ log(` ${ts()} \u270F\uFE0F ${chalk.green(text)}`);
2078
2245
  break;
2079
2246
  case "think":
2080
- icon = chalk.blue("\uD83D\uDCAD");
2081
- break;
2082
- case "tool":
2083
- icon = chalk.dim("\uD83D\uDD27");
2247
+ log(` ${ts()} \uD83D\uDCAD ${chalk.blue(text)}`);
2084
2248
  break;
2085
- case "analyze":
2086
- icon = chalk.blue("\u25C6");
2087
- break;
2088
- case "apply":
2089
- icon = chalk.green("\u25B8");
2090
- break;
2091
- case "review":
2092
- icon = chalk.magenta("\u25CF");
2249
+ case "read":
2250
+ log(` ${ts()} \uD83D\uDCD6 ${chalk.dim(text)}`);
2093
2251
  break;
2094
2252
  default:
2095
- icon = chalk.dim("\xB7");
2253
+ log(` ${ts()} ${chalk.dim(text)}`);
2096
2254
  break;
2097
2255
  }
2098
- log(` ${ts()} ${icon} ${chalk.dim(text)}`);
2099
2256
  },
2100
2257
  onLog(msg, color) {
2101
2258
  if (jsonl)
@@ -2138,6 +2295,11 @@ async function runBatch(opts) {
2138
2295
  if (analysis.opsNotes.length > 0) {
2139
2296
  log(` ${chalk.yellow(" ops:")} ${analysis.opsNotes.join("; ")}`);
2140
2297
  }
2298
+ if (analysis.discoveries && analysis.discoveries.length > 0) {
2299
+ for (const d of analysis.discoveries) {
2300
+ log(` ${chalk.cyan(` \uD83D\uDCA1 [${d.type}] ${d.key}:`)} ${d.value}`);
2301
+ }
2302
+ }
2141
2303
  },
2142
2304
  onDecision(commit, decision) {
2143
2305
  if (jsonl)
@@ -2282,12 +2444,12 @@ import { useState, useEffect, useCallback, useRef } from "react";
2282
2444
  import { Box, Text, useInput, useApp, Static, Newline } from "ink";
2283
2445
  import SelectInput from "ink-select-input";
2284
2446
  import Spinner from "ink-spinner";
2285
- import { readFileSync as readFileSync4 } from "fs";
2286
- import { join as join5 } from "path";
2447
+ import { readFileSync as readFileSync5 } from "fs";
2448
+ import { join as join6 } from "path";
2287
2449
  import { jsxDEV, Fragment } from "react/jsx-dev-runtime";
2288
2450
  var XAB_VERSION = (() => {
2289
2451
  try {
2290
- return JSON.parse(readFileSync4(join5(import.meta.dir, "..", "package.json"), "utf-8")).version ?? "?";
2452
+ return JSON.parse(readFileSync5(join6(import.meta.dir, "..", "package.json"), "utf-8")).version ?? "?";
2291
2453
  } catch {
2292
2454
  return "?";
2293
2455
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xab",
3
- "version": "11.0.0",
3
+ "version": "13.0.0",
4
4
  "description": "AI-powered curated branch reconciliation engine",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,6 @@
33
33
  "ink-text-input": "^6.0.0",
34
34
  "react": "18.3.1",
35
35
  "simple-git": "^3.33.0",
36
- "xab": "^5.0.0"
36
+ "xab": "12"
37
37
  }
38
38
  }