@jobshimo/browser-link 0.41.0 → 0.43.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 (85) hide show
  1. package/README.md +28 -4
  2. package/dist/activity/db.js +18 -0
  3. package/dist/activity/db.js.map +1 -1
  4. package/dist/activity/extract.d.ts +18 -2
  5. package/dist/activity/extract.js +122 -1
  6. package/dist/activity/extract.js.map +1 -1
  7. package/dist/activity/queries.d.ts +1 -1
  8. package/dist/activity/queries.js +20 -4
  9. package/dist/activity/queries.js.map +1 -1
  10. package/dist/activity/summary.d.ts +54 -0
  11. package/dist/activity/summary.js +95 -0
  12. package/dist/activity/summary.js.map +1 -0
  13. package/dist/activity/types.d.ts +30 -0
  14. package/dist/activity/types.js +31 -1
  15. package/dist/activity/types.js.map +1 -1
  16. package/dist/admin/actions.d.ts +57 -0
  17. package/dist/admin/actions.js +163 -0
  18. package/dist/admin/actions.js.map +1 -0
  19. package/dist/admin/descriptor.d.ts +65 -0
  20. package/dist/admin/descriptor.js +205 -0
  21. package/dist/admin/descriptor.js.map +1 -0
  22. package/dist/admin/i18n.d.ts +90 -0
  23. package/dist/admin/i18n.js +203 -0
  24. package/dist/admin/i18n.js.map +1 -0
  25. package/dist/admin/primary.d.ts +21 -0
  26. package/dist/admin/primary.js +56 -0
  27. package/dist/admin/primary.js.map +1 -0
  28. package/dist/admin/sources.d.ts +22 -0
  29. package/dist/admin/sources.js +82 -0
  30. package/dist/admin/sources.js.map +1 -0
  31. package/dist/bridge/ws-bridge.d.ts +38 -1
  32. package/dist/bridge/ws-bridge.js +114 -0
  33. package/dist/bridge/ws-bridge.js.map +1 -1
  34. package/dist/cli.js +18 -2
  35. package/dist/cli.js.map +1 -1
  36. package/dist/commands/activity.d.ts +5 -0
  37. package/dist/commands/activity.js +92 -1
  38. package/dist/commands/activity.js.map +1 -1
  39. package/dist/commands/config.d.ts +14 -0
  40. package/dist/commands/config.js +48 -4
  41. package/dist/commands/config.js.map +1 -1
  42. package/dist/config.d.ts +10 -0
  43. package/dist/config.js.map +1 -1
  44. package/dist/extension/admin-view.d.ts +95 -0
  45. package/dist/extension/admin-view.js +164 -0
  46. package/dist/extension/admin-view.js.map +1 -0
  47. package/dist/extension/background.js +76 -0
  48. package/dist/extension/background.js.map +1 -1
  49. package/dist/extension/manifest.json +1 -1
  50. package/dist/extension/popup.html +113 -0
  51. package/dist/extension/popup.js +206 -0
  52. package/dist/extension/popup.js.map +1 -1
  53. package/dist/manual/topics.js +2 -2
  54. package/dist/manual/topics.js.map +1 -1
  55. package/dist/map/db.js +26 -0
  56. package/dist/map/db.js.map +1 -1
  57. package/dist/map/learning.d.ts +81 -0
  58. package/dist/map/learning.js +244 -0
  59. package/dist/map/learning.js.map +1 -0
  60. package/dist/map/queries.d.ts +45 -0
  61. package/dist/map/queries.js +108 -5
  62. package/dist/map/queries.js.map +1 -1
  63. package/dist/map/tools.js +2 -1
  64. package/dist/map/tools.js.map +1 -1
  65. package/dist/messages.d.ts +86 -2
  66. package/dist/server.js +23 -0
  67. package/dist/server.js.map +1 -1
  68. package/dist/tools/browser-definitions.js +15 -1
  69. package/dist/tools/browser-definitions.js.map +1 -1
  70. package/dist/tools/browser-dispatch.d.ts +10 -0
  71. package/dist/tools/browser-dispatch.js +88 -18
  72. package/dist/tools/browser-dispatch.js.map +1 -1
  73. package/dist/ui/app.d.ts +4 -1
  74. package/dist/ui/app.js +55 -42
  75. package/dist/ui/app.js.map +1 -1
  76. package/dist/ui/screens/index.d.ts +1 -0
  77. package/dist/ui/screens/index.js +1 -0
  78. package/dist/ui/screens/index.js.map +1 -1
  79. package/dist/ui/screens/menu.d.ts +11 -1
  80. package/dist/ui/screens/menu.js +12 -4
  81. package/dist/ui/screens/menu.js.map +1 -1
  82. package/dist/ui/screens/status.d.ts +19 -0
  83. package/dist/ui/screens/status.js +67 -0
  84. package/dist/ui/screens/status.js.map +1 -0
  85. package/package.json +1 -1
