@polishd/next 0.2.1 → 0.2.2-dev.1

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 (59) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/DATABASE.md +8 -1
  3. package/README.md +52 -0
  4. package/dist/ai/actions.d.ts +10 -1
  5. package/dist/ai/actions.d.ts.map +1 -1
  6. package/dist/ai/actions.js +21 -0
  7. package/dist/ai/actions.js.map +1 -1
  8. package/dist/ai/ignored.d.ts +26 -0
  9. package/dist/ai/ignored.d.ts.map +1 -0
  10. package/dist/ai/ignored.js +134 -0
  11. package/dist/ai/ignored.js.map +1 -0
  12. package/dist/ai/summary.d.ts +8 -1
  13. package/dist/ai/summary.d.ts.map +1 -1
  14. package/dist/ai/summary.js +59 -22
  15. package/dist/ai/summary.js.map +1 -1
  16. package/dist/ai/types.d.ts +31 -0
  17. package/dist/ai/types.d.ts.map +1 -1
  18. package/dist/client/dom.d.ts +23 -0
  19. package/dist/client/dom.d.ts.map +1 -0
  20. package/dist/client/dom.js +71 -0
  21. package/dist/client/dom.js.map +1 -0
  22. package/dist/client/init.d.ts.map +1 -1
  23. package/dist/client/init.js +3 -62
  24. package/dist/client/init.js.map +1 -1
  25. package/dist/dashboard/index.d.ts.map +1 -1
  26. package/dist/dashboard/index.js +16 -10
  27. package/dist/dashboard/index.js.map +1 -1
  28. package/dist/dashboard/summary.d.ts.map +1 -1
  29. package/dist/dashboard/summary.js +64 -4
  30. package/dist/dashboard/summary.js.map +1 -1
  31. package/dist/dashboard/telemetry-consent.d.ts +3 -0
  32. package/dist/dashboard/telemetry-consent.d.ts.map +1 -0
  33. package/dist/dashboard/telemetry-consent.js +20 -0
  34. package/dist/dashboard/telemetry-consent.js.map +1 -0
  35. package/dist/dashboard/telemetry.d.ts +21 -0
  36. package/dist/dashboard/telemetry.d.ts.map +1 -0
  37. package/dist/dashboard/telemetry.js +212 -0
  38. package/dist/dashboard/telemetry.js.map +1 -0
  39. package/dist/dashboard.css +52 -0
  40. package/dist/server/ingest.d.ts +20 -0
  41. package/dist/server/ingest.d.ts.map +1 -1
  42. package/dist/server/ingest.js +49 -0
  43. package/dist/server/ingest.js.map +1 -1
  44. package/dist/server/store.d.ts +6 -2
  45. package/dist/server/store.d.ts.map +1 -1
  46. package/dist/server/store.js +34 -13
  47. package/dist/server/store.js.map +1 -1
  48. package/dist/server/telemetry.d.ts +28 -0
  49. package/dist/server/telemetry.d.ts.map +1 -0
  50. package/dist/server/telemetry.js +83 -0
  51. package/dist/server/telemetry.js.map +1 -0
  52. package/dist/shared/types.d.ts +16 -0
  53. package/dist/shared/types.d.ts.map +1 -1
  54. package/dist/shared/types.js.map +1 -1
  55. package/dist/telemetry.d.ts +43 -0
  56. package/dist/telemetry.d.ts.map +1 -0
  57. package/dist/telemetry.js +81 -0
  58. package/dist/telemetry.js.map +1 -0
  59. package/package.json +5 -1
package/CHANGELOG.md CHANGED
@@ -1,7 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Cross-install telemetry ingest
6
+
7
+ A new `@polishd/next/telemetry` entry point exports a CORS-enabled ingest
8
+ route for collecting telemetry *from other polishd installations* — the
9
+ foundation for polishd dogfooding its own dashboards. Unlike the first-party
10
+ route, it answers preflights, accepts POSTs cross-origin, and takes identity
11
+ from the body (a client-minted anonymous session id plus a stable, anonymous
12
+ `installId`) since the httpOnly session cookie never crosses origins. Events
13
+ land tagged with an `install_id` — a new nullable column on `events`, migrated
14
+ automatically on both backends — so one shared database can tell installations
15
+ apart. Nothing sends to this endpoint yet; the dashboard-side emitter and its
16
+ opt-in consent flow ship separately.
17
+
18
+ ### The dashboard dogfoods itself — with consent
19
+
20
+ The dashboard now asks, once, whether it may share anonymous usage of
21
+ *itself* with the polishd project. Opt-in and off by default: say no and it
22
+ never asks again; say yes and a small emitter records clicks (with the same
23
+ rage/dead classification the host capture layer uses, via newly shared DOM
24
+ helpers), tab views, and one viewport sample — namespaced under `/~polishd/…`
25
+ paths, tied to a random install id, and suspended the instant the URL leaves
26
+ the dashboard. Nothing from the host site is ever sent: not its analytics,
27
+ not its visitors, not its URLs. `POLISHD_TELEMETRY=off` kills the feature
28
+ (prompt included), `POLISHD_TELEMETRY_ENDPOINT` redirects it for testing, and
29
+ `navigator.doNotTrack` is honored regardless of stored consent. Batches
30
+ travel as `text/plain` so the cross-origin POST stays preflight-free and
31
+ beacon-compatible.
32
+
3
33
  ## 0.2.1
4
34
 
