@bunshin-cc/bunshin 0.4.2 → 0.4.4

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/bin.js +294 -139
  2. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -1694,6 +1694,45 @@ function sessionGate(s, opts = {}) {
1694
1694
  if (!cacheWarm) return { reflect: false, worth, reason: `${reason}; cache cold \u2192 cloud`, signals };
1695
1695
  return { reflect: true, worth, reason, signals };
1696
1696
  }
1697
+ function earlierSection(earlier, digest2) {
1698
+ const where = digest2 ? "The digest below holds only the part of the session not yet reflected on." : earlier.from ? `Consider only what happened from this point of the conversation on: ${earlier.from}. Everything before it was already reflected on.` : "Consider only the most recent part of the conversation; the rest was already reflected on.";
1699
+ const lessons = earlier.lessons.length ? earlier.lessons.map((l) => `- ${l}`).join("\n") : "- (none were worth keeping)";
1700
+ return `
1701
+ ## Already reflected on
1702
+
1703
+ This session was reflected on before. ${where}
1704
+ Lessons already taken from it. Never propose these again, nor a rewording of them:
1705
+ ${lessons}
1706
+ `;
1707
+ }
1708
+ function summarizeOp(op) {
1709
+ const clip = (s) => {
1710
+ const one = s.replace(/\s+/g, " ").trim();
1711
+ return one.length > 100 ? `${one.slice(0, 97)}\u2026` : one;
1712
+ };
1713
+ switch (op.op) {
1714
+ case "append":
1715
+ return clip(op.condition ? `${op.condition}: ${op.text}` : op.text);
1716
+ case "replace":
1717
+ return clip(`${op.find} \u2192 ${op.text}`);
1718
+ case "create":
1719
+ return clip(op.section.title);
1720
+ case "param":
1721
+ return `${op.key} = ${String(op.value)}`;
1722
+ case "rewrite":
1723
+ return clip(`rewrite (${op.text.length} chars)`);
1724
+ case "file":
1725
+ return clip(op.content === null ? `delete ${op.path}` : `file ${op.path}`);
1726
+ case "retire":
1727
+ return clip(`retire: ${op.reason}`);
1728
+ case "situation":
1729
+ return clip(`also applies to: ${op.text}`);
1730
+ }
1731
+ }
1732
+ function lessonLine(s) {
1733
+ const where = s.target.sectionId ?? (s.op.op === "create" ? "new document" : s.target.layer ?? "library");
1734
+ return `${where}: ${summarizeOp(s.op)}`;
1735
+ }
1697
1736
  function buildReflectPrompt(input) {
1698
1737
  const max = input.maxSuggestions ?? 4;
1699
1738
  const index = input.index.length ? input.index.map((s) => {
@@ -1707,7 +1746,7 @@ function buildReflectPrompt(input) {
1707
1746
 
1708
1747
  You are a reflection pass over the work in this conversation. Do not continue the task and do not
1709
1748
  call tools. Your only output is one JSON object matching the schema at the end.
1710
- ${input.transcriptDigest ? `
1749
+ ${input.earlier ? earlierSection(input.earlier, !!input.transcriptDigest) : ""}${input.transcriptDigest ? `
1711
1750
  ## Session transcript (digest)
1712
1751
 
1713
1752
  ${input.transcriptDigest}
@@ -2387,6 +2426,17 @@ var init_store2 = __esm({
2387
2426
  this.writeSession(s);
2388
2427
  return s;
2389
2428
  }
2429
+ listSessions() {
2430
+ const dir = this.p("sessions");
2431
+ if (!fileExists(dir)) return [];
2432
+ const out = [];
2433
+ for (const f of fs4.readdirSync(dir)) {
2434
+ if (!f.endsWith(".json") || f === "current.json") continue;
2435
+ const raw = readJson(path3.join(dir, f), null);
2436
+ if (raw?.sessionId) out.push(normalizeSession(raw.sessionId, raw));
2437
+ }
2438
+ return out;
2439
+ }
2390
2440
  readCurrent() {
2391
2441
  return readJson(this.p("sessions", "current.json"), null);
2392
2442
  }
@@ -2819,7 +2869,7 @@ function captureNewSkills(c, state, roots, owned, incoming, out, layer, only) {
2819
2869
  } catch {
2820
2870
  continue;
2821
2871
  }
2822
- if (!(born >= since) && !only) continue;
2872
+ if (!(born >= since - BORN_SLACK_MS) && !only) continue;
2823
2873
  const files = {};
2824
2874
  for (const rel of listFiles(dir)) {
2825
2875
  if (rel === "SKILL.md" || rel === MANIFEST) continue;
@@ -3090,7 +3140,7 @@ function materialize(sections, dir) {
3090
3140
  }
3091
3141
  return { files };
3092
3142
  }
3093
- var settingsFile, sha, BLOCK_BEGIN, BLOCK_END, MANIFEST, MAX_FILE, isExecutable, LAYER_RANK, docMarker;
3143
+ var settingsFile, sha, BLOCK_BEGIN, BLOCK_END, MANIFEST, MAX_FILE, isExecutable, LAYER_RANK, docMarker, BORN_SLACK_MS;
3094
3144
  var init_deploy = __esm({
3095
3145
  "src/v2/deploy.ts"() {
3096
3146
  "use strict";
@@ -3112,6 +3162,7 @@ var init_deploy = __esm({
3112
3162
  };
3113
3163
  LAYER_RANK = { org: 0, dept: 1, team: 2, user: 3, fleet: 0, arm: 1, mount: 2, robot: 3 };
3114
3164
  docMarker = (id, version2) => `<!-- bunshin:doc id=${id} v=${version2} -->`;
3165
+ BORN_SLACK_MS = 2e3;
3115
3166
  }
3116
3167
  });
3117
3168
 
@@ -3592,28 +3643,7 @@ function appendedText(op) {
3592
3643
  return op.condition ? `- **${op.condition}:** ${op.text.trim()}` : op.text.trim();
3593
3644
  }
3594
3645
  function summarize(op) {
3595
- const clip = (s) => {
3596
- const one = s.replace(/\s+/g, " ").trim();
3597
- return one.length > 100 ? `${one.slice(0, 97)}\u2026` : one;
3598
- };
3599
- switch (op.op) {
3600
- case "append":
3601
- return clip(op.condition ? `${op.condition}: ${op.text}` : op.text);
3602
- case "replace":
3603
- return clip(`${op.find} \u2192 ${op.text}`);
3604
- case "create":
3605
- return clip(op.section.title);
3606
- case "param":
3607
- return `${op.key} = ${String(op.value)}`;
3608
- case "rewrite":
3609
- return clip(`rewrite (${op.text.length} chars)`);
3610
- case "file":
3611
- return clip(op.content === null ? `delete ${op.path}` : `file ${op.path}`);
3612
- case "retire":
3613
- return clip(`retire: ${op.reason}`);
3614
- case "situation":
3615
- return clip(`also applies to: ${op.text}`);
3616
- }
3646
+ return summarizeOp(op);
3617
3647
  }
3618
3648
  function describeTarget(s, view) {
3619
3649
  if (s.op.op === "param") return view.ctx.kind === "robot" ? "params.json" : "private note";
@@ -3779,6 +3809,112 @@ var init_apply = __esm({
3779
3809
  }
3780
3810
  });
3781
3811
 
3812
+ // src/v2/signals.ts
3813
+ import fs9 from "node:fs";
3814
+ function isCorrection(text2) {
3815
+ const t = text2.trim().slice(0, 600);
3816
+ return !!t && CORRECTION_RE.test(t);
3817
+ }
3818
+ function codeOf(input) {
3819
+ let v = input;
3820
+ if (typeof v === "string") {
3821
+ try {
3822
+ v = JSON.parse(v);
3823
+ } catch {
3824
+ return null;
3825
+ }
3826
+ }
3827
+ if (v && typeof v === "object" && typeof v.code === "string") return v.code;
3828
+ return null;
3829
+ }
3830
+ function scanTranscript(file) {
3831
+ const empty = { facts: { turns: [], userMessages: 0, toolCalls: 0, toolFailures: 0 }, snippets: [] };
3832
+ if (!file) return empty;
3833
+ let raw;
3834
+ try {
3835
+ raw = fs9.readFileSync(file, "utf8");
3836
+ } catch {
3837
+ return empty;
3838
+ }
3839
+ const out = { facts: parseTranscript(file), snippets: [] };
3840
+ for (const text2 of raw.split("\n")) {
3841
+ if (!text2.trim()) continue;
3842
+ let line;
3843
+ try {
3844
+ line = JSON.parse(text2);
3845
+ } catch {
3846
+ continue;
3847
+ }
3848
+ const at = typeof line.timestamp === "string" && !Number.isNaN(Date.parse(line.timestamp)) ? line.timestamp : void 0;
3849
+ if (at && !out.firstAt) out.firstAt = at;
3850
+ const isModel = line.type === "assistant" || line.type === "response_item" && (line.payload?.type === "message" || line.payload?.type === "function_call");
3851
+ if (at && isModel) out.lastModelAt = at;
3852
+ if (line.type === "assistant" && Array.isArray(line.message?.content)) {
3853
+ for (const b of line.message.content) {
3854
+ if (b?.type === "tool_use") {
3855
+ const code2 = codeOf(b.input);
3856
+ if (code2) out.snippets.push(code2);
3857
+ }
3858
+ }
3859
+ } else if (line.type === "response_item" && (line.payload?.type === "function_call" || line.payload?.type === "custom_tool_call")) {
3860
+ const code2 = codeOf(line.payload.input ?? line.payload.arguments);
3861
+ if (code2) out.snippets.push(code2);
3862
+ }
3863
+ }
3864
+ return out;
3865
+ }
3866
+ function sinceTurn(scan, from) {
3867
+ if (from <= 0) return scan;
3868
+ const turns = scan.facts.turns.slice(from);
3869
+ return {
3870
+ ...scan,
3871
+ facts: {
3872
+ turns,
3873
+ userMessages: turns.filter((t) => t.role === "user").length,
3874
+ toolCalls: turns.filter((t) => t.role === "tool" && t.tool !== "result").length,
3875
+ toolFailures: turns.filter((t) => t.role === "tool" && t.tool === "result" && t.isError).length
3876
+ }
3877
+ };
3878
+ }
3879
+ function reflectCursor(session, turns) {
3880
+ const at = Math.max(session.reflectedTurns ?? 0, session.serverReflectedTurns ?? 0);
3881
+ return at > turns ? 0 : at;
3882
+ }
3883
+ function sessionSignals({ scan, startedAt, lastActivityAt, partial }) {
3884
+ const users = scan.facts.turns.filter((t) => t.role === "user");
3885
+ const corrections = users.slice(partial ? 0 : 1).filter((t) => isCorrection(t.text)).length;
3886
+ const last = [scan.lastModelAt, lastActivityAt].filter((x) => !!x).sort().at(-1) ?? lastActivityAt;
3887
+ const first = [scan.firstAt, startedAt].filter((x) => !!x).sort()[0] ?? startedAt;
3888
+ const minutes = Math.max(0, (Date.parse(last) - Date.parse(first)) / 6e4);
3889
+ return {
3890
+ toolCalls: scan.facts.toolCalls,
3891
+ errors: scan.facts.toolFailures,
3892
+ corrections,
3893
+ userTurns: scan.facts.userMessages,
3894
+ minutes: Number.isFinite(minutes) ? Math.round(minutes * 10) / 10 : 0,
3895
+ lastActivityAt: last
3896
+ };
3897
+ }
3898
+ function errorStreakOvercome(facts, cursor = 0) {
3899
+ const results = facts.turns.filter((t) => t.role === "tool" && t.tool === "result" && !t.pending);
3900
+ const fresh = results.slice(Math.min(cursor, results.length));
3901
+ let failures = 0;
3902
+ let nudge = false;
3903
+ for (const r of fresh) {
3904
+ if (r.isError) failures++;
3905
+ else if (failures >= 3) nudge = true;
3906
+ }
3907
+ return { nudge, cursor: nudge ? results.length : Math.min(cursor, results.length) };
3908
+ }
3909
+ var CORRECTION_RE;
3910
+ var init_signals = __esm({
3911
+ "src/v2/signals.ts"() {
3912
+ "use strict";
3913
+ init_transcript();
3914
+ CORRECTION_RE = /^(?:no\b|nope\b|wrong\b|stop\b|wait\b|not quite\b)|\b(?:that'?s (?:not|wrong|incorrect)|that is (?:not|wrong)|not what i|i (?:said|meant|asked for)|you (?:should(?:n'?t)?|need to|forgot|missed|broke)|(?:please )?don'?t|do not|instead of|use \S+ instead|actually,|revert (?:that|this|it)|undo (?:that|this|it)|we (?:always|never|don'?t|do not))\b/i;
3915
+ }
3916
+ });
3917
+
3782
3918
  // src/v2/sync.ts
3783
3919
  var sync_exports = {};
3784
3920
  __export(sync_exports, {
@@ -3787,11 +3923,11 @@ __export(sync_exports, {
3787
3923
  syncV2: () => syncV2,
3788
3924
  uploadIndex: () => uploadIndex
3789
3925
  });
3790
- import fs9 from "node:fs";
3926
+ import fs10 from "node:fs";
3791
3927
  import path8 from "node:path";
3792
3928
  function episodePayload(e) {
3793
- const { orgId: _o, uploaded: _u, servedReported: _s, ...rest } = e;
3794
- void _o, _u, _s;
3929
+ const { orgId: _o, uploaded: _u, servedReported: _s, reportedServed: _rs, reportedLoaded: _rl, ...rest } = e;
3930
+ void _o, _u, _s, _rs, _rl;
3795
3931
  return rest;
3796
3932
  }
3797
3933
  function suggestionPayload(s) {
@@ -3828,18 +3964,27 @@ async function syncV2(store, opts = {}) {
3828
3964
  for (const e of v2.listEpisodes().filter((x) => !x.uploaded)) {
3829
3965
  if (Date.now() >= deadline) break;
3830
3966
  try {
3831
- await call("POST", "/episodes", { episode: episodePayload(e) });
3967
+ const res = await call("POST", "/episodes", { episode: episodePayload(e) });
3968
+ if (res && (typeof res.reflectedTurns === "number" || Array.isArray(res.lessons)) && v2.readSession(e.sessionId)) {
3969
+ v2.updateSession(e.sessionId, "", (s) => {
3970
+ if (typeof res.reflectedTurns === "number") s.serverReflectedTurns = Math.max(s.serverReflectedTurns ?? 0, res.reflectedTurns);
3971
+ if (Array.isArray(res.lessons)) s.serverLessons = res.lessons.filter((l) => typeof l === "string").slice(0, 40);
3972
+ });
3973
+ }
3832
3974
  const fresh = v2.readEpisode(e.id) ?? e;
3833
3975
  const next = { ...fresh, uploaded: JSON.stringify(episodePayload(fresh)) === JSON.stringify(episodePayload(e)) ? true : fresh.uploaded };
3834
- if (!fresh.servedReported) {
3835
- const served = e.sectionsServed.filter((id) => !isLocalId(id));
3836
- const loaded = e.sectionsLoaded.filter((id) => !isLocalId(id));
3837
- try {
3838
- if (served.length) await call("POST", "/recall/served", { sectionIds: served });
3839
- if (loaded.length) await call("POST", "/recall/served", { sectionIds: loaded, loaded: true });
3840
- next.servedReported = true;
3841
- } catch {
3842
- }
3976
+ const legacy = fresh.servedReported && !fresh.reportedServed;
3977
+ const doneServed = new Set(legacy ? e.sectionsServed : fresh.reportedServed ?? []);
3978
+ const doneLoaded = new Set(legacy ? e.sectionsLoaded : fresh.reportedLoaded ?? []);
3979
+ const served = e.sectionsServed.filter((id) => !isLocalId(id) && !doneServed.has(id));
3980
+ const loaded = e.sectionsLoaded.filter((id) => !isLocalId(id) && !doneLoaded.has(id));
3981
+ try {
3982
+ if (served.length) await call("POST", "/recall/served", { sectionIds: served });
3983
+ if (loaded.length) await call("POST", "/recall/served", { sectionIds: loaded, loaded: true });
3984
+ next.reportedServed = [...doneServed, ...served];
3985
+ next.reportedLoaded = [...doneLoaded, ...loaded];
3986
+ delete next.servedReported;
3987
+ } catch {
3843
3988
  }
3844
3989
  v2.writeEpisode(next);
3845
3990
  report.uploadedEpisodes++;
@@ -3934,7 +4079,7 @@ async function uploadIndex(store, cwd, deadline = Date.now() + 3e4) {
3934
4079
  const { root, index } = indexWorkingTree(v2, config, cwd, { force: true });
3935
4080
  const docs = (index.paths ?? Object.keys(index.files)).flatMap((rel) => {
3936
4081
  try {
3937
- return [{ path: rel, text: fs9.readFileSync(path8.join(root, rel), "utf8") }];
4082
+ return [{ path: rel, text: fs10.readFileSync(path8.join(root, rel), "utf8") }];
3938
4083
  } catch {
3939
4084
  return [];
3940
4085
  }
@@ -3960,7 +4105,7 @@ var init_sync = __esm({
3960
4105
  // src/v2/reflect.ts
3961
4106
  import { spawn as spawn3 } from "node:child_process";
3962
4107
  import { createHash as createHash2 } from "node:crypto";
3963
- import fs10 from "node:fs";
4108
+ import fs11 from "node:fs";
3964
4109
  function episodeIdFor(contextId, sessionId) {
3965
4110
  return `e_${createHash2("sha256").update(`${contextId}
3966
4111
  ${sessionId}`).digest("hex").slice(0, 16)}`;
@@ -3980,7 +4125,7 @@ function runClaudeFork(sessionId, prompt, { cwd, timeoutMs = FORK_TIMEOUT_MS } =
3980
4125
  };
3981
4126
  let child;
3982
4127
  try {
3983
- child = spawn3(bin, args, { cwd: cwd && fs10.existsSync(cwd) ? cwd : void 0, env: childEnv(), stdio: ["ignore", "pipe", "pipe"] });
4128
+ child = spawn3(bin, args, { cwd: cwd && fs11.existsSync(cwd) ? cwd : void 0, env: childEnv(), stdio: ["ignore", "pipe", "pipe"] });
3984
4129
  } catch (e) {
3985
4130
  resolve({ ok: false, error: e instanceof Error ? e.message : String(e) });
3986
4131
  return;
@@ -4025,13 +4170,28 @@ function reflectionIndex(view, ids, task, focus, humanTurns = []) {
4025
4170
  return [...chosen.values()].slice(0, 30).map(({ id, kind, layer, title, when, outline }) => ({ id, kind, layer, title, when, ...outline?.length ? { outline } : {} }));
4026
4171
  }
4027
4172
  function humanTurnsOf(transcriptPath) {
4028
- if (!transcriptPath || !fs10.existsSync(transcriptPath)) return [];
4173
+ if (!transcriptPath || !fs11.existsSync(transcriptPath)) return [];
4029
4174
  try {
4030
4175
  return parseTranscript(transcriptPath).turns.filter((t) => t.role === "user" && t.text.trim()).map((t) => t.text.slice(0, 1e3));
4031
4176
  } catch {
4032
4177
  return [];
4033
4178
  }
4034
4179
  }
4180
+ function readFacts(transcriptPath) {
4181
+ if (!transcriptPath || !fs11.existsSync(transcriptPath)) return null;
4182
+ try {
4183
+ return parseTranscript(transcriptPath);
4184
+ } catch {
4185
+ return null;
4186
+ }
4187
+ }
4188
+ function anchorAt(facts, from) {
4189
+ const quote = (t) => `"${t.replace(/\s+/g, " ").trim().slice(0, 160)}"`;
4190
+ const user = facts.turns.slice(from).find((t) => t.role === "user" && t.text.trim());
4191
+ if (user) return `the user message that begins ${quote(user.text)}`;
4192
+ const reply = facts.turns.slice(0, from).reverse().find((t) => t.role === "assistant" && t.text.trim());
4193
+ return reply ? `just after your reply that begins ${quote(reply.text)}` : void 0;
4194
+ }
4035
4195
  async function runReflectionV2(store, opts) {
4036
4196
  const v2 = new V2Store(store);
4037
4197
  const config = store.config();
@@ -4052,7 +4212,12 @@ async function runReflectionV2(store, opts) {
4052
4212
  const captured = [.../* @__PURE__ */ new Set([...session.captured, ...prior.map((s) => s.target.sectionId ?? localSectionId(s.id))])];
4053
4213
  const index = reflectionIndex(view, [...session.loaded, ...session.pushed, ...session.served], session.task, opts.focus, humanTurnsOf(session.transcriptPath));
4054
4214
  const harness = session.harness ?? config.harness;
4055
- const promptBase = { contextKind: view.ctx.kind, layers: view.layers, index, captured, ...opts.focus ? { focus: opts.focus } : {} };
4215
+ const facts = readFacts(session.transcriptPath);
4216
+ const turns = facts?.turns.length ?? 0;
4217
+ const from = reflectCursor(session, turns);
4218
+ const lessons = [.../* @__PURE__ */ new Set([...prior.map(lessonLine), ...session.serverLessons ?? []])].slice(0, 40);
4219
+ const earlier = from > 0 || lessons.length ? { ...from > 0 && facts ? { from: anchorAt(facts, from) } : {}, lessons } : void 0;
4220
+ const promptBase = { contextKind: view.ctx.kind, layers: view.layers, index, captured, ...opts.focus ? { focus: opts.focus } : {}, ...earlier ? { earlier } : {} };
4056
4221
  let raw = null;
4057
4222
  if (harness === "claude-code") {
4058
4223
  const fork = await runClaudeFork(opts.sessionId, buildReflectPrompt(promptBase), { cwd: session.cwd });
@@ -4070,8 +4235,8 @@ async function runReflectionV2(store, opts) {
4070
4235
  result.method = "fork";
4071
4236
  } else result.error = fork.error;
4072
4237
  }
4073
- if (result.method === "none" && session.transcriptPath && fs10.existsSync(session.transcriptPath)) {
4074
- const transcriptDigest = digest(parseTranscript(session.transcriptPath), { maxChars: 4e4 });
4238
+ if (result.method === "none" && session.transcriptPath && fs11.existsSync(session.transcriptPath)) {
4239
+ const transcriptDigest = digest(facts ?? parseTranscript(session.transcriptPath), { from, maxChars: 4e4 });
4075
4240
  const res = await askJson(config, {
4076
4241
  system: "You are Bunshin's reflection pass. Follow the instructions in the user message and reply with one JSON object only.",
4077
4242
  user: buildReflectPrompt({ ...promptBase, transcriptDigest }),
@@ -4107,7 +4272,10 @@ async function runReflectionV2(store, opts) {
4107
4272
  const ids = result.suggestions.map((s) => s.id);
4108
4273
  v2.updateSession(opts.sessionId, session.cwd, (s) => {
4109
4274
  addUnique(s.suggestionIds, ...ids);
4110
- if (result.method !== "none") s.reflectedAt = createdAt;
4275
+ if (result.method !== "none") {
4276
+ s.reflectedAt = createdAt;
4277
+ s.reflectedTurns = Math.max(s.reflectedTurns ?? 0, turns);
4278
+ }
4111
4279
  if (origin === "learn") {
4112
4280
  for (const sug of result.suggestions) if (sug.status !== "proposed") addUnique(s.captured, sug.target.sectionId ?? localSectionId(sug.id));
4113
4281
  }
@@ -4120,6 +4288,7 @@ async function runReflectionV2(store, opts) {
4120
4288
  suggestionIds: [.../* @__PURE__ */ new Set([...episode.suggestionIds, ...ids])],
4121
4289
  captured: fresh?.captured ?? episode.captured,
4122
4290
  reflected: result.method !== "none" ? "edge" : episode.reflected === "edge" ? "none" : episode.reflected,
4291
+ ...result.method !== "none" ? { reflectedTurns: Math.max(episode.reflectedTurns ?? 0, turns), turns: Math.max(episode.turns ?? 0, turns) } : {},
4123
4292
  uploaded: false
4124
4293
  });
4125
4294
  }
@@ -4156,6 +4325,7 @@ var init_reflect2 = __esm({
4156
4325
  init_api();
4157
4326
  init_apply();
4158
4327
  init_store2();
4328
+ init_signals();
4159
4329
  init_view();
4160
4330
  FORK_TIMEOUT_MS = 12e4;
4161
4331
  }
@@ -7941,95 +8111,15 @@ function refreshInBackground(v2, store, detach2, cwd, now = Date.now()) {
7941
8111
  // src/v2/hooks.ts
7942
8112
  init_reflect2();
7943
8113
  init_repo();
7944
-
7945
- // src/v2/signals.ts
7946
- init_transcript();
7947
- import fs11 from "node:fs";
7948
- var CORRECTION_RE = /^(?:no\b|nope\b|wrong\b|stop\b|wait\b|not quite\b)|\b(?:that'?s (?:not|wrong|incorrect)|that is (?:not|wrong)|not what i|i (?:said|meant|asked for)|you (?:should(?:n'?t)?|need to|forgot|missed|broke)|(?:please )?don'?t|do not|instead of|use \S+ instead|actually,|revert (?:that|this|it)|undo (?:that|this|it)|we (?:always|never|don'?t|do not))\b/i;
7949
- function isCorrection(text2) {
7950
- const t = text2.trim().slice(0, 600);
7951
- return !!t && CORRECTION_RE.test(t);
7952
- }
7953
- function codeOf(input) {
7954
- let v = input;
7955
- if (typeof v === "string") {
7956
- try {
7957
- v = JSON.parse(v);
7958
- } catch {
7959
- return null;
7960
- }
7961
- }
7962
- if (v && typeof v === "object" && typeof v.code === "string") return v.code;
7963
- return null;
7964
- }
7965
- function scanTranscript(file) {
7966
- const empty = { facts: { turns: [], userMessages: 0, toolCalls: 0, toolFailures: 0 }, snippets: [] };
7967
- if (!file) return empty;
7968
- let raw;
7969
- try {
7970
- raw = fs11.readFileSync(file, "utf8");
7971
- } catch {
7972
- return empty;
7973
- }
7974
- const out = { facts: parseTranscript(file), snippets: [] };
7975
- for (const text2 of raw.split("\n")) {
7976
- if (!text2.trim()) continue;
7977
- let line;
7978
- try {
7979
- line = JSON.parse(text2);
7980
- } catch {
7981
- continue;
7982
- }
7983
- const at = typeof line.timestamp === "string" && !Number.isNaN(Date.parse(line.timestamp)) ? line.timestamp : void 0;
7984
- if (at && !out.firstAt) out.firstAt = at;
7985
- const isModel = line.type === "assistant" || line.type === "response_item" && (line.payload?.type === "message" || line.payload?.type === "function_call");
7986
- if (at && isModel) out.lastModelAt = at;
7987
- if (line.type === "assistant" && Array.isArray(line.message?.content)) {
7988
- for (const b of line.message.content) {
7989
- if (b?.type === "tool_use") {
7990
- const code2 = codeOf(b.input);
7991
- if (code2) out.snippets.push(code2);
7992
- }
7993
- }
7994
- } else if (line.type === "response_item" && (line.payload?.type === "function_call" || line.payload?.type === "custom_tool_call")) {
7995
- const code2 = codeOf(line.payload.input ?? line.payload.arguments);
7996
- if (code2) out.snippets.push(code2);
7997
- }
7998
- }
7999
- return out;
8000
- }
8001
- function sessionSignals({ scan, startedAt, lastActivityAt }) {
8002
- const users = scan.facts.turns.filter((t) => t.role === "user");
8003
- const corrections = users.slice(1).filter((t) => isCorrection(t.text)).length;
8004
- const last = [scan.lastModelAt, lastActivityAt].filter((x) => !!x).sort().at(-1) ?? lastActivityAt;
8005
- const first = [scan.firstAt, startedAt].filter((x) => !!x).sort()[0] ?? startedAt;
8006
- const minutes = Math.max(0, (Date.parse(last) - Date.parse(first)) / 6e4);
8007
- return {
8008
- toolCalls: scan.facts.toolCalls,
8009
- errors: scan.facts.toolFailures,
8010
- corrections,
8011
- userTurns: scan.facts.userMessages,
8012
- minutes: Number.isFinite(minutes) ? Math.round(minutes * 10) / 10 : 0,
8013
- lastActivityAt: last
8014
- };
8015
- }
8016
- function errorStreakOvercome(facts, cursor = 0) {
8017
- const results = facts.turns.filter((t) => t.role === "tool" && t.tool === "result" && !t.pending);
8018
- const fresh = results.slice(Math.min(cursor, results.length));
8019
- let failures = 0;
8020
- let nudge = false;
8021
- for (const r of fresh) {
8022
- if (r.isError) failures++;
8023
- else if (failures >= 3) nudge = true;
8024
- }
8025
- return { nudge, cursor: nudge ? results.length : Math.min(cursor, results.length) };
8026
- }
8027
-
8028
- // src/v2/hooks.ts
8114
+ init_signals();
8029
8115
  init_store2();
8030
8116
  init_view();
8031
8117
  var NUDGE_TEXT = "This looks worth capturing. Consider /learn.";
8032
8118
  var NUDGE_INTERVAL_MS = 60 * 6e4;
8119
+ var CHECKPOINT_MS = 30 * 6e4;
8120
+ var LEFTOVER_IDLE_MS = 60 * 6e4;
8121
+ var LEFTOVER_MAX_AGE_MS = 14 * 24 * 60 * 6e4;
8122
+ var LEFTOVERS_PER_START = 5;
8033
8123
  var MAX_PUSH = 3;
8034
8124
  var additional = (hookEventName, text2) => text2.trim() ? { hookSpecificOutput: { hookEventName, additionalContext: text2.trim() } } : {};
8035
8125
  function touch(v2, ctx, patch = () => {
@@ -8072,6 +8162,11 @@ async function onSessionStart(ctx) {
8072
8162
  v2.writeCurrent({ sessionId: id, cwd: ctx.cwd, at: now });
8073
8163
  const config = ctx.store.config();
8074
8164
  if (canCall(config)) {
8165
+ try {
8166
+ const swept = checkpointLeftovers(ctx.store, id);
8167
+ if (swept.length) v2.log("v2-leftovers", { session: id, episodes: swept });
8168
+ } catch {
8169
+ }
8075
8170
  const budget = Math.min(3e3, ctx.deadline - Date.now() - 400);
8076
8171
  if (budget > 100) {
8077
8172
  try {
@@ -8254,6 +8349,16 @@ function onStop(ctx) {
8254
8349
  const config = ctx.store.config();
8255
8350
  if (ctx.input.stop_hook_active) return {};
8256
8351
  const scan = scanTranscript(ctx.input.transcript_path ?? session.transcriptPath);
8352
+ if (canCall(config) && checkpointDue(session, scan, now)) {
8353
+ try {
8354
+ const { episode } = buildEpisode(ctx.store, session, void 0, now, { scan });
8355
+ v2.writeEpisode(episode);
8356
+ markCheckpoint(v2, session, scan, now);
8357
+ ctx.detach(["--root", ctx.store.root, "sync", "--quiet"], { cwd: ctx.cwd });
8358
+ v2.log("v2-checkpoint", { session: session.sessionId, episode: episode.id });
8359
+ } catch {
8360
+ }
8361
+ }
8257
8362
  const check = learningCheck(v2, session, scan.facts.toolCalls, now);
8258
8363
  if (check) return check;
8259
8364
  if (!config.reflect.nudge) return {};
@@ -8285,13 +8390,16 @@ function learningCheck(v2, session, toolCalls, now) {
8285
8390
  const reason = doc.body.replace(/^\s*<!--\s*bunshin:[\s\S]*?-->\s*/, "").trim().replace(/\{delta\}/g, String(toolCalls - last));
8286
8391
  return { decision: "block", reason };
8287
8392
  }
8288
- function buildEpisode(store, session, transcriptPath, now = /* @__PURE__ */ new Date()) {
8393
+ function buildEpisode(store, session, transcriptPath, now = /* @__PURE__ */ new Date(), opts = {}) {
8289
8394
  const v2 = new V2Store(store);
8290
8395
  const config = store.config();
8291
8396
  const view = localView(v2, config, session.cwd, { index: false });
8292
- const scan = scanTranscript(transcriptPath);
8397
+ const scan = opts.scan ?? scanTranscript(transcriptPath ?? session.transcriptPath);
8293
8398
  const outcome = v2.readOutcome(session.sessionId);
8294
- const signals = { ...sessionSignals({ scan, startedAt: session.startedAt, lastActivityAt: session.lastActivityAt }), ...outcome ? { outcome: outcome.status } : {} };
8399
+ const turns = scan.facts.turns.length;
8400
+ const from = reflectCursor(session, turns);
8401
+ const fresh = sinceTurn(scan, from);
8402
+ const signals = { ...sessionSignals({ scan: fresh, startedAt: session.startedAt, lastActivityAt: session.lastActivityAt, partial: from > 0 }), ...outcome ? { outcome: outcome.status } : {} };
8295
8403
  const gate = sessionGate(signals, { now: now.toISOString(), cacheTtlMs: config.reflect.cacheTtlMs });
8296
8404
  const id = episodeIdFor(view.ctx.id, session.sessionId);
8297
8405
  const previous = v2.readEpisode(id);
@@ -8306,23 +8414,69 @@ function buildEpisode(store, session, transcriptPath, now = /* @__PURE__ */ new
8306
8414
  harness: ["claude-code", "codex", "pi", "other"].includes(session.harness) ? session.harness : config.harness,
8307
8415
  task: session.task,
8308
8416
  startedAt: session.startedAt,
8309
- endedAt: now.toISOString(),
8417
+ endedAt: opts.endedAt ?? now.toISOString(),
8310
8418
  sectionsServed: session.served,
8311
8419
  sectionsLoaded: session.loaded,
8312
8420
  captured: session.captured,
8313
8421
  suggestionIds: [.../* @__PURE__ */ new Set([...previous?.suggestionIds ?? [], ...session.suggestionIds])],
8314
- reflected: previous?.reflected === "edge" ? "edge" : "none",
8422
+ // "edge" only while this machine's reflections cover every turn; new work is open again.
8423
+ reflected: previous?.reflected === "edge" && from >= turns ? "edge" : "none",
8315
8424
  gate,
8316
8425
  upload,
8317
- ...upload !== "header-only" && scan.facts.turns.length ? { transcript: digest(scan.facts, { maxChars: upload === "full" ? 2e5 : 6e4 }) } : {},
8426
+ turns,
8427
+ reflectedTurns: from,
8428
+ ...upload !== "header-only" && turns > from ? { transcript: digest(scan.facts, { from, maxChars: upload === "full" ? 2e5 : 6e4 }), transcriptFrom: from } : {},
8318
8429
  ...calls.length ? { calls } : {},
8319
8430
  ...snippets.length ? { snippets } : {},
8320
8431
  ...outcome ? { outcome } : {},
8321
8432
  ...view.bundle?.release?.version ? { harnessVersion: view.bundle.release.version } : {},
8322
- uploaded: false
8433
+ uploaded: false,
8434
+ ...previous?.reportedServed ? { reportedServed: previous.reportedServed } : {},
8435
+ ...previous?.reportedLoaded ? { reportedLoaded: previous.reportedLoaded } : {},
8436
+ ...previous?.servedReported ? { servedReported: true } : {}
8323
8437
  };
8324
8438
  return { episode, gate, scan };
8325
8439
  }
8440
+ var progressOf = (scan) => scan.facts.toolCalls + scan.facts.turns.length;
8441
+ function checkpointDue(session, scan, now) {
8442
+ const since = Date.parse(session.checkpointAt ?? session.startedAt);
8443
+ if (!Number.isFinite(since) || now.getTime() - since < CHECKPOINT_MS) return false;
8444
+ return progressOf(scan) > (session.checkpointProgress ?? 0);
8445
+ }
8446
+ function markCheckpoint(v2, session, scan, now) {
8447
+ v2.updateSession(session.sessionId, session.cwd, (s) => {
8448
+ s.checkpointAt = now.toISOString();
8449
+ s.checkpointProgress = progressOf(scan);
8450
+ s.checkpointActivity = s.lastActivityAt;
8451
+ });
8452
+ }
8453
+ function checkpointLeftovers(store, currentSessionId, now = /* @__PURE__ */ new Date()) {
8454
+ const v2 = new V2Store(store);
8455
+ const out = [];
8456
+ const candidates = v2.listSessions().filter((s) => {
8457
+ if (s.sessionId === currentSessionId || !s.transcriptPath) return false;
8458
+ const idle = now.getTime() - Date.parse(s.lastActivityAt);
8459
+ if (!(idle >= LEFTOVER_IDLE_MS && idle <= LEFTOVER_MAX_AGE_MS)) return false;
8460
+ return s.checkpointActivity !== s.lastActivityAt;
8461
+ }).sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt));
8462
+ for (const s of candidates) {
8463
+ if (out.length >= LEFTOVERS_PER_START) break;
8464
+ const config = store.config();
8465
+ const id = episodeIdFor(localView(v2, config, s.cwd, { index: false }).ctx.id, s.sessionId);
8466
+ const prev = v2.readEpisode(id);
8467
+ if (prev && prev.endedAt >= s.lastActivityAt) {
8468
+ v2.updateSession(s.sessionId, s.cwd, (x) => {
8469
+ x.checkpointActivity = x.lastActivityAt;
8470
+ });
8471
+ continue;
8472
+ }
8473
+ const { episode, scan } = buildEpisode(store, s, void 0, now, { endedAt: s.lastActivityAt });
8474
+ v2.writeEpisode(episode);
8475
+ markCheckpoint(v2, s, scan, now);
8476
+ out.push(episode.id);
8477
+ }
8478
+ return out;
8479
+ }
8326
8480
  function recordTrialEvidence(v2, session, outcome) {
8327
8481
  if (!outcome || outcome.status === "unknown") return 0;
8328
8482
  const used = /* @__PURE__ */ new Set([...session.loaded, ...session.pushed]);
@@ -8374,7 +8528,8 @@ function onPreCompact(ctx) {
8374
8528
  const session = touch(v2, ctx);
8375
8529
  if (!config.reflect.enabled) return {};
8376
8530
  const scan = scanTranscript(ctx.input.transcript_path ?? session.transcriptPath);
8377
- const gate = sessionGate(sessionSignals({ scan, startedAt: session.startedAt, lastActivityAt: (/* @__PURE__ */ new Date()).toISOString() }), { cacheTtlMs: config.reflect.cacheTtlMs });
8531
+ const from = reflectCursor(session, scan.facts.turns.length);
8532
+ const gate = sessionGate(sessionSignals({ scan: sinceTurn(scan, from), startedAt: session.startedAt, lastActivityAt: (/* @__PURE__ */ new Date()).toISOString(), partial: from > 0 }), { cacheTtlMs: config.reflect.cacheTtlMs });
8378
8533
  if (gate.worth) ctx.detach(reflectArgs(ctx, session.sessionId), { cwd: session.cwd || ctx.cwd });
8379
8534
  v2.log("v2-pre-compact", { session: session.sessionId, gate: gate.reason, fork: gate.worth });
8380
8535
  return {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunshin-cc/bunshin",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Shared memory for company AI agents. Connect Codex, Claude Code, or Pi to Bunshin.",
5
5
  "type": "module",
6
6
  "bin": {