chromeflow 0.11.1 → 0.12.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/bin/chromeflow.mjs +157 -35
  2. package/package.json +2 -2
@@ -24760,6 +24760,10 @@ var WsBridge = class {
24760
24760
  import { homedir } from "node:os";
24761
24761
  import { join, dirname } from "node:path";
24762
24762
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
24763
+ var PROMOTE_AT_SUCCESS = 2;
24764
+ var PRUNE_AT_FAILS = 2;
24765
+ var PROVISIONAL_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
24766
+ var ATOM_KEYS = ["tool", "target", "selector", "recovered_via", "signal", "fragile", "reason"];
24763
24767
  function originKey(url) {
24764
24768
  if (!url) return void 0;
24765
24769
  try {
@@ -24772,20 +24776,47 @@ function originKey(url) {
24772
24776
  }
24773
24777
  }
24774
24778
  var FRAGILE_RE = /:nth-(of-type|child)\(|>\s*\w+:nth/;
24779
+ function signatureOf(steps) {
24780
+ return JSON.stringify(steps.map((a) => [a.tool, a.target, a.selector ?? ""]));
24781
+ }
24782
+ function sanitizeAtom(a) {
24783
+ const out = {};
24784
+ for (const k of ATOM_KEYS) {
24785
+ if (a[k] !== void 0) out[k] = a[k];
24786
+ }
24787
+ return out;
24788
+ }
24789
+ function autoLabel(k, steps) {
24790
+ const tools = [...new Set(steps.map((s) => s.tool.replace("_element", "")))].join("+");
24791
+ let path2 = "/";
24792
+ try {
24793
+ path2 = new URL(k).pathname || "/";
24794
+ } catch {
24795
+ }
24796
+ return `auto: ${tools} @ ${path2}`;
24797
+ }
24775
24798
  var FlowStore = class {
24776
24799
  path;
24777
24800
  data;
24778
24801
  version;
24802
+ now;
24779
24803
  // In-memory, per-session state (never persisted):
24780
24804
  buffer = /* @__PURE__ */ new Map();
24781
24805
  // notable atoms not yet committed, by origin
24782
24806
  surfaced = /* @__PURE__ */ new Set();
24783
24807
  // origins whose recall hint already fired this session
24808
+ recalled = /* @__PURE__ */ new Set();
24809
+ // origins whose trusted flow was actually shown this session
24784
24810
  lastOrigin;
24785
- constructor(version2, baseDir) {
24811
+ constructor(version2, baseDir, now = Date.now) {
24786
24812
  this.version = version2;
24813
+ this.now = now;
24787
24814
  this.path = join(baseDir ?? join(homedir(), ".chromeflow"), "flows.json");
24788
24815
  this.data = this.load();
24816
+ this.pruneExpired();
24817
+ }
24818
+ nowIso() {
24819
+ return new Date(this.now()).toISOString();
24789
24820
  }
24790
24821
  load() {
24791
24822
  try {
@@ -24810,10 +24841,36 @@ var FlowStore = class {
24810
24841
  } catch {
24811
24842
  }
24812
24843
  }
24813
- /** Update the "current origin" from any URL chromeflow observed. */
24844
+ /** Drop expired provisional flows and any flow already past the fail ceiling. */
24845
+ pruneExpired() {
24846
+ const cutoff = this.now() - PROVISIONAL_TTL_MS;
24847
+ let changed = false;
24848
+ for (const [k, flows] of Object.entries(this.data.origins)) {
24849
+ const kept = flows.filter((f) => {
24850
+ if (f.fail_count >= PRUNE_AT_FAILS) return false;
24851
+ if (f.tier === "provisional" && Date.parse(f.last_verified) < cutoff) return false;
24852
+ return true;
24853
+ });
24854
+ if (kept.length !== flows.length) {
24855
+ changed = true;
24856
+ if (kept.length === 0) delete this.data.origins[k];
24857
+ else this.data.origins[k] = kept;
24858
+ }
24859
+ }
24860
+ if (changed) this.persist();
24861
+ }
24862
+ /**
24863
+ * Update the "current origin". Crossing to a DIFFERENT origin first autosaves
24864
+ * the origin we are leaving — that boundary is our best server-side proxy for
24865
+ * "a task on that site just finished".
24866
+ */
24814
24867
  noteUrl(url) {
24815
24868
  const k = originKey(url);
24816
- if (k) this.lastOrigin = k;
24869
+ if (!k) return;
24870
+ if (this.lastOrigin && this.lastOrigin !== k && (this.buffer.get(this.lastOrigin)?.length ?? 0) > 0) {
24871
+ this.autoCommit(this.lastOrigin);
24872
+ }
24873
+ this.lastOrigin = k;
24817
24874
  }
24818
24875
  /** Buffer a notable atom against an origin (defaults to last-seen origin). */
24819
24876
  observe(atom, url) {
@@ -24823,16 +24880,66 @@ var FlowStore = class {
24823
24880
  const list = this.buffer.get(k) ?? [];
24824
24881
  const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
24825
24882
  if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
24826
- list.push(atom);
24883
+ list.push(sanitizeAtom(atom));
24827
24884
  this.buffer.set(k, list);
24828
24885
  }
24829
- /** Compact recall hint for an origin, at most once per origin per session. */
24886
+ /** Autosave a single origin's buffer as a provisional flow (or promote a match). */
24887
+ autoCommit(k) {
24888
+ const buf = this.buffer.get(k);
24889
+ if (!buf || buf.length === 0) return;
24890
+ this.buffer.delete(k);
24891
+ this.upsert(k, buf, null);
24892
+ this.pruneExpired();
24893
+ this.persist();
24894
+ }
24895
+ /** Flush every buffered origin. Call on shutdown and for single-origin sessions. */
24896
+ flushAll() {
24897
+ for (const k of [...this.buffer.keys()]) this.autoCommit(k);
24898
+ }
24899
+ /**
24900
+ * Insert or reinforce a flow for an origin.
24901
+ * label === null → autosave: create provisional, or bump+maybe-promote a match.
24902
+ * label is string → manual save_flow: create trusted, or promote+relabel a match.
24903
+ */
24904
+ upsert(k, steps, label) {
24905
+ const now = this.nowIso();
24906
+ const sig = signatureOf(steps);
24907
+ const flows = this.data.origins[k] ?? [];
24908
+ const existing = flows.find((f) => signatureOf(f.steps) === sig);
24909
+ if (existing) {
24910
+ existing.success_count += 1;
24911
+ existing.last_verified = now;
24912
+ existing.chromeflow_version = this.version;
24913
+ if (label !== null) {
24914
+ existing.tier = "trusted";
24915
+ existing.task_label = label;
24916
+ } else if (existing.success_count >= PROMOTE_AT_SUCCESS) {
24917
+ existing.tier = "trusted";
24918
+ }
24919
+ } else {
24920
+ flows.push({
24921
+ id: `${k}#${flows.length + 1}`,
24922
+ task_label: label ?? autoLabel(k, steps),
24923
+ steps,
24924
+ tier: label !== null ? "trusted" : "provisional",
24925
+ created_at: now,
24926
+ last_verified: now,
24927
+ success_count: 1,
24928
+ fail_count: 0,
24929
+ chromeflow_version: this.version
24930
+ });
24931
+ }
24932
+ this.data.origins[k] = flows;
24933
+ return { saved: steps.length };
24934
+ }
24935
+ /** Compact recall hint for an origin (TRUSTED flows only), at most once per origin per session. */
24830
24936
  recallHint(url) {
24831
24937
  const k = originKey(url);
24832
24938
  if (!k || this.surfaced.has(k)) return "";
24833
- const flows = this.data.origins[k];
24834
- if (!flows || flows.length === 0) return "";
24939
+ const flows = (this.data.origins[k] ?? []).filter((f) => f.tier === "trusted");
24940
+ if (flows.length === 0) return "";
24835
24941
  this.surfaced.add(k);
24942
+ this.recalled.add(k);
24836
24943
  const best = [...flows].sort((a, b) => b.success_count - a.success_count).slice(0, 3);
24837
24944
  const lines = best.map((f) => {
24838
24945
  const steps = f.steps.map((s, i) => {
@@ -24850,7 +24957,31 @@ ${steps}`;
24850
24957
  \u2139 known_flow for ${k} \u2014 prefer these proven steps over rediscovery (verify each as usual):
24851
24958
  ${lines.join("\n")}`;
24852
24959
  }
24853
- /** Nudge to save buffered hard-won steps, when there are uncommitted ones. */
24960
+ /**
24961
+ * A recalled trusted step failed on replay. Raise its fail_count; drop the flow
24962
+ * once it crosses PRUNE_AT_FAILS. Gated on the flow having actually been recalled
24963
+ * this session, so an unrelated failure can't ding a flow the agent never used.
24964
+ */
24965
+ observeFailure(url, selectorOrText) {
24966
+ const k = originKey(url) ?? this.lastOrigin;
24967
+ if (!k || !selectorOrText || !this.recalled.has(k)) return;
24968
+ const flows = this.data.origins[k];
24969
+ if (!flows) return;
24970
+ let changed = false;
24971
+ for (const f of flows) {
24972
+ if (f.tier !== "trusted") continue;
24973
+ const hit = f.steps.some((s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`);
24974
+ if (hit) {
24975
+ f.fail_count += 1;
24976
+ changed = true;
24977
+ }
24978
+ }
24979
+ if (changed) {
24980
+ this.pruneExpired();
24981
+ this.persist();
24982
+ }
24983
+ }
24984
+ /** Nudge to save buffered hard-won steps — kept as the manual instant-vouch path. */
24854
24985
  capturableHint(url) {
24855
24986
  const k = originKey(url) ?? this.lastOrigin;
24856
24987
  if (!k) return "";
@@ -24859,9 +24990,9 @@ ${lines.join("\n")}`;
24859
24990
  const reasons = [...new Set(buf.map((a) => a.reason))].slice(0, 2).join("; ");
24860
24991
  return `
24861
24992
 
24862
- \u2139 flow_capturable: ${buf.length} hard-won step(s) on ${k} not yet saved (${reasons}). Call save_flow("<task label>") to persist them so future runs skip the trial-and-error.`;
24993
+ \u2139 flow_capturable: ${buf.length} hard-won step(s) on ${k} buffered (${reasons}). They autosave on leaving the site; call save_flow("<task label>") to trust them immediately.`;
24863
24994
  }
24864
- /** Commit the buffered atoms for an origin as a named flow. */
24995
+ /** Manual save_flow: commit the buffered atoms for an origin as a TRUSTED flow. */
24865
24996
  commit(taskLabel, url) {
24866
24997
  const k = originKey(url) ?? this.lastOrigin;
24867
24998
  if (!k) return { saved: 0, origin: null, message: "No origin known yet \u2014 navigate or interact with a page first." };
@@ -24869,30 +25000,16 @@ ${lines.join("\n")}`;
24869
25000
  if (buf.length === 0) {
24870
25001
  return { saved: 0, origin: k, message: `Nothing notable buffered for ${k}. Flows capture hard-won steps (a fallback fired, a verified submit, a field needing real keystrokes) \u2014 an ordinary first-try click isn't recorded.` };
24871
25002
  }
24872
- const now = (/* @__PURE__ */ new Date()).toISOString();
24873
- const sig = JSON.stringify(buf.map((a) => [a.tool, a.target, a.selector ?? ""]));
24874
- const flows = this.data.origins[k] ?? [];
24875
- const existing = flows.find((f) => f.task_label === taskLabel && JSON.stringify(f.steps.map((a) => [a.tool, a.target, a.selector ?? ""])) === sig);
24876
- if (existing) {
24877
- existing.success_count += 1;
24878
- existing.last_verified = now;
24879
- existing.chromeflow_version = this.version;
24880
- } else {
24881
- flows.push({
24882
- id: `${k}#${flows.length + 1}`,
24883
- task_label: taskLabel,
24884
- steps: buf,
24885
- created_at: now,
24886
- last_verified: now,
24887
- success_count: 1,
24888
- fail_count: 0,
24889
- chromeflow_version: this.version
24890
- });
24891
- }
24892
- this.data.origins[k] = flows;
24893
25003
  this.buffer.delete(k);
25004
+ const { saved } = this.upsert(k, buf, taskLabel);
25005
+ this.pruneExpired();
24894
25006
  this.persist();
24895
- return { saved: buf.length, origin: k, message: `Saved flow "${taskLabel}" (${buf.length} steps) for ${k}.` };
25007
+ return { saved, origin: k, message: `Saved flow "${taskLabel}" (${saved} steps) for ${k} \u2014 trusted.` };
25008
+ }
25009
+ /** Test-only accessor: the persisted flows for an origin. */
25010
+ _flowsFor(url) {
25011
+ const k = originKey(url) ?? url;
25012
+ return this.data.origins[k] ?? [];
24896
25013
  }
24897
25014
  };
24898
25015
  function isFragileSelector(selector) {
@@ -25954,6 +26071,7 @@ Current URL: ${activeTab.url}`;
25954
26071
  const recall = flowStore.recallHint(nowUrl);
25955
26072
  const capturable = flowStore.capturableHint(actionUrl);
25956
26073
  if (!r.success) {
26074
+ flowStore.observeFailure(actionUrl, selector ?? textHint);
25957
26075
  return {
25958
26076
  content: [
25959
26077
  {
@@ -25970,9 +26088,9 @@ Current URL: ${activeTab.url}`;
25970
26088
  );
25971
26089
  server.tool(
25972
26090
  "save_flow",
25973
- `Persist the hard-won interaction steps chromeflow buffered for the current site as a reusable, named flow. chromeflow auto-buffers only NOTABLE resolutions (a click that needed a fallback, a verified submit, a field that needed real keystrokes) \u2014 so you just give the task a label and it commits whatever is buffered for the current origin. Next session, those steps are surfaced back as a known_flow hint so you skip the trial-and-error.
26091
+ `Trust the hard-won interaction steps chromeflow buffered for the current site, immediately, as a named flow. chromeflow auto-buffers only NOTABLE resolutions (a click that needed a fallback, a verified submit, a field that needed real keystrokes), and AUTOSAVES them as a provisional flow when you leave the site \u2014 so memory works even if you never call this. Provisional flows are not recalled until they have been independently re-observed, or until you vouch for them here. Calling save_flow promotes the buffered steps to TRUSTED right away (an explicit "I confirm this worked"), so they are recalled next session instead of waiting to earn it.
25974
26092
 
25975
- Call this when a response shows \`flow_capturable\`. Stored locally only (~/.chromeflow/flows.json), selectors/signals only \u2014 never typed text. Guidance, not autopilot: recalled steps are still verified on replay.`,
26093
+ Call this when a response shows \`flow_capturable\` and you are confident the task genuinely succeeded. Stored locally only (~/.chromeflow/flows.json), selectors/signals only \u2014 never typed text. Guidance, not autopilot: recalled steps are still verified on replay.`,
25976
26094
  {
25977
26095
  task_label: external_exports.string().describe('Short human label for what this flow accomplishes, e.g. "submit text post", "set flair and submit", "log report time".')
25978
26096
  },
@@ -26294,7 +26412,7 @@ ${lines.join("\n")}${shadowSection}` }] };
26294
26412
  }
26295
26413
 
26296
26414
  // packages/mcp-server/src/index.ts
26297
- var PACKAGE_VERSION = true ? "0.11.1" : "dev";
26415
+ var PACKAGE_VERSION = true ? "0.12.0" : "dev";
26298
26416
  main().catch((err) => {
26299
26417
  console.error("[chromeflow] Fatal error:", err);
26300
26418
  process.exit(1);
@@ -26370,6 +26488,10 @@ ${tabList}`
26370
26488
  await server.connect(transport);
26371
26489
  const exitClean = (reason) => {
26372
26490
  console.error(`[chromeflow] host disconnected (${reason}), exiting.`);
26491
+ try {
26492
+ flowStore.flushAll();
26493
+ } catch {
26494
+ }
26373
26495
  process.exit(0);
26374
26496
  };
26375
26497
  process.stdin.on("end", () => exitClean("stdin end"));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "chromeflow",
3
- "version": "0.11.1",
4
- "description": "MCP server for chromeflow lets Claude Code or Codex CLI drive your real Chrome browser with sessions intact. Plugin install recommended; npx chromeflow for manual MCP wiring.",
3
+ "version": "0.12.0",
4
+ "description": "MCP server for chromeflow \u2014 lets Claude Code or Codex CLI drive your real Chrome browser with sessions intact. Plugin install recommended; npx chromeflow for manual MCP wiring.",
5
5
  "type": "module",
6
6
  "main": "./bin/chromeflow.mjs",
7
7
  "bin": {