35
+ ### Losses can be ignored, and the model remembers why
36
+
37
+ A loss had one answer next to it — **File bug** — and no way to say the other
38
+ thing: that it isn't a problem. Nothing recorded that judgement, so the next
39
+ regenerate found the deliberate two-step checkout again, and again.
40
+
41
+ **Ignore** now sits beside File bug on every loss. One click, no explanation
42
+ required: the loss leaves the card, leaves the cached summary immediately, and
43
+ is dropped from every later summary *before* the four-loss cap applies — so
44
+ dismissing one promotes the next real problem into its place rather than
45
+ leaving a gap.
46
+
47
+ A **reason** is optional and offered after the dismissal, so the fast path
48
+ stays one click. Give one and it's replayed to the model verbatim on every
49
+ later call, alongside the problem it dismissed — context the analytics digest
50
+ can't carry ("the second CTA is deliberate", "that page is staff-only"). The
51
+ model is told not to re-report a dismissed problem under any wording, and to
52
+ read the rest of the data taking the stated reason as true. Reasons can be
53
+ edited afterwards, and **Undo** puts a loss back where the model can find it
54
+ again.
55
+
56
+ Dismissals key off the same evidence citation the GitHub issue log uses, so a
57
+ problem refound under different wording is still recognised as the one you
58
+ already reviewed. They fold into the summary fingerprint too: ignoring
59
+ something marks the summary stale exactly like new analytics does, so the next
60
+ refresh really does re-ask the model.
61
+
5
62
  ### Setup moved out of the way, into a Settings tab
6
63
 
7
64
  The first screen of the dashboard was also its setup form. Provider, key, site
package/DATABASE.md CHANGED
@@ -88,13 +88,20 @@ CREATE TABLE IF NOT EXISTS events (
88
88
  text TEXT,
89
89
  value DOUBLE PRECISION,
90
90
  meta JSONB,
91
- received_at BIGINT NOT NULL
91
+ received_at BIGINT NOT NULL,
92
+ install_id TEXT
92
93
  );
93
94
  CREATE INDEX IF NOT EXISTS idx_events_path ON events(path);
94
95
  CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
95
96
  CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
97
+ CREATE INDEX IF NOT EXISTS idx_events_install ON events(install_id);
96
98
  ```
97
99
 
100
+ (`install_id` is null on every first-party event; it is only set on rows that
101
+ arrived through a `@polishd/next/telemetry` endpoint, which tags each event
102
+ with the anonymous id of the remote installation that sent it. Databases
103
+ created before the column existed are migrated automatically at open.)
104
+
98
105
  ---
99
106
 
100
107
  ## Production — Option B: Turso (edge SQLite) — not yet wired up
package/README.md CHANGED
@@ -363,6 +363,33 @@ authoritative context, so:
363
363
  > provide. Your source is read only during an explicit scan (or a targeted
364
364
  > gap-fill), and always under strict size budgets.
365
365
 
366
+ ### Two verdicts on a loss: file it, or ignore it
367
+
368
+ Every loss carries both answers, side by side.
369
+
370
+ **File bug** (with GitHub connected) verifies the report against your repo's
371
+ source before anything is filed: confirmed reports become an issue with the
372
+ technical analysis and suggested fixes, and a report the code disproves comes
373
+ back as *"checked the source — not an actual bug"* with the reasoning, filing
374
+ nothing.
375
+
376
+ **Ignore** is the other answer: this isn't a problem, stop showing it to me. It
377
+ takes one click and no explanation. The loss drops out of the card and out of
378
+ every later summary — including the cached one, immediately — and the model is
379
+ told about the dismissal on every subsequent call, so it doesn't spend a loss
380
+ slot rediscovering it. Dismissing one loss doesn't shrink the list; the next
381
+ real problem takes its place.
382
+
383
+ A **reason** is optional and offered after the fact — *"the second CTA is
384
+ deliberate"*, *"that page is staff-only"*. It's worth adding: reasons are
385
+ replayed to the model verbatim as context it can't get from the numbers, which
386
+ is what stops the same finding coming back in different words. You can edit a
387
+ reason later, and **Undo** puts the loss back where the model can report it
388
+ again.
389
+
390
+ Ignoring a loss marks the summary stale, exactly like new analytics does, so
391
+ the next refresh actually re-asks the model with the dismissal in hand.
392
+
366
393
  ## Design review
367
394
 
368
395
  The dashboard's **Design** tab (in the sidebar) is your site's brand guideline
@@ -415,6 +442,28 @@ import { PolishdMonitor } from "@polishd/next/client";
415
442
  </PolishdMonitor>
416
443
  ```
417
444
 
445
+ ## Dashboard telemetry
446
+
447
+ Polishd can report anonymous usage of its own dashboard back to the polishd
448
+ project — the same rage-click/dead-click/page-view signals it captures for
449
+ you, applied to itself, so the dashboard is improved by what it preaches.
450
+
451
+ **Opt-in, and off by default.** The first time you open the dashboard it asks
452
+ once; nothing is sent unless you say yes. What's shared when you do:
453
+
454
+ - Clicks and tab views **inside the dashboard only** (paths are namespaced
455
+ `/~polishd/…` and capture suspends the moment you navigate back to your
456
+ site), plus one viewport-size sample per session.
457
+ - A random install id (`crypto.randomUUID()`, minted on consent) and an
458
+ anonymous per-browser-session id. No cookies, no fingerprinting, no URLs,
459
+ page content, or user data from your site — your analytics never leave your
460
+ database.
461
+
462
+ Say no and it never asks again. Change your mind, or disable it fleet-wide,
463
+ with `POLISHD_TELEMETRY=off`; `navigator.doNotTrack` is honored regardless of
464
+ consent. The receiving end is an ordinary `@polishd/next/telemetry` route on
465
+ the polishd site's own installation — the same package, dogfooding itself.
466
+
418
467
  ## Configuration
