chromeflow 0.11.1 → 0.12.2

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 +190 -37
  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,49 @@ 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
+ lastAutosaved = null;
24812
+ // most recent autosave, for save_flow to vouch for
24813
+ constructor(version2, baseDir, now = Date.now) {
24786
24814
  this.version = version2;
24815
+ this.now = now;
24787
24816
  this.path = join(baseDir ?? join(homedir(), ".chromeflow"), "flows.json");
24788
24817
  this.data = this.load();
24818
+ this.pruneExpired();
24819
+ }
24820
+ nowIso() {
24821
+ return new Date(this.now()).toISOString();
24789
24822
  }
24790
24823
  load() {
24791
24824
  try {
@@ -24810,10 +24843,36 @@ var FlowStore = class {
24810
24843
  } catch {
24811
24844
  }
24812
24845
  }
24813
- /** Update the "current origin" from any URL chromeflow observed. */
24846
+ /** Drop expired provisional flows and any flow already past the fail ceiling. */
24847
+ pruneExpired() {
24848
+ const cutoff = this.now() - PROVISIONAL_TTL_MS;
24849
+ let changed = false;
24850
+ for (const [k, flows] of Object.entries(this.data.origins)) {
24851
+ const kept = flows.filter((f) => {
24852
+ if (f.fail_count >= PRUNE_AT_FAILS) return false;
24853
+ if (f.tier === "provisional" && Date.parse(f.last_verified) < cutoff) return false;
24854
+ return true;
24855
+ });
24856
+ if (kept.length !== flows.length) {
24857
+ changed = true;
24858
+ if (kept.length === 0) delete this.data.origins[k];
24859
+ else this.data.origins[k] = kept;
24860
+ }
24861
+ }
24862
+ if (changed) this.persist();
24863
+ }
24864
+ /**
24865
+ * Update the "current origin". Crossing to a DIFFERENT origin first autosaves
24866
+ * the origin we are leaving — that boundary is our best server-side proxy for
24867
+ * "a task on that site just finished".
24868
+ */
24814
24869
  noteUrl(url) {
24815
24870
  const k = originKey(url);
24816
- if (k) this.lastOrigin = k;
24871
+ if (!k) return;
24872
+ if (this.lastOrigin && this.lastOrigin !== k && (this.buffer.get(this.lastOrigin)?.length ?? 0) > 0) {
24873
+ this.autoCommit(this.lastOrigin);
24874
+ }
24875
+ this.lastOrigin = k;
24817
24876
  }
24818
24877
  /** Buffer a notable atom against an origin (defaults to last-seen origin). */
24819
24878
  observe(atom, url) {
@@ -24823,16 +24882,67 @@ var FlowStore = class {
24823
24882
  const list = this.buffer.get(k) ?? [];
24824
24883
  const sig = `${atom.tool}|${atom.target}|${atom.selector ?? ""}`;
24825
24884
  if (list.some((a) => `${a.tool}|${a.target}|${a.selector ?? ""}` === sig)) return;
24826
- list.push(atom);
24885
+ list.push(sanitizeAtom(atom));
24827
24886
  this.buffer.set(k, list);
24828
24887
  }
24829
- /** Compact recall hint for an origin, at most once per origin per session. */
24888
+ /** Autosave a single origin's buffer as a provisional flow (or promote a match). */
24889
+ autoCommit(k) {
24890
+ const buf = this.buffer.get(k);
24891
+ if (!buf || buf.length === 0) return;
24892
+ this.buffer.delete(k);
24893
+ this.upsert(k, buf, null);
24894
+ this.lastAutosaved = { key: k, sig: signatureOf(buf) };
24895
+ this.pruneExpired();
24896
+ this.persist();
24897
+ }
24898
+ /** Flush every buffered origin. Call on shutdown and for single-origin sessions. */
24899
+ flushAll() {
24900
+ for (const k of [...this.buffer.keys()]) this.autoCommit(k);
24901
+ }
24902
+ /**
24903
+ * Insert or reinforce a flow for an origin.
24904
+ * label === null → autosave: create provisional, or bump+maybe-promote a match.
24905
+ * label is string → manual save_flow: create trusted, or promote+relabel a match.
24906
+ */
24907
+ upsert(k, steps, label) {
24908
+ const now = this.nowIso();
24909
+ const sig = signatureOf(steps);
24910
+ const flows = this.data.origins[k] ?? [];
24911
+ const existing = flows.find((f) => signatureOf(f.steps) === sig);
24912
+ if (existing) {
24913
+ existing.success_count += 1;
24914
+ existing.last_verified = now;
24915
+ existing.chromeflow_version = this.version;
24916
+ if (label !== null) {
24917
+ existing.tier = "trusted";
24918
+ existing.task_label = label;
24919
+ } else if (existing.success_count >= PROMOTE_AT_SUCCESS) {
24920
+ existing.tier = "trusted";
24921
+ }
24922
+ } else {
24923
+ flows.push({
24924
+ id: `${k}#${flows.length + 1}`,
24925
+ task_label: label ?? autoLabel(k, steps),
24926
+ steps,
24927
+ tier: label !== null ? "trusted" : "provisional",
24928
+ created_at: now,
24929
+ last_verified: now,
24930
+ success_count: 1,
24931
+ fail_count: 0,
24932
+ chromeflow_version: this.version
24933
+ });
24934
+ }
24935
+ this.data.origins[k] = flows;
24936
+ return { saved: steps.length };
24937
+ }
24938
+ /** Compact recall hint for an origin (TRUSTED flows only), at most once per origin per session. */
24830
24939
  recallHint(url) {
24831
24940
  const k = originKey(url);
24832
24941
  if (!k || this.surfaced.has(k)) return "";
24833
- const flows = this.data.origins[k];
24834
- if (!flows || flows.length === 0) return "";
24942
+ const flows = (this.data.origins[k] ?? []).filter((f) => f.tier === "trusted");
24943
+ if (flows.length === 0) return "";
24835
24944
  this.surfaced.add(k);
24945
+ this.recalled.add(k);
24836
24946
  const best = [...flows].sort((a, b) => b.success_count - a.success_count).slice(0, 3);
24837
24947
  const lines = best.map((f) => {
24838
24948
  const steps = f.steps.map((s, i) => {
@@ -24850,7 +24960,31 @@ ${steps}`;
24850
24960
  \u2139 known_flow for ${k} \u2014 prefer these proven steps over rediscovery (verify each as usual):
24851
24961
  ${lines.join("\n")}`;
24852
24962
  }
24853
- /** Nudge to save buffered hard-won steps, when there are uncommitted ones. */
24963
+ /**
24964
+ * A recalled trusted step failed on replay. Raise its fail_count; drop the flow
24965
+ * once it crosses PRUNE_AT_FAILS. Gated on the flow having actually been recalled
24966
+ * this session, so an unrelated failure can't ding a flow the agent never used.
24967
+ */
24968
+ observeFailure(url, selectorOrText) {
24969
+ const k = originKey(url) ?? this.lastOrigin;
24970
+ if (!k || !selectorOrText || !this.recalled.has(k)) return;
24971
+ const flows = this.data.origins[k];
24972
+ if (!flows) return;
24973
+ let changed = false;
24974
+ for (const f of flows) {
24975
+ if (f.tier !== "trusted") continue;
24976
+ const hit = f.steps.some((s) => s.selector === selectorOrText || s.target === selectorOrText || s.target === `selector=${selectorOrText}`);
24977
+ if (hit) {
24978
+ f.fail_count += 1;
24979
+ changed = true;
24980
+ }
24981
+ }
24982
+ if (changed) {
24983
+ this.pruneExpired();
24984
+ this.persist();
24985
+ }
24986
+ }
24987
+ /** Nudge to save buffered hard-won steps — kept as the manual instant-vouch path. */
24854
24988
  capturableHint(url) {
24855
24989
  const k = originKey(url) ?? this.lastOrigin;
24856
24990
  if (!k) return "";
@@ -24859,40 +24993,39 @@ ${lines.join("\n")}`;
24859
24993
  const reasons = [...new Set(buf.map((a) => a.reason))].slice(0, 2).join("; ");
24860
24994
  return `
24861
24995
 
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.`;
24996
+ \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
24997
  }
24864
- /** Commit the buffered atoms for an origin as a named flow. */
24998
+ /** Manual save_flow: commit the buffered atoms for an origin as a TRUSTED flow. */
24865
24999
  commit(taskLabel, url) {
24866
25000
  const k = originKey(url) ?? this.lastOrigin;
24867
25001
  if (!k) return { saved: 0, origin: null, message: "No origin known yet \u2014 navigate or interact with a page first." };
24868
25002
  const buf = this.buffer.get(k) ?? [];
24869
25003
  if (buf.length === 0) {
25004
+ if (this.lastAutosaved) {
25005
+ const flows = this.data.origins[this.lastAutosaved.key] ?? [];
25006
+ const f = flows.find((x) => signatureOf(x.steps) === this.lastAutosaved.sig);
25007
+ if (f) {
25008
+ f.tier = "trusted";
25009
+ f.task_label = taskLabel;
25010
+ f.last_verified = this.nowIso();
25011
+ const promotedKey = this.lastAutosaved.key;
25012
+ this.lastAutosaved = null;
25013
+ this.persist();
25014
+ return { saved: f.steps.length, origin: promotedKey, message: `Promoted the just-autosaved flow to trusted: "${taskLabel}" (${f.steps.length} steps) for ${promotedKey}.` };
25015
+ }
25016
+ }
24870
25017
  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
25018
  }
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
25019
  this.buffer.delete(k);
25020
+ const { saved } = this.upsert(k, buf, taskLabel);
25021
+ this.pruneExpired();
24894
25022
  this.persist();
24895
- return { saved: buf.length, origin: k, message: `Saved flow "${taskLabel}" (${buf.length} steps) for ${k}.` };
25023
+ return { saved, origin: k, message: `Saved flow "${taskLabel}" (${saved} steps) for ${k} \u2014 trusted.` };
25024
+ }
25025
+ /** Test-only accessor: the persisted flows for an origin. */
25026
+ _flowsFor(url) {
25027
+ const k = originKey(url) ?? url;
25028
+ return this.data.origins[k] ?? [];
24896
25029
  }
24897
25030
  };
24898
25031
  function isFragileSelector(selector) {
@@ -25022,9 +25155,12 @@ Examples: switch_to_tab({tab: 1}) for the first tab, switch_to_tab({tab: "form"}
25022
25155
  if (response.type !== "tabs_response") throw new Error("Unexpected response");
25023
25156
  const tabs = response.tabs;
25024
25157
  const lines = tabs.map((t) => `${t.index}. ${t.active ? "[active] " : ""}${t.title} \u2014 ${t.url}`);
25158
+ const activeUrl = tabs.find((t) => t.active)?.url;
25159
+ flowStore.noteUrl(activeUrl);
25160
+ const recall = flowStore.recallHint(activeUrl);
25025
25161
  return {
25026
25162
  content: [{ type: "text", text: `Open tabs:
25027
- ${lines.join("\n")}` }]
25163
+ ${lines.join("\n")}${recall}` }]
25028
25164
  };
25029
25165
  }
25030
25166
  );
@@ -25951,9 +26087,10 @@ Current URL: ${activeTab.url}`;
25951
26087
  }, actionUrl);
25952
26088
  }
25953
26089
  flowStore.noteUrl(nowUrl);
25954
- const recall = flowStore.recallHint(nowUrl);
26090
+ const recall = flowStore.recallHint(actionUrl) || flowStore.recallHint(nowUrl);
25955
26091
  const capturable = flowStore.capturableHint(actionUrl);
25956
26092
  if (!r.success) {
26093
+ flowStore.observeFailure(actionUrl, selector ?? textHint);
25957
26094
  return {
25958
26095
  content: [
25959
26096
  {
@@ -25970,9 +26107,9 @@ Current URL: ${activeTab.url}`;
25970
26107
  );
25971
26108
  server.tool(
25972
26109
  "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.
26110
+ `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
26111
 
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.`,
26112
+ 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
26113
  {
25977
26114
  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
26115
  },
@@ -26294,7 +26431,7 @@ ${lines.join("\n")}${shadowSection}` }] };
26294
26431
  }
26295
26432
 
26296
26433
  // packages/mcp-server/src/index.ts
26297
- var PACKAGE_VERSION = true ? "0.11.1" : "dev";
26434
+ var PACKAGE_VERSION = true ? "0.12.2" : "dev";
26298
26435
  main().catch((err) => {
26299
26436
  console.error("[chromeflow] Fatal error:", err);
26300
26437
  process.exit(1);
@@ -26368,12 +26505,28 @@ ${tabList}`
26368
26505
  );
26369
26506
  const transport = new StdioServerTransport();
26370
26507
  await server.connect(transport);
26508
+ let exited = false;
26371
26509
  const exitClean = (reason) => {
26510
+ if (exited) return;
26511
+ exited = true;
26372
26512
  console.error(`[chromeflow] host disconnected (${reason}), exiting.`);
26513
+ try {
26514
+ flowStore.flushAll();
26515
+ } catch {
26516
+ }
26373
26517
  process.exit(0);
26374
26518
  };
26375
26519
  process.stdin.on("end", () => exitClean("stdin end"));
26376
26520
  process.stdin.on("close", () => exitClean("stdin close"));
26521
+ process.on("SIGTERM", () => exitClean("SIGTERM"));
26522
+ process.on("SIGINT", () => exitClean("SIGINT"));
26523
+ process.on("SIGHUP", () => exitClean("SIGHUP"));
26524
+ process.on("beforeExit", () => {
26525
+ try {
26526
+ flowStore.flushAll();
26527
+ } catch {
26528
+ }
26529
+ });
26377
26530
  const originalPpid = process.ppid;
26378
26531
  setInterval(() => {
26379
26532
  const ppid = process.ppid;
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.2",
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": {