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.
- package/bin/chromeflow.mjs +190 -37
- package/package.json +2 -2
package/bin/chromeflow.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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)
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
/**
|
|
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}
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
`
|
|
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
|
|
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.
|
|
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.
|
|
4
|
-
"description": "MCP server for chromeflow
|
|
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": {
|