419
468
 
420
469
  Defaults suit a low-traffic site. Override via `polishd.config.ts`:
@@ -487,6 +536,8 @@ POLISHD_SESSION_COOKIE=my_session
487
536
  | `POLISHD_GITHUB_REPO` | GitHub (optional) | Repository as `owner/repo` — enables filing bugs from AI-found losses |
488
537
  | `POLISHD_GITHUB_TOKEN` | GitHub (optional) | Fine-grained PAT: Contents (read), Issues & Pull requests (write) |
489
538
  | `POLISHD_GITHUB_AUTO_ISSUES` | GitHub (optional) | `true` — file a GitHub issue automatically for each new problem a summary finds |
539
+ | `POLISHD_TELEMETRY` | optional | `off` (or `0`/`false`/`no`) — disable [dashboard telemetry](#dashboard-telemetry) entirely, consent prompt included |
540
+ | `POLISHD_TELEMETRY_ENDPOINT` | optional | Where dashboard telemetry is sent, if not the polishd project's own endpoint |
490
541
 
491
542
  AI settings are optional — the dashboard's **Settings** tab configures the same
492
543
  fields, and what you save there overrides these env defaults.
@@ -519,6 +570,7 @@ Run `npx @polishd/next doctor` first — it catches most of these by name, and
519
570
  | `@polishd/next/client` | `initPolishd`, `PolishdMonitor` |
520
571
  | `@polishd/next/server` | store, ingest, queries, `withPolishdSession` (Node only) |
521
572
  | `@polishd/next/route` | `POST`, `createPolishdRoute` |
573
+ | `@polishd/next/telemetry` | `POST`, `OPTIONS`, `createPolishdTelemetryRoute` — CORS-enabled ingest for telemetry from *other* polishd installs |
522
574
  | `@polishd/next/proxy` | `proxy`, `config`, `withPolishdSession`, `polishdMatcher`, `composePolishd` |
523
575
  | `@polishd/next/dashboard` | `createPolishdPage`, `polishdTokenAuth`, `PolishdDashboard`, `loadPolishdDashboardData` |
524
576
 
@@ -1,5 +1,5 @@
1
1
  import { type ListModelsInput, type SaveAISettingsInput } from "./settings";
2
- import type { PolishdAISettingsPublic, PolishdLossItem, CreateIssueResult, GenerateDesignReviewResult, GenerateProfileResult, GenerateSummaryResult, ListModelsResult, VerifyGithubResult } from "./types";
2
+ import type { PolishdAISettingsPublic, PolishdLossItem, CreateIssueResult, GenerateDesignReviewResult, GenerateProfileResult, GenerateSummaryResult, IgnoreLossResult, ListModelsResult, UnignoreLossResult, VerifyGithubResult } from "./types";
3
3
  /** Generate or refresh the narrative. `force` re-asks the model even if data is unchanged. */
4
4
  export declare function generateSummaryAction(force?: boolean): Promise<GenerateSummaryResult>;
5
5
  /**
@@ -31,4 +31,13 @@ export declare function verifyGithubAction(): Promise<VerifyGithubResult>;
31
31
  * disproved report returns `not-a-bug` with the reasoning instead of filing.
32
32
  */
33
33
  export declare function createIssueFromLossAction(loss: Pick<PolishdLossItem, "issue" | "evidence" | "location">): Promise<CreateIssueResult>;
34
+ /**
35
+ * The other verdict on a loss: dismiss it, with an optional reason. The loss
36
+ * leaves every summary from here on, and the dismissal — reason included — is
37
+ * replayed into later model calls so the problem isn't reported again. Calling
38
+ * it on an already-dismissed loss just updates the reason.
39
+ */
40
+ export declare function ignoreLossAction(loss: Pick<PolishdLossItem, "issue" | "evidence">, reason?: string): Promise<IgnoreLossResult>;
41
+ /** Undo a dismissal: the loss can be found and reported again. */
42
+ export declare function unignoreLossAction(evidence: string): Promise<UnignoreLossResult>;
34
43
  //# sourceMappingURL=actions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/ai/actions.ts"],"names":[],"mappings":"AAqBA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACzB,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EACV,uBAAuB,EACvB,eAAe,EACf,iBAAiB,EACjB,0BAA0B,EAC1B,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EACnB,MAAM,SAAS,CAAC;AAEjB,8FAA8F;AAC9F,wBAAsB,qBAAqB,CAAC,KAAK,UAAQ,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAGzF;AAED;;;;GAIG;AACH,wBAAsB,0BAA0B,CAC9C,KAAK,UAAQ,GACZ,OAAO,CAAC,0BAA0B,CAAC,CAGrC;AAED,mFAAmF;AACnF,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC,uBAAuB,CAAC,CAGlC;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAI5E;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAGxF;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAGtE;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,GAAG,UAAU,GAAG,UAAU,CAAC,GAC7D,OAAO,CAAC,iBAAiB,CAAC,CAG5B"}
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/ai/actions.ts"],"names":[],"mappings":"AAsBA,OAAO,EAGL,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACzB,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EACV,uBAAuB,EACvB,eAAe,EACf,iBAAiB,EACjB,0BAA0B,EAC1B,qBAAqB,EACrB,qBAAqB,EACrB,gBAAgB,EAChB,gBAAgB,EAChB,kBAAkB,EAClB,kBAAkB,EACnB,MAAM,SAAS,CAAC;AAEjB,8FAA8F;AAC9F,wBAAsB,qBAAqB,CAAC,KAAK,UAAQ,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAGzF;AAED;;;;GAIG;AACH,wBAAsB,0BAA0B,CAC9C,KAAK,UAAQ,GACZ,OAAO,CAAC,0BAA0B,CAAC,CAGrC;AAED,mFAAmF;AACnF,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,mBAAmB,GACzB,OAAO,CAAC,uBAAuB,CAAC,CAGlC;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,qBAAqB,CAAC,CAI5E;AAED;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAGxF;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAGtE;AAED;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,GAAG,UAAU,GAAG,UAAU,CAAC,GAC7D,OAAO,CAAC,iBAAiB,CAAC,CAG5B;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,GAAG,UAAU,CAAC,EACjD,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,gBAAgB,CAAC,CAM3B;AAED,kEAAkE;AAClE,wBAAsB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAKtF"}
@@ -15,6 +15,7 @@
15
15
  import { requirePolishdAuth } from "./guard";
16
16
  import { generateDesignReview } from "./design";
17
17
  import { verifyGithubConnection } from "./github";
18
+ import { ignoreLoss, unignoreLoss } from "./ignored";
18
19
  import { createIssueFromLoss } from "./issues";
19
20
  import { generateProjectProfile } from "./profile";
20
21
  import { loadPolishdDashboardData } from "../server/queries";
@@ -73,4 +74,24 @@ export async function createIssueFromLossAction(loss) {
73
74
  await requirePolishdAuth();
74
75
  return createIssueFromLoss(loss);
75
76
  }
77
+ /**
78
+ * The other verdict on a loss: dismiss it, with an optional reason. The loss
79
+ * leaves every summary from here on, and the dismissal — reason included — is
80
+ * replayed into later model calls so the problem isn't reported again. Calling
81
+ * it on an already-dismissed loss just updates the reason.
82
+ */
83
+ export async function ignoreLossAction(loss, reason) {
84
+ await requirePolishdAuth();
85
+ const ignored = await ignoreLoss(loss, reason);
86
+ return ignored
87
+ ? { ok: true, ignored }
88
+ : { ok: false, message: "Couldn't save that — the analytics store didn't accept the write." };
89
+ }
90
+ /** Undo a dismissal: the loss can be found and reported again. */
91
+ export async function unignoreLossAction(evidence) {
92
+ await requirePolishdAuth();
93
+ return (await unignoreLoss(evidence))
94
+ ? { ok: true }
95
+ : { ok: false, message: "Couldn't undo that — the analytics store didn't accept the write." };
96
+ }
76
97
  //# sourceMappingURL=actions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"actions.js","sourceRoot":"","sources":["../../src/ai/actions.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAC/C,OAAO,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,EACL,qBAAqB,EACrB,cAAc,GAGf,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAY5C,8FAA8F;AAC9F,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,KAAK,GAAG,KAAK;IACvD,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,eAAe,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;AACpC,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC9C,KAAK,GAAG,KAAK;IAEb,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,oBAAoB,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;AACzC,CAAC;AAED,mFAAmF;AACnF,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,KAA0B;IAE1B,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,cAAc,CAAC,KAAK,CAAC,CAAC;AAC/B,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB;IACzC,MAAM,kBAAkB,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,MAAM,wBAAwB,EAAE,CAAC;IAC9C,OAAO,sBAAsB,CAAC,IAAI,CAAC,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,KAAsB;IAC3D,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,qBAAqB,CAAC,KAAK,CAAC,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,sBAAsB,EAAE,CAAC;AAClC,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,IAA8D;IAE9D,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,mBAAmB,CAAC,IAAI,CAAC,CAAC;AACnC,CAAC"}
1
+ {"version":3,"file":"actions.js","sourceRoot":"","sources":["../../src/ai/actions.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAChD,OAAO,EAAE,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAC/C,OAAO,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,EACL,qBAAqB,EACrB,cAAc,GAGf,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAc5C,8FAA8F;AAC9F,MAAM,CAAC,KAAK,UAAU,qBAAqB,CAAC,KAAK,GAAG,KAAK;IACvD,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,eAAe,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;AACpC,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAC9C,KAAK,GAAG,KAAK;IAEb,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,oBAAoB,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;AACzC,CAAC;AAED,mFAAmF;AACnF,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,KAA0B;IAE1B,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,cAAc,CAAC,KAAK,CAAC,CAAC;AAC/B,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB;IACzC,MAAM,kBAAkB,EAAE,CAAC;IAC3B,MAAM,IAAI,GAAG,MAAM,wBAAwB,EAAE,CAAC;IAC9C,OAAO,sBAAsB,CAAC,IAAI,CAAC,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,KAAsB;IAC3D,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,qBAAqB,CAAC,KAAK,CAAC,CAAC;AACtC,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,sBAAsB,EAAE,CAAC;AAClC,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,IAA8D;IAE9D,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,mBAAmB,CAAC,IAAI,CAAC,CAAC;AACnC,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,IAAiD,EACjD,MAAe;IAEf,MAAM,kBAAkB,EAAE,CAAC;IAC3B,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC/C,OAAO,OAAO;QACZ,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE;QACvB,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,mEAAmE,EAAE,CAAC;AAClG,CAAC;AAED,kEAAkE;AAClE,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,QAAgB;IACvD,MAAM,kBAAkB,EAAE,CAAC;IAC3B,OAAO,CAAC,MAAM,YAAY,CAAC,QAAQ,CAAC,CAAC;QACnC,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE;QACd,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,mEAAmE,EAAE,CAAC;AAClG,CAAC"}
@@ -0,0 +1,26 @@
1
+ import type { PolishdIgnoredLoss, PolishdLossItem, PolishdSummary } from "./types";
2
+ /** Same normalization as the issue log: one citation, one verdict. */
3
+ export declare function ignoreKey(evidence: string): string;
4
+ /** Every dismissal, newest first. */
5
+ export declare function loadIgnoredLosses(): Promise<PolishdIgnoredLoss[]>;
6
+ /** The evidence keys to filter losses by. */
7
+ export declare function ignoredKeys(list: PolishdIgnoredLoss[]): Set<string>;
8
+ /**
9
+ * Dismiss one loss, with an optional reason. Re-ignoring an already-ignored
10
+ * loss updates its reason (that's the "add a reason afterwards" path), keeping
11
+ * the original timestamp so the log stays in the order things were reviewed.
12
+ */
13
+ export declare function ignoreLoss(loss: Pick<PolishdLossItem, "issue" | "evidence">, reason?: string): Promise<PolishdIgnoredLoss | null>;
14
+ /** Undo a dismissal. Returns false only when the store refused the write. */
15
+ export declare function unignoreLoss(evidence: string): Promise<boolean>;
16
+ /**
17
+ * The dismissals as prompt context: what the owner already reviewed and
18
+ * rejected, so the model doesn't spend a loss slot re-reporting it. Reasons are
19
+ * included verbatim — they're the part the analytics can't tell the model.
20
+ */
21
+ export declare function ignoredSection(list: PolishdIgnoredLoss[]): string | null;
22
+ /** Hash of the dismissals, so a new one invalidates the cached summary. */
23
+ export declare function ignoredFingerprint(list: PolishdIgnoredLoss[]): string;
24
+ /** Drop dismissed losses from a summary about to be rendered. */
25
+ export declare function withoutIgnored(summary: PolishdSummary, keys: Set<string>): PolishdSummary;
26
+ //# sourceMappingURL=ignored.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ignored.d.ts","sourceRoot":"","sources":["../../src/ai/ignored.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,kBAAkB,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAUnF,sEAAsE;AACtE,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAElD;AAeD,qCAAqC;AACrC,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAEvE;AAED,6CAA6C;AAC7C,wBAAgB,WAAW,CAAC,IAAI,EAAE,kBAAkB,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CAEnE;AAOD;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,IAAI,EAAE,IAAI,CAAC,eAAe,EAAE,OAAO,GAAG,UAAU,CAAC,EACjD,MAAM,CAAC,EAAE,MAAM,GACd,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CA0BpC;AAED,6EAA6E;AAC7E,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAMrE;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,kBAAkB,EAAE,GAAG,MAAM,GAAG,IAAI,CAcxE;AAED,2EAA2E;AAC3E,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,kBAAkB,EAAE,GAAG,MAAM,CASrE;AAED,iEAAiE;AACjE,wBAAgB,cAAc,CAC5B,OAAO,EAAE,cAAc,EACvB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,GAChB,cAAc,CAIhB"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Polishd — ignored losses (server only).
3
+ *
4
+ * The counterpart to filing a bug. A loss is a claim the model made from the
5
+ * analytics; "File bug" says *yes, and here's the tracker issue*, and this says
6
+ * *no — stop telling me about it*, with or without a reason.
7
+ *
8
+ * A dismissal is not just a UI toggle, or the next regenerate would hand the
9
+ * same problem straight back:
10
+ *
11
+ * 1. Ignored losses are dropped from every summary that renders — the fresh
12
+ * one and the cached one alike — before the four-loss cap applies, so
13
+ * dismissing one promotes a real problem into its place rather than
14
+ * leaving a gap.
15
+ * 2. The dismissals (and their reasons) are replayed into the prompt of every
16
+ * later model call, so the model knows what has already been reviewed and
17
+ * rejected, and why. "Checkout CTA is fine, it's a two-step flow on
18
+ * purpose" is context no digest can carry.
19
+ * 3. They fold into the summary fingerprint, so ignoring something marks the
20
+ * cached summary stale and a regenerate actually re-asks the model.
21
+ *
22
+ * Keyed by the evidence citation — the same key the GitHub issue log uses (see
23
+ * issues.ts) — so a problem refound under slightly different wording is still
24
+ * recognised as the one that was dismissed.
25
+ */
26
+ import { getMeta, setMeta } from "../server/store";
27
+ import { fingerprintDigest } from "./digest";
28
+ const IGNORED_KEY = "ignored_losses";
29
+ /** Keep the log (and the prompt section built from it) bounded. */
30
+ const MAX_STORED = 50;
31
+ const MAX_IN_PROMPT = 20;
32
+ const MAX_ISSUE_CHARS = 160;
33
+ const MAX_REASON_CHARS = 240;
34
+ /** Same normalization as the issue log: one citation, one verdict. */
35
+ export function ignoreKey(evidence) {
36
+ return evidence.trim().toLowerCase();
37
+ }
38
+ async function loadLog() {
39
+ const raw = await getMeta(IGNORED_KEY);
40
+ if (!raw)
41
+ return {};
42
+ try {
43
+ const parsed = JSON.parse(raw);
44
+ return parsed && typeof parsed === "object" ? parsed : {};
45
+ }
46
+ catch {
47
+ return {};
48
+ }
49
+ }
50
+ /** Every dismissal, newest first. */
51
+ export async function loadIgnoredLosses() {
52
+ return Object.values(await loadLog()).sort((a, b) => b.ignoredAt - a.ignoredAt);
53
+ }
54
+ /** The evidence keys to filter losses by. */
55
+ export function ignoredKeys(list) {
56
+ return new Set(list.map((i) => ignoreKey(i.evidence)));
57
+ }
58
+ const clip = (s, max) => {
59
+ const t = s.replace(/\s+/g, " ").trim();
60
+ return t.length > max ? `${t.slice(0, max - 1)}…` : t;
61
+ };
62
+ /**
63
+ * Dismiss one loss, with an optional reason. Re-ignoring an already-ignored
64
+ * loss updates its reason (that's the "add a reason afterwards" path), keeping
65
+ * the original timestamp so the log stays in the order things were reviewed.
66
+ */
67
+ export async function ignoreLoss(loss, reason) {
68
+ const evidence = loss.evidence.trim();
69
+ if (!evidence)
70
+ return null;
71
+ const key = ignoreKey(evidence);
72
+ const log = await loadLog();
73
+ const trimmed = reason?.trim();
74
+ const entry = {
75
+ issue: clip(loss.issue, MAX_ISSUE_CHARS),
76
+ evidence,
77
+ reason: trimmed ? clip(trimmed, MAX_REASON_CHARS) : undefined,
78
+ ignoredAt: log[key]?.ignoredAt ?? Date.now(),
79
+ };
80
+ log[key] = entry;
81
+ // Bound the log: oldest dismissals fall off first.
82
+ const keys = Object.keys(log);
83
+ if (keys.length > MAX_STORED) {
84
+ for (const k of keys
85
+ .sort((a, b) => log[a].ignoredAt - log[b].ignoredAt)
86
+ .slice(0, keys.length - MAX_STORED)) {
87
+ delete log[k];
88
+ }
89
+ }
90
+ return (await setMeta(IGNORED_KEY, JSON.stringify(log))) ? entry : null;
91
+ }
92
+ /** Undo a dismissal. Returns false only when the store refused the write. */
93
+ export async function unignoreLoss(evidence) {
94
+ const log = await loadLog();
95
+ const key = ignoreKey(evidence);
96
+ if (!(key in log))
97
+ return true; // already gone — nothing to undo
98
+ delete log[key];
99
+ return setMeta(IGNORED_KEY, JSON.stringify(log));
100
+ }
101
+ /**
102
+ * The dismissals as prompt context: what the owner already reviewed and
103
+ * rejected, so the model doesn't spend a loss slot re-reporting it. Reasons are
104
+ * included verbatim — they're the part the analytics can't tell the model.
105
+ */
106
+ export function ignoredSection(list) {
107
+ if (list.length === 0)
108
+ return null;
109
+ const lines = list
110
+ .slice(0, MAX_IN_PROMPT)
111
+ .map((i) => `- "${i.issue}" (cited: ${i.evidence}) — ` +
112
+ (i.reason ? `dismissed because: ${i.reason}` : "dismissed without a reason"));
113
+ return ("ALREADY REVIEWED AND DISMISSED BY THE SITE OWNER (do not report these " +
114
+ "again, in these or any other words — treat a stated reason as true):\n" +
115
+ lines.join("\n"));
116
+ }
117
+ /** Hash of the dismissals, so a new one invalidates the cached summary. */
118
+ export function ignoredFingerprint(list) {
119
+ if (list.length === 0)
120
+ return "";
121
+ return fingerprintDigest(list
122
+ .slice(0, MAX_IN_PROMPT)
123
+ .map((i) => `${ignoreKey(i.evidence)}:${i.reason ?? ""}`)
124
+ .sort()
125
+ .join("|"));
126
+ }
127
+ /** Drop dismissed losses from a summary about to be rendered. */
128
+ export function withoutIgnored(summary, keys) {
129
+ if (!summary.losses?.length || keys.size === 0)
130
+ return summary;
131
+ const losses = summary.losses.filter((l) => !keys.has(ignoreKey(l.evidence)));
132
+ return losses.length === summary.losses.length ? summary : { ...summary, losses };
133
+ }
134
+ //# sourceMappingURL=ignored.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ignored.js","sourceRoot":"","sources":["../../src/ai/ignored.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,14 @@
1
1
  import { type PolishdDashboardData } from "../server/queries";
2
2
  import type { PolishdSummary, GenerateSummaryResult } from "./types";
3
3
  export { getAISettingsPublic, saveAISettings, type SaveAISettingsInput, } from "./settings";
4
- /** The last generated summary, or null. Read-only — never calls a model. */
4
+ /**
5
+ * The last generated summary, or null. Read-only — never calls a model.
6
+ *
7
+ * Losses the owner has dismissed since it was generated are filtered out here,
8
+ * so an ignore takes effect on the cached summary immediately rather than at
9
+ * the next regenerate. The stored copy keeps them, which is what makes an undo
10
+ * bring the loss back.
11
+ */
5
12
  export declare function loadSummary(): Promise<PolishdSummary | null>;
6
13
  /**
7
14
  * Generate (or reuse) the narrative summary.
@@ -1 +1 @@
1
- {"version":3,"file":"summary.d.ts","sourceRoot":"","sources":["../../src/ai/summary.ts"],"names":[],"mappings":"AAmBA,OAAO,EAA4B,KAAK,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAOxF,OAAO,KAAK,EAGV,cAAc,EACd,qBAAqB,EACtB,MAAM,SAAS,CAAC;AAGjB,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,YAAY,CAAC;AAIpB,4EAA4E;AAC5E,wBAAsB,WAAW,IAAI,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAQlE;AA2ID;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAC7B,OAAO,CAAC,qBAAqB,CAAC,CAgFhC;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,SAAS,CAAC,EAAE,oBAAoB,GAC/B,OAAO,CAAC;IACT,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC,CAAC,CAuBD"}
1
+ {"version":3,"file":"summary.d.ts","sourceRoot":"","sources":["../../src/ai/summary.ts"],"names":[],"mappings":"AAmBA,OAAO,EAA4B,KAAK,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAexF,OAAO,KAAK,EAIV,cAAc,EACd,qBAAqB,EACtB,MAAM,SAAS,CAAC;AAGjB,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,YAAY,CAAC;AAIpB;;;;;;;GAOG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAUlE;AA2KD;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAC7B,OAAO,CAAC,qBAAqB,CAAC,CAiFhC;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,SAAS,CAAC,EAAE,oBAAoB,GAC/B,OAAO,CAAC;IACT,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IAC/B,KAAK,EAAE,OAAO,CAAC;IACf,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC,CAAC,CA0BD"}
@@ -11,14 +11,15 @@
11
11
  * profile.ts), generated once — the summary call never re-reads source.
12
12
  * Only when analytics mention a component the profile doesn't cover do we
13
13
  * do a tiny targeted read of the files naming it.
14
- * 3. We fingerprint the inputs (digest + profile) and cache the summary. A
15
- * regenerate with no new data returns the cached text for free
14
+ * 3. We fingerprint the inputs (digest + profile + dismissals) and cache the
15
+ * summary. A regenerate with no new data returns the cached text for free
16
16
  * (`regenerated: false`).
17
17
  * 4. The system prompt is tight and the output is capped to a paragraph.
18
18
  */
19
19
  import { getMeta, setMeta } from "../server/store";
20
20
  import { loadPolishdDashboardData } from "../server/queries";
21
21
  import { buildDigest, fingerprintDigest } from "./digest";
22
+ import { ignoreKey, ignoredFingerprint, ignoredKeys, ignoredSection, loadIgnoredLosses, withoutIgnored, } from "./ignored";
22
23
  import { attachGithubIssues } from "./issues";
23
24
  import { callModel } from "./providers";
24
25
  import { coverageGaps, loadProjectProfile } from "./profile";
@@ -27,17 +28,26 @@ import { resolveSettings } from "./settings";
27
28
  // Settings live in ./settings; re-exported here so existing imports keep working.
28
29
  export { getAISettingsPublic, saveAISettings, } from "./settings";
29
30
  const SUMMARY_KEY = "ai_summary";
30
- /** The last generated summary, or null. Read-only — never calls a model. */
31
+ /**
32
+ * The last generated summary, or null. Read-only — never calls a model.
33
+ *
34
+ * Losses the owner has dismissed since it was generated are filtered out here,
35
+ * so an ignore takes effect on the cached summary immediately rather than at
36
+ * the next regenerate. The stored copy keeps them, which is what makes an undo
37
+ * bring the loss back.
38
+ */
31
39
  export async function loadSummary() {
32
40
  const raw = await getMeta(SUMMARY_KEY);
33
41
  if (!raw)
34
42
  return null;
43
+ let summary;
35
44
  try {
36
- return JSON.parse(raw);
45
+ summary = JSON.parse(raw);
37
46
  }
38
47
  catch {
39
48
  return null;
40
49
  }
50
+ return withoutIgnored(summary, ignoredKeys(await loadIgnoredLosses()));
41
51
  }
42
52
  const SYSTEM_PROMPT = "You are a product analyst embedded in a website's analytics dashboard. " +
43
53
  "You receive a compact digest of real user-behavior signals (clicks, rage " +
@@ -58,7 +68,10 @@ const SYSTEM_PROMPT = "You are a product analyst embedded in a website's analyti
58
68
  "must be concrete (what is wrong, where, and what the numbers show), and " +
59
69
  "evidence must be the exact page path, CSS selector, or component name " +
60
70
  "copied verbatim from the digest that demonstrates it. Never invent a loss: " +
61
- "if the data shows no real problems, return an empty array.";
71
+ "if the data shows no real problems, return an empty array. If the prompt " +
72
+ "lists problems the site owner has already reviewed and dismissed, do not " +
73
+ "report them again — not under a different citation or a different wording — " +
74
+ "and take any reason they gave as true when reading the rest of the data.";
62
75
  /**
63
76
  * Parse the model's JSON reply, tolerating stray fences or prose around it.
64
77
  * Falls back to treating the whole text as the story so a model that ignores
@@ -99,9 +112,11 @@ function citationTokens(s) {
99
112
  /**
100
113
  * Hold losses to their evidence bar. A loss survives only if its citation
101
114
  * actually appears in the prompt we sent (so it can't be invented), and is
102
- * then matched to a source file when the codebase is on disk.
115
+ * then matched to a source file when the codebase is on disk. Anything the
116
+ * owner has dismissed is dropped here, before the cap — so a dismissal frees
117
+ * its slot for the next real problem instead of leaving a gap.
103
118
  */
104
- function verifyLosses(raw, promptText, sourceDirs) {
119
+ function verifyLosses(raw, promptText, sourceDirs, ignored) {
105
120
  const hay = promptText.toLowerCase();
106
121
  const out = [];
107
122
  for (const l of raw) {
@@ -110,6 +125,8 @@ function verifyLosses(raw, promptText, sourceDirs) {
110
125
  const evidence = typeof l.evidence === "string" ? l.evidence.trim() : "";
111
126
  if (!evidence)
112
127
  continue; // no citation, no loss
128
+ if (ignored.has(ignoreKey(evidence)))
129
+ continue; // reviewed and dismissed
113
130
  const tokens = citationTokens(evidence);
114
131
  if (!tokens.length || !tokens.some((t) => hay.includes(t)))
115
132
  continue; // not in the data we sent
@@ -126,11 +143,29 @@ function verifyLosses(raw, promptText, sourceDirs) {
126
143
  return out;
127
144
  }
128
145
  /**
129
- * The exact user message for a summary call, plus its cache fingerprint.
130
- * Fingerprint covers the digest AND the profile, so a re-scan (new profile)
131
- * marks the cached summary stale too.
146
+ * The one place the cache fingerprint is defined. It covers the digest, the
147
+ * profile AND the dismissals, so a re-scan or a newly ignored loss marks the
148
+ * cached summary stale exactly like new analytics does.
149
+ */
150
+ function fingerprintInputs(digest, profile, ignored) {
151
+ const ignoredHash = ignoredFingerprint(ignored);
152
+ return fingerprintDigest([
153
+ digest,
154
+ profile ? `PROFILE:${profile.fingerprint}` : "",
155
+ ignoredHash ? `IGNORED:${ignoredHash}` : "",
156
+ ]
157
+ .filter(Boolean)
158
+ .join("\n"));
159
+ }
160
+ /**
161
+ * The exact user message for a summary call, plus its cache fingerprint and
162
+ * the slice of it a citation is allowed to be grounded in.
163
+ *
164
+ * `grounding` is the message minus the dismissals: past problems are quoted
165
+ * back to the model as context, and quoting them must not turn their citations
166
+ * into fresh evidence a new loss can lean on (see verifyLosses).
132
167
  */
133
- function composePrompt(data, profile, context, sourceDirs) {
168
+ function composePrompt(data, profile, context, sourceDirs, ignored) {
134
169
  const digest = buildDigest(data, context);
135
170
  const sections = [];
136
171
  if (profile) {
@@ -149,11 +184,12 @@ function composePrompt(data, profile, context, sourceDirs) {
149
184
  }
150
185
  }
151
186
  }
152
- sections.push(digest);
153
- const user = sections.join("\n\n");
187
+ const dismissed = ignoredSection(ignored);
188
+ const grounding = [...sections, digest].join("\n\n");
154
189
  return {
155
- user,
156
- fingerprint: fingerprintDigest(profile ? `${digest}\nPROFILE:${profile.fingerprint}` : digest),
190
+ user: dismissed ? [...sections, dismissed, digest].join("\n\n") : grounding,
191
+ grounding,
192
+ fingerprint: fingerprintInputs(digest, profile, ignored),
157
193
  };
158
194
  }
159
195
  /**
@@ -176,8 +212,8 @@ export async function generateSummary(opts = {}) {
176
212
  message: "No analytics captured yet — browse the site, then generate.",
177
213
  };
178
214
  }
179
- const profile = await loadProjectProfile();
180
- const { user, fingerprint } = composePrompt(data, profile, settings.context, settings.sourceDirs);
215
+ const [profile, ignored] = await Promise.all([loadProjectProfile(), loadIgnoredLosses()]);
216
+ const { user, grounding, fingerprint } = composePrompt(data, profile, settings.context, settings.sourceDirs, ignored);
181
217
  if (!opts.force) {
182
218
  const cached = await loadSummary();
183
219
  if (cached && cached.fingerprint === fingerprint) {
@@ -216,7 +252,7 @@ export async function generateSummary(opts = {}) {
216
252
  const parsed = parseStructured(reply.text);
217
253
  // Link losses to their GitHub issues — filing new ones when the owner
218
254
  // enabled auto-filing, attaching already-filed ones either way.
219
- const losses = await attachGithubIssues(verifyLosses(parsed.losses, user, settings.sourceDirs));
255
+ const losses = await attachGithubIssues(verifyLosses(parsed.losses, grounding, settings.sourceDirs, ignoredKeys(ignored)));
220
256
  const summary = {
221
257
  text: parsed.story,
222
258
  wins: parsed.wins,
@@ -243,14 +279,15 @@ export async function loadSummaryState(preloaded) {
243
279
  if (!data.overview.ready || data.overview.totalEvents === 0) {
244
280
  return { summary, stale: false, currentFingerprint: null };
245
281
  }
246
- const [{ settings }, profile] = await Promise.all([
282
+ const [{ settings }, profile, ignored] = await Promise.all([
247
283
  resolveSettings(),
248
284
  loadProjectProfile(),
285
+ loadIgnoredLosses(),
249
286
  ]);
250
287
  // Staleness only needs the fingerprint — skip the targeted source read by
251
- // computing it the same way composePrompt does, without collecting excerpts.
252
- const digest = buildDigest(data, settings.context);
253
- const currentFingerprint = fingerprintDigest(profile ? `${digest}\nPROFILE:${profile.fingerprint}` : digest);
288
+ // computing it from the same inputs composePrompt does, without collecting
289
+ // excerpts.
290
+ const currentFingerprint = fingerprintInputs(buildDigest(data, settings.context), profile, ignored);
254
291
  return {
255
292
  summary,
256
293
  stale: summary !== null && summary.fingerprint !== currentFingerprint,