@@ -0,0 +1,81 @@
1
+ import type Database from 'better-sqlite3';
2
+ /**
3
+ * The map learns from use.
4
+ *
5
+ * Every click/type/press/find/wait_for an agent dispatches already tells us
6
+ * whether a target worked: a thrown `retargeted`, a `find` that resolved with
7
+ * `matched: false`, a click whose `expect` was met. Until now that evidence
8
+ * died with the tool result. This module turns it into ok/fail counters per
9
+ * durable target in the map DB (`selector_outcomes`, see db.ts), so the
10
+ * `list_tabs` hint and `browser.map.recall` can say "this selector failed
11
+ * here before" without the agent doing any bookkeeping.
12
+ *
13
+ * Privacy rule: the stored target is the CSS selector string or the
14
+ * `browser.find` semantic key, and nothing else. Typed text, evaluate
15
+ * expressions, key names and any other value an action carried are never
16
+ * read here.
17
+ *
18
+ * Two layers:
19
+ * - `classifyOutcome` is pure: (tool, args, result, error) → verdict.
20
+ * - `recordOutcome` persists one verdict; `learnFromDispatch` is the glue
21
+ * the dispatcher's `recordOutcome` dep calls, gated by the config switch.
22
+ */
23
+ /** The fixed failure vocabulary. `last_reason` never holds anything else. */
24
+ export declare const LEARNED_REASONS: readonly ["retargeted", "ref-stale", "expect-failed", "not-found", "multiple-matches", "unmatched", "timeout", "unsettled", "hit-mismatch"];
25
+ export type LearnedReason = (typeof LEARNED_REASONS)[number];
26
+ export type LearnedOutcome = {
27
+ ok: true;
28
+ } | {
29
+ ok: false;
30
+ reason: LearnedReason;
31
+ };
32
+ export type TargetKind = 'selector' | 'key';
33
+ /** Rows kept per app. Past it, the least used row is evicted. */
34
+ export declare const MAX_LEARNED_PER_APP = 200;
35
+ /**
36
+ * Classify one dispatched call.
37
+ *
38
+ * Returns `{ ok: true }`, `{ ok: false, reason }`, or null when there is
39
+ * nothing to learn: a tool outside click/type/press/find/wait_for, a call
40
+ * without a durable target, or a result that proves neither success nor
41
+ * failure (a quiet click with no `expect`: quiet is not effect).
42
+ */
43
+ export declare function classifyOutcome(tool: string, args: unknown, result: unknown, error: unknown): LearnedOutcome | null;
44
+ export interface RecordOutcomeInput {
45
+ origin: string;
46
+ pathname: string;
47
+ target: string;
48
+ target_kind: TargetKind;
49
+ action: string;
50
+ outcome: LearnedOutcome;
51
+ }
52
+ /**
53
+ * Persist one verdict: upsert the counters of the (app, route, target,
54
+ * kind, action) row, then enforce the per-app cap.
55
+ *
56
+ * The app is resolved by `resolveLearnerApp`: the most recently seen app
57
+ * of the origin (the one `list_tabs` and `recall` show), left untouched,
58
+ * or a new app keyed by the origin host when the origin has none. The tab
59
+ * title is never an input: a title such as "Inbox (3) - My App" would mint
60
+ * a fresh app that then hides the saved map.
61
+ */
62
+ export declare function recordOutcome(db: Database.Database, input: RecordOutcomeInput): void;
63
+ /** What the dispatcher hands the learner after a call settles. */
64
+ export interface DispatchOutcomeContext {
65
+ tool: string;
66
+ args: unknown;
67
+ result: unknown;
68
+ error: unknown;
69
+ /** The tab's URL after the call, from the live tab table. */
70
+ url: string | null;
71
+ /** The tab's title. Part of the dispatcher contract but deliberately
72
+ * ignored by the learner: it never resolves apps from or stores titles. */
73
+ title: string | null;
74
+ }
75
+ /**
76
+ * The dispatcher's `recordOutcome` dep in production (wired in server.ts).
77
+ * Classifies first, so calls with nothing to learn cost no config read and
78
+ * no DB access; then honours the `mapLearningEnabled` switch, read on every
79
+ * call so `browser-link config set map-learning off` applies at once.
80
+ */
81
+ export declare function learnFromDispatch(ctx: DispatchOutcomeContext): void;
@@ -0,0 +1,244 @@
1
+ import { loadConfig } from '../config.js';
2
+ import { getDb } from './db.js';
3
+ import { resolveLearnerApp } from './queries.js';
4
+ /**
5
+ * The map learns from use.
6
+ *
7
+ * Every click/type/press/find/wait_for an agent dispatches already tells us
8
+ * whether a target worked: a thrown `retargeted`, a `find` that resolved with
9
+ * `matched: false`, a click whose `expect` was met. Until now that evidence
10
+ * died with the tool result. This module turns it into ok/fail counters per
11
+ * durable target in the map DB (`selector_outcomes`, see db.ts), so the
12
+ * `list_tabs` hint and `browser.map.recall` can say "this selector failed
13
+ * here before" without the agent doing any bookkeeping.
14
+ *
15
+ * Privacy rule: the stored target is the CSS selector string or the
16
+ * `browser.find` semantic key, and nothing else. Typed text, evaluate
17
+ * expressions, key names and any other value an action carried are never
18
+ * read here.
19
+ *
20
+ * Two layers:
21
+ * - `classifyOutcome` is pure: (tool, args, result, error) → verdict.
22
+ * - `recordOutcome` persists one verdict; `learnFromDispatch` is the glue
23
+ * the dispatcher's `recordOutcome` dep calls, gated by the config switch.
24
+ */
25
+ /** The fixed failure vocabulary. `last_reason` never holds anything else. */
26
+ export const LEARNED_REASONS = [
27
+ 'retargeted',
28
+ 'ref-stale',
29
+ 'expect-failed',
30
+ 'not-found',
31
+ 'multiple-matches',
32
+ 'unmatched',
33
+ 'timeout',
34
+ 'unsettled',
35
+ 'hit-mismatch',
36
+ ];
37
+ /** Rows kept per app. Past it, the least used row is evicted. */
38
+ export const MAX_LEARNED_PER_APP = 200;
39
+ /** Tools whose outcome says something about a target, and the short action
40
+ * name stored for them. */
41
+ const LEARNABLE_ACTIONS = {
42
+ 'browser.click': 'click',
43
+ 'browser.type': 'type',
44
+ 'browser.press': 'press',
45
+ 'browser.find': 'find',
46
+ 'browser.wait_for': 'wait_for',
47
+ };
48
+ function asRecord(value) {
49
+ return typeof value === 'object' && value !== null ? value : null;
50
+ }
51
+ /**
52
+ * The durable target of a call, or null. A selector string counts for every
53
+ * learnable tool. A semantic key counts only for `browser.find` — on
54
+ * `browser.press`, `key` is the keyboard key, not an element. A `ref` (or
55
+ * `find` by text) names no durable target: refs die with the document and
56
+ * text is a value, so such calls learn nothing.
57
+ */
58
+ function durableTarget(tool, args) {
59
+ const a = asRecord(args);
60
+ if (!a)
61
+ return null;
62
+ if (typeof a.selector === 'string' && a.selector.length > 0) {
63
+ return { target: a.selector, target_kind: 'selector' };
64
+ }
65
+ if (tool === 'browser.find') {
66
+ const key = asRecord(a.key);
67
+ if (key) {
68
+ // Fixed field order so the same key always yields the same row.
69
+ return {
70
+ target: JSON.stringify({ role: key.role, name: key.name, path: key.path }),
71
+ target_kind: 'key',
72
+ };
73
+ }
74
+ }
75
+ return null;
76
+ }
77
+ /** Results normally arrive raw; an MCP `{ content: [{ type: 'text', text }] }`
78
+ * envelope is read through its first text block. Unparseable text is no
79
+ * result at all. */
80
+ function unwrapEnvelope(result) {
81
+ const content = asRecord(result)?.content;
82
+ if (!Array.isArray(content))
83
+ return result;
84
+ const first = asRecord(content[0]);
85
+ if (first?.type !== 'text' || typeof first.text !== 'string')
86
+ return null;
87
+ try {
88
+ return JSON.parse(first.text);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ }
94
+ function errorText(error) {
95
+ if (error instanceof Error)
96
+ return error.message;
97
+ return typeof error === 'string' ? error : String(error);
98
+ }
99
+ /** Thrown failures with a known meaning. Anything else (argument
100
+ * validation, claim conflicts, a disconnected tab) says nothing about the
101
+ * target and is not learned. */
102
+ function classifyError(error) {
103
+ const message = errorText(error);
104
+ if (/\bretargeted\b|^Click would land on /.test(message)) {
105
+ return { ok: false, reason: 'retargeted' };
106
+ }
107
+ if (/\bref-stale\b/.test(message))
108
+ return { ok: false, reason: 'ref-stale' };
109
+ if (/expectation not met/.test(message))
110
+ return { ok: false, reason: 'expect-failed' };
111
+ if (/^Tool '.*' timed out after/.test(message))
112
+ return { ok: false, reason: 'timeout' };
113
+ if (/^Element not found:/.test(message))
114
+ return { ok: false, reason: 'not-found' };
115
+ return null;
116
+ }
117
+ /**
118
+ * Classify one dispatched call.
119
+ *
120
+ * Returns `{ ok: true }`, `{ ok: false, reason }`, or null when there is
121
+ * nothing to learn: a tool outside click/type/press/find/wait_for, a call
122
+ * without a durable target, or a result that proves neither success nor
123
+ * failure (a quiet click with no `expect`: quiet is not effect).
124
+ */
125
+ export function classifyOutcome(tool, args, result, error) {
126
+ if (LEARNABLE_ACTIONS[tool] === undefined)
127
+ return null;
128
+ const target = durableTarget(tool, args);
129
+ if (!target)
130
+ return null;
131
+ if (error !== undefined)
132
+ return classifyError(error);
133
+ const r = asRecord(unwrapEnvelope(result)) ?? {};
134
+ if (tool === 'browser.find' || tool === 'browser.wait_for') {
135
+ if (r.matched === true)
136
+ return { ok: true };
137
+ if (r.matched !== false)
138
+ return null;
139
+ if (tool === 'browser.find' && r.reason === 'not-found') {
140
+ return { ok: false, reason: 'not-found' };
141
+ }
142
+ if (tool === 'browser.find' && r.reason === 'multiple-matches') {
143
+ return { ok: false, reason: 'multiple-matches' };
144
+ }
145
+ return { ok: false, reason: 'unmatched' };
146
+ }
147
+ // click / type / press resolved.
148
+ if (typeof r.hit_element === 'string' &&
149
+ target.target_kind === 'selector' &&
150
+ r.hit_element !== target.target) {
151
+ return { ok: false, reason: 'hit-mismatch' };
152
+ }
153
+ // An `expect` that did not hold would have thrown, so a resolved call
154
+ // carrying one is proof of effect.
155
+ if (asRecord(args)?.expect !== undefined)
156
+ return { ok: true };
157
+ const settle = asRecord(r.settle);
158
+ if (settle?.settled === false)
159
+ return { ok: false, reason: 'unsettled' };
160
+ if (typeof settle?.mutation_count === 'number' && settle.mutation_count > 0) {
161
+ return { ok: true };
162
+ }
163
+ return null;
164
+ }
165
+ /**
166
+ * Persist one verdict: upsert the counters of the (app, route, target,
167
+ * kind, action) row, then enforce the per-app cap.
168
+ *
169
+ * The app is resolved by `resolveLearnerApp`: the most recently seen app
170
+ * of the origin (the one `list_tabs` and `recall` show), left untouched,
171
+ * or a new app keyed by the origin host when the origin has none. The tab
172
+ * title is never an input: a title such as "Inbox (3) - My App" would mint
173
+ * a fresh app that then hides the saved map.
174
+ */
175
+ export function recordOutcome(db, input) {
176
+ if (!input.outcome.ok && !LEARNED_REASONS.includes(input.outcome.reason))
177
+ return;
178
+ const app = resolveLearnerApp(db, input.origin);
179
+ const ts = new Date().toISOString();
180
+ const ok = input.outcome.ok;
181
+ const reason = input.outcome.ok ? null : input.outcome.reason;
182
+ db.transaction(() => {
183
+ db.prepare(`INSERT INTO selector_outcomes
184
+ (app_id, url_pattern, target, target_kind, action, ok_n, fail_n, last_reason, last_ok_at, last_fail_at)
185
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
186
+ ON CONFLICT(app_id, url_pattern, target, target_kind, action) DO UPDATE SET
187
+ ok_n = ok_n + excluded.ok_n,
188
+ fail_n = fail_n + excluded.fail_n,
189
+ last_reason = COALESCE(excluded.last_reason, last_reason),
190
+ last_ok_at = COALESCE(excluded.last_ok_at, last_ok_at),
191
+ last_fail_at = COALESCE(excluded.last_fail_at, last_fail_at)`).run(app.id, input.pathname, input.target, input.target_kind, input.action, ok ? 1 : 0, ok ? 0 : 1, reason, ok ? ts : null, ok ? null : ts);
192
+ const { n } = db
193
+ .prepare('SELECT COUNT(*) AS n FROM selector_outcomes WHERE app_id = ?')
194
+ .get(app.id);
195
+ if (n <= MAX_LEARNED_PER_APP)
196
+ return;
197
+ // Evict the least used rows, oldest first on a tie, never the row just
198
+ // written: evicting the newcomer would freeze a full app forever.
199
+ db.prepare(`DELETE FROM selector_outcomes WHERE id IN (
200
+ SELECT id FROM selector_outcomes
201
+ WHERE app_id = ?
202
+ AND NOT (url_pattern = ? AND target = ? AND target_kind = ? AND action = ?)
203
+ ORDER BY ok_n + fail_n ASC,
204
+ MAX(COALESCE(last_ok_at, ''), COALESCE(last_fail_at, '')) ASC,
205
+ id ASC
206
+ LIMIT ?
207
+ )`).run(app.id, input.pathname, input.target, input.target_kind, input.action, n - MAX_LEARNED_PER_APP);
208
+ })();
209
+ }
210
+ /**
211
+ * The dispatcher's `recordOutcome` dep in production (wired in server.ts).
212
+ * Classifies first, so calls with nothing to learn cost no config read and
213
+ * no DB access; then honours the `mapLearningEnabled` switch, read on every
214
+ * call so `browser-link config set map-learning off` applies at once.
215
+ */
216
+ export function learnFromDispatch(ctx) {
217
+ const outcome = classifyOutcome(ctx.tool, ctx.args, ctx.result, ctx.error);
218
+ if (!outcome)
219
+ return;
220
+ const target = durableTarget(ctx.tool, ctx.args);
221
+ const action = LEARNABLE_ACTIONS[ctx.tool];
222
+ if (!target || !action || !ctx.url)
223
+ return;
224
+ let url;
225
+ try {
226
+ url = new URL(ctx.url);
227
+ }
228
+ catch {
229
+ return;
230
+ }
231
+ if (url.origin === 'null')
232
+ return;
233
+ if (loadConfig().mapLearningEnabled === false)
234
+ return;
235
+ recordOutcome(getDb(), {
236
+ origin: url.origin,
237
+ pathname: url.pathname,
238
+ target: target.target,
239
+ target_kind: target.target_kind,
240
+ action,
241
+ outcome,
242
+ });
243
+ }
244
+ //# sourceMappingURL=learning.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"learning.js","sourceRoot":"","sources":["../../src/map/learning.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,4 @@
1
+ import type Database from 'better-sqlite3';
1
2
  export type EntryKind = 'selector' | 'flow' | 'gotcha';
2
3
  export interface AppRow {
3
4
  id: number;
@@ -30,6 +31,19 @@ export interface UpsertAppInput {
30
31
  notes?: string | null;
31
32
  }
32
33
  export declare function upsertApp(input: UpsertAppInput): AppRow;
34
+ /**
35
+ * The app the map learner counts into for an origin, read-only on purpose.
36
+ *
37
+ * Picks the most recently seen app of the canonical origin with the same
38
+ * winner rule `getMapHints` applies (last_seen_at DESC, id DESC), so learned
39
+ * rows land in the app `list_tabs` and `recall` already show. It never
40
+ * touches that app's `last_seen_at` or `title`: a passive learner bumping
41
+ * freshness would reorder which app an origin resolves to. Only when the
42
+ * origin has no app at all does it create one, keyed and titled by the
43
+ * origin host, never by a tab title (titles change with page state, e.g.
44
+ * "Inbox (3) - My App", and would mint a new app per title).
45
+ */
46
+ export declare function resolveLearnerApp(db: Database.Database, origin: string): AppRow;
33
47
  export interface SaveEntryInput {
34
48
  origin: string;
35
49
  app_key?: string | null;
@@ -84,8 +98,32 @@ export interface RecallResult {
84
98
  pitfalls: InsightRow[];
85
99
  mechanisms: InsightRow[];
86
100
  entries: EntryRow[];
101
+ /** What the map learned from use (see learning.ts): failures first, then
102
+ * most used, at most `RECALL_MAX_LEARNED`. Present only when the app has
103
+ * learned rows, so a recall of an app with none reads exactly as before. */
104
+ learned?: LearnedRecallItem[];
87
105
  flows: FlowRow[];
88
106
  }
107
+ /** One learned target as `list_tabs`' inline map carries it. Targets only:
108
+ * a CSS selector, or a `browser.find` semantic key as JSON. */
109
+ export interface LearnedSummary {
110
+ target: string;
111
+ target_kind: 'selector' | 'key';
112
+ action: string;
113
+ ok_n: number;
114
+ fail_n: number;
115
+ last_reason: string | null;
116
+ }
117
+ /** Recall's richer form: the route it was learned on and when. */
118
+ export interface LearnedRecallItem extends LearnedSummary {
119
+ url_pattern: string;
120
+ last_ok_at: string | null;
121
+ last_fail_at: string | null;
122
+ }
123
+ /** Learned rows of one app in hint order, capped at `limit`. Takes the DB
124
+ * explicitly so the learner's tests can read through the handle they write
125
+ * through. */
126
+ export declare function listLearned(db: Database.Database, app_id: number, limit: number): LearnedRecallItem[];
89
127
  export declare function recall(input: RecallInput): RecallResult;
90
128
  export interface RecordUseInput {
91
129
  entry_id: number;
@@ -193,6 +231,10 @@ export interface MapHintInline {
193
231
  description: string | null;
194
232
  steps: unknown;
195
233
  }[];
234
+ /** Up to 3 learned targets, failures first. Present only when the app has
235
+ * learned rows and at least one fits the byte cap; never counted toward
236
+ * `INLINE_MAX_ITEMS`. */
237
+ learned?: LearnedSummary[];
196
238
  }
197
239
  export interface MapHint {
198
240
  app_key: string;
@@ -203,6 +245,9 @@ export interface MapHint {
203
245
  /** Present only when the map is small (see `INLINE_MAX_ITEMS` /
204
246
  * `INLINE_MAX_BYTES`); otherwise the counts above are all there is. */
205
247
  inline?: MapHintInline;
248
+ /** Learned targets of this app that have failed at least once, present
249
+ * only when there are some and no inline map carrying them fits. */
250
+ learned_failures?: number;
206
251
  }
207
252
  /**
208
253
  * Lean existence-and-count lookup for a tab's origin — powers the optional
@@ -59,6 +59,39 @@ export function upsertApp(input) {
59
59
  .run(origin, app_key, input.title ?? null, input.notes ?? null, ts, ts);
60
60
  return db.prepare('SELECT * FROM apps WHERE id = ?').get(info.lastInsertRowid);
61
61
  }
62
+ /**
63
+ * The app the map learner counts into for an origin, read-only on purpose.
64
+ *
65
+ * Picks the most recently seen app of the canonical origin with the same
66
+ * winner rule `getMapHints` applies (last_seen_at DESC, id DESC), so learned
67
+ * rows land in the app `list_tabs` and `recall` already show. It never
68
+ * touches that app's `last_seen_at` or `title`: a passive learner bumping
69
+ * freshness would reorder which app an origin resolves to. Only when the
70
+ * origin has no app at all does it create one, keyed and titled by the
71
+ * origin host, never by a tab title (titles change with page state, e.g.
72
+ * "Inbox (3) - My App", and would mint a new app per title).
73
+ */
74
+ export function resolveLearnerApp(db, origin) {
75
+ const canonical = canonicalOrigin(origin);
76
+ const winner = db
77
+ .prepare('SELECT * FROM apps WHERE origin = ? ORDER BY last_seen_at DESC, id DESC LIMIT 1')
78
+ .get(canonical);
79
+ if (winner)
80
+ return winner;
81
+ let host;
82
+ try {
83
+ host = new URL(canonical).host;
84
+ }
85
+ catch {
86
+ host = canonical;
87
+ }
88
+ const ts = now();
89
+ const info = db
90
+ .prepare(`INSERT INTO apps (origin, app_key, title, notes, created_at, last_seen_at)
91
+ VALUES (?, ?, ?, NULL, ?, ?)`)
92
+ .run(canonical, deriveAppKey(canonical, null), host, ts, ts);
93
+ return db.prepare('SELECT * FROM apps WHERE id = ?').get(info.lastInsertRowid);
94
+ }
62
95
  export function saveEntry(input) {
63
96
  const app = upsertApp({
64
97
  origin: input.origin,
@@ -131,6 +164,22 @@ export function restoreEntry(input) {
131
164
  .get(info.lastInsertRowid);
132
165
  return { app, entry: hydrate(inserted) };
133
166
  }
167
+ /** At most this many learned rows in a recall. */
168
+ const RECALL_MAX_LEARNED = 50;
169
+ /** At most this many learned rows in the `list_tabs` inline map. */
170
+ const INLINE_MAX_LEARNED = 3;
171
+ /** Failures first (rows that ever failed), then the most used; oldest row
172
+ * first on a tie so the order is stable. */
173
+ const LEARNED_ORDER = 'ORDER BY (fail_n > 0) DESC, (ok_n + fail_n) DESC, id ASC';
174
+ /** Learned rows of one app in hint order, capped at `limit`. Takes the DB
175
+ * explicitly so the learner's tests can read through the handle they write
176
+ * through. */
177
+ export function listLearned(db, app_id, limit) {
178
+ return db
179
+ .prepare(`SELECT target, target_kind, action, ok_n, fail_n, last_reason, url_pattern, last_ok_at, last_fail_at
180
+ FROM selector_outcomes WHERE app_id = ? ${LEARNED_ORDER} LIMIT ?`)
181
+ .all(app_id, limit);
182
+ }
134
183
  export function recall(input) {
135
184
  const db = getDb();
136
185
  // Same canonicalization as upsertApp — a recall with a trailing-slash or
@@ -162,12 +211,15 @@ export function recall(input) {
162
211
  .all(app.id));
163
212
  // Flow recipes are not url_pattern-scoped (see the schema comment in
164
213
  // db.ts) — recall always returns every flow saved for the app, regardless
165
- // of the optional `url` filter.
214
+ // of the optional `url` filter. Learned rows are app-wide too: each one
215
+ // carries its own `url_pattern`.
216
+ const learned = listLearned(db, app.id, RECALL_MAX_LEARNED);
166
217
  return {
167
218
  app,
168
219
  pitfalls: listInsights(app.id, 'pitfall'),
169
220
  mechanisms: listInsights(app.id, 'mechanism'),
170
221
  entries: rows.map((row) => withPreference(hydrate(row))),
222
+ ...(learned.length > 0 ? { learned } : {}),
171
223
  flows: listFlows(app.id),
172
224
  };
173
225
  }
@@ -353,6 +405,38 @@ function summarizeInsight(row) {
353
405
  ? { key: row.key, description: row.description }
354
406
  : { key: row.key, description: row.description, symptom: row.symptom };
355
407
  }
408
+ function toLearnedSummary(row) {
409
+ return {
410
+ target: row.target,
411
+ target_kind: row.target_kind,
412
+ action: row.action,
413
+ ok_n: row.ok_n,
414
+ fail_n: row.fail_n,
415
+ last_reason: row.last_reason,
416
+ };
417
+ }
418
+ function fitsInline(inline) {
419
+ return Buffer.byteLength(JSON.stringify(inline), 'utf8') <= INLINE_MAX_BYTES;
420
+ }
421
+ /** The saved map inline (or null when it does not fit), then as many of the
422
+ * top learned rows as still fit under the same byte cap. With nothing saved,
423
+ * the inline exists only to carry learned rows: null when none fits. */
424
+ function buildInlineWithLearned(appId, savedTotal) {
425
+ const base = savedTotal === 0
426
+ ? { pitfalls: [], mechanisms: [], entries: [], flows: [] }
427
+ : savedTotal <= INLINE_MAX_ITEMS
428
+ ? buildInline(appId)
429
+ : null;
430
+ if (!base)
431
+ return null;
432
+ const learned = listLearned(getDb(), appId, INLINE_MAX_LEARNED).map(toLearnedSummary);
433
+ for (let n = learned.length; n > 0; n--) {
434
+ const candidate = { ...base, learned: learned.slice(0, n) };
435
+ if (fitsInline(candidate))
436
+ return candidate;
437
+ }
438
+ return savedTotal === 0 ? null : base;
439
+ }
356
440
  function buildInline(appId) {
357
441
  const inline = {
358
442
  pitfalls: listInsights(appId, 'pitfall').map(summarizeInsight),
@@ -369,7 +453,7 @@ function buildInline(appId) {
369
453
  steps: f.steps,
370
454
  })),
371
455
  };
372
- return Buffer.byteLength(JSON.stringify(inline), 'utf8') <= INLINE_MAX_BYTES ? inline : null;
456
+ return fitsInline(inline) ? inline : null;
373
457
  }
374
458
  /**
375
459
  * Lean existence-and-count lookup for a tab's origin — powers the optional
@@ -442,7 +526,14 @@ export function getMapHints(origins) {
442
526
  const insightCounts = db
443
527
  .prepare(`SELECT app_id, kind, COUNT(*) AS n FROM insights WHERE app_id IN (${idPlaceholders}) GROUP BY app_id, kind`)
444
528
  .all(...appIds);
529
+ // What the learner recorded (see learning.ts): total rows and rows that
530
+ // ever failed, per app, in one more grouped query.
531
+ const learnedCounts = db
532
+ .prepare(`SELECT app_id, COUNT(*) AS n, SUM(CASE WHEN fail_n > 0 THEN 1 ELSE 0 END) AS failing
533
+ FROM selector_outcomes WHERE app_id IN (${idPlaceholders}) GROUP BY app_id`)
534
+ .all(...appIds);
445
535
  const entriesByAppId = new Map(entryCounts.map((row) => [row.app_id, row.n]));
536
+ const learnedByAppId = new Map(learnedCounts.map((row) => [row.app_id, row]));
446
537
  const flowsByAppId = new Map(flowCounts.map((row) => [row.app_id, row.n]));
447
538
  const insightCount = (appId, kind) => insightCounts.find((row) => row.app_id === appId && row.kind === kind)?.n ?? 0;
448
539
  for (const [origin, app] of winnerByOrigin) {
@@ -451,14 +542,26 @@ export function getMapHints(origins) {
451
542
  const mechanisms = insightCount(app.id, 'mechanism');
452
543
  const pitfalls = insightCount(app.id, 'pitfall');
453
544
  const total = entries + flows + mechanisms + pitfalls;
454
- if (total === 0)
545
+ const learned = learnedByAppId.get(app.id);
546
+ if (total === 0 && learned === undefined)
455
547
  continue;
456
548
  const hint = { app_key: app.app_key, entries, flows, mechanisms, pitfalls };
457
549
  // Only small maps are expanded (extra queries run just for them), so a
458
- // big map costs nothing beyond the count queries above.
459
- const inline = total <= INLINE_MAX_ITEMS ? buildInline(app.id) : null;
550
+ // big map costs nothing beyond the count queries above. Learned rows
551
+ // never count toward INLINE_MAX_ITEMS; they only share the byte cap.
552
+ const inline = learned === undefined
553
+ ? total <= INLINE_MAX_ITEMS
554
+ ? buildInline(app.id)
555
+ : null
556
+ : buildInlineWithLearned(app.id, total);
460
557
  if (inline)
461
558
  hint.inline = inline;
559
+ const failing = learned?.failing ?? 0;
560
+ if (failing > 0 && !inline?.learned)
561
+ hint.learned_failures = failing;
562
+ // Learned rows alone that neither fit inline nor ever failed say nothing.
563
+ if (total === 0 && !hint.inline && hint.learned_failures === undefined)
564
+ continue;
462
565
  result.set(origin, hint);
463
566
  }
464
567
  return result;