@reposkein/mcp 0.4.0 → 0.6.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 (78) hide show
  1. package/README.md +6 -1
  2. package/binary-digests.json +4 -4
  3. package/dist/ads/auditLog.d.ts +23 -0
  4. package/dist/ads/auditLog.js +33 -0
  5. package/dist/ads/auditLog.js.map +1 -0
  6. package/dist/ads/config.d.ts +83 -0
  7. package/dist/ads/config.js +107 -0
  8. package/dist/ads/config.js.map +1 -0
  9. package/dist/ads/luluSource.d.ts +32 -0
  10. package/dist/ads/luluSource.js +60 -0
  11. package/dist/ads/luluSource.js.map +1 -0
  12. package/dist/ads/sanitize.d.ts +16 -0
  13. package/dist/ads/sanitize.js +187 -0
  14. package/dist/ads/sanitize.js.map +1 -0
  15. package/dist/ads/slot.d.ts +63 -0
  16. package/dist/ads/slot.js +159 -0
  17. package/dist/ads/slot.js.map +1 -0
  18. package/dist/ads/supporter.d.ts +73 -0
  19. package/dist/ads/supporter.js +119 -0
  20. package/dist/ads/supporter.js.map +1 -0
  21. package/dist/ads/supporterKey.d.ts +45 -0
  22. package/dist/ads/supporterKey.js +51 -0
  23. package/dist/ads/supporterKey.js.map +1 -0
  24. package/dist/ads/supporterStore.d.ts +61 -0
  25. package/dist/ads/supporterStore.js +113 -0
  26. package/dist/ads/supporterStore.js.map +1 -0
  27. package/dist/ads/supporterToken.d.ts +133 -0
  28. package/dist/ads/supporterToken.js +233 -0
  29. package/dist/ads/supporterToken.js.map +1 -0
  30. package/dist/ads/types.d.ts +53 -0
  31. package/dist/ads/types.js +15 -0
  32. package/dist/ads/types.js.map +1 -0
  33. package/dist/cli/support.d.ts +46 -0
  34. package/dist/cli/support.js +276 -0
  35. package/dist/cli/support.js.map +1 -0
  36. package/dist/cli/view.js +27 -6
  37. package/dist/cli/view.js.map +1 -1
  38. package/dist/index.d.ts +8 -56
  39. package/dist/index.js +45 -402
  40. package/dist/index.js.map +1 -1
  41. package/dist/indexer/indexLock.d.ts +13 -0
  42. package/dist/indexer/indexLock.js +66 -0
  43. package/dist/indexer/indexLock.js.map +1 -0
  44. package/dist/indexer/runIndexer.d.ts +8 -0
  45. package/dist/indexer/runIndexer.js +12 -0
  46. package/dist/indexer/runIndexer.js.map +1 -1
  47. package/dist/serve/serve.d.ts +95 -0
  48. package/dist/serve/serve.js +581 -0
  49. package/dist/serve/serve.js.map +1 -0
  50. package/dist/serve/tokens.d.ts +49 -0
  51. package/dist/serve/tokens.js +108 -0
  52. package/dist/serve/tokens.js.map +1 -0
  53. package/dist/serve/watch.d.ts +73 -0
  54. package/dist/serve/watch.js +135 -0
  55. package/dist/serve/watch.js.map +1 -0
  56. package/dist/server/createMcpServer.d.ts +134 -0
  57. package/dist/server/createMcpServer.js +473 -0
  58. package/dist/server/createMcpServer.js.map +1 -0
  59. package/dist/store/JsonlGraphStore.d.ts +5 -1
  60. package/dist/store/JsonlGraphStore.js +6 -2
  61. package/dist/store/JsonlGraphStore.js.map +1 -1
  62. package/dist/store/teamConfig.d.ts +20 -6
  63. package/dist/store/teamConfig.js +70 -17
  64. package/dist/store/teamConfig.js.map +1 -1
  65. package/dist/tools/recordDecision.d.ts +3 -0
  66. package/dist/tools/recordDecision.js +1 -1
  67. package/dist/tools/recordDecision.js.map +1 -1
  68. package/dist/tools/writeSemanticSummary.d.ts +4 -1
  69. package/dist/tools/writeSemanticSummary.js +5 -2
  70. package/dist/tools/writeSemanticSummary.js.map +1 -1
  71. package/dist/viz/assets/index-BnqIVYFE.css +1 -0
  72. package/dist/viz/assets/index-IhtszTp0.js +5 -0
  73. package/dist/viz/assets/{r3f-BZY3QR6_.js → r3f-Bn9htB92.js} +1 -1
  74. package/dist/viz/assets/{tanstack-CjXr-L-s.js → tanstack-CSzx6NDA.js} +7 -7
  75. package/dist/viz/index.html +4 -4
  76. package/package.json +3 -2
  77. package/dist/viz/assets/index-CxqeGuRS.css +0 -1
  78. package/dist/viz/assets/index-DiNZyrXx.js +0 -9
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # @reposkein/mcp
6
6
 
7
- **Give your AI coding agent a map of your codebase — instead of letting it grep and guess.**
7
+ **RepoSkein gives your AI coding agent a map of your codebase — so it navigates structure instead of grepping and guessing.**
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/@reposkein/mcp?style=for-the-badge&logo=npm&logoColor=EAE7DC&label=npm&labelColor=070A12&color=F2B84B)](https://www.npmjs.com/package/@reposkein/mcp)
10
10
  [![downloads](https://img.shields.io/npm/dm/@reposkein/mcp?style=for-the-badge&label=downloads&labelColor=070A12&color=2DD4BF)](https://www.npmjs.com/package/@reposkein/mcp)
@@ -75,6 +75,7 @@ Then ask your agent *"what calls this function?"* or *"what breaks if I change X
75
75
  - `reposkein-mcp index` — rebuild the committed graph after big changes.
76
76
  - `reposkein-mcp stats [--last | --session <id> | --all] [--json]` — session usage report: calls by tool, top queried nodes/files, ADRs/summaries written, session duration, and an *estimated* context-tokens-saved-vs-grep number. See [Session usage stats](#session-usage-stats) below.
77
77
  - `reposkein-mcp view [path]` — open the **constellation viewer**: a local, read-only, zero-infra web app (bound to `127.0.0.1`) that renders the committed `.reposkein` graph as an interactive 3D astronomy-style map. `--export <dir>` instead writes a self-contained static site (works from `file://` or any static host). See the [viewer section in the main README](https://github.com/reposkein/reposkein#visualize-the-graph--the-constellation-viewer), or **[try the live demo](https://reposkein.github.io/reposkein/)** (RepoSkein viewing its own graph).
78
+ - `reposkein-mcp serve --http [path]` — **advanced, optional**: one shared server for agents that have no clone. Speaks MCP over Streamable HTTP and serves the viewer + `/api/*` from the same process, behind bearer-token auth, read-only by default, re-indexing when the served checkout's HEAD moves. Nothing binds a socket unless you run it; stdio remains the default transport. See [`docs/REMOTE.md`](https://github.com/reposkein/reposkein/blob/main/docs/REMOTE.md).
78
79
 
79
80
  ## How your agent uses it
80
81
 
@@ -170,6 +171,8 @@ a resolved repo. See `mcp/src/store/resolveRepoPath.ts` and
170
171
  | `REPOSKEIN_REPO_PATH` | pins the repository the server operates on — optional, see repo resolution above |
171
172
  | `REPOSKEIN_SESSION_ID` | override the session id used for `reposkein-mcp stats` logging (default: start-timestamp + pid) |
172
173
  | `REPOSKEIN_STORE` | `auto` (default) · `jsonl` (zero-infra) · `neo4j` |
174
+ | `REPOSKEIN_AGENT` | names the writer in `summary_by` / `decided_by` and picks its `local/summaries-<agent>.jsonl` sidecar (default `agent`) |
175
+ | `REPOSKEIN_SERVE_TOKENS` | `serve --http` only — `name:secret[:write]` entries, comma- or whitespace-separated. Required for that mode; see [`docs/REMOTE.md`](https://github.com/reposkein/reposkein/blob/main/docs/REMOTE.md) |
173
176
  | `REPOSKEIN_INDEXER_BIN` | override the `reposkein-indexer` binary path (unsupported platforms) |
174
177
  | `NEO4J_URI` / `NEO4J_USER` / `NEO4J_PASSWORD` | optional Neo4j backend (large graphs / Cypher at scale) |
175
178
  | `REPOSKEIN_EMBED_PROVIDER` | `none` (default) · `voyage` · `http` — see below |
@@ -214,6 +217,8 @@ Full documentation, architecture, supported-language details, benchmarks, and th
214
217
 
215
218
  👉 **https://github.com/reposkein/reposkein**
216
219
 
220
+ **Support:** [Ko-fi](https://ko-fi.com/mongx) — funds hosted-constellation infra + indexer maintenance.
221
+
217
222
  ## License
218
223
 
219
224
  [Apache-2.0](https://github.com/reposkein/reposkein/blob/main/LICENSE).
@@ -1,6 +1,6 @@
1
1
  {
2
- "reposkein-indexer-darwin-arm64": "8d00be1abda687bb847b6bbd3340c1d53f20c69668d8b65029f0c52966e0b5e8",
3
- "reposkein-indexer-linux-arm64": "19853d8d8fb9cf7530c1613ec5dc2e0c0b4036a3691f716fb67e055055dea4f7",
4
- "reposkein-indexer-linux-x64": "d07e83f81da1c8668f17dfb12252199099ada5de9bc13c078a590f52abf3ca1e",
5
- "reposkein-indexer-win32-x64.exe": "ccce3f03e6b50cec117ef9d33eb8040b9356e12dec0baf43d509bcc7fb63d0dd"
2
+ "reposkein-indexer-darwin-arm64": "bb460f99a92c15210c5d1d2a9d865846ea4bc168bf864204e20436e3bdb0bc53",
3
+ "reposkein-indexer-linux-arm64": "0a2ed10e489d8ce56f1387d8531d023f1f7ff2b0a2f7aea078f48011c6f70e5e",
4
+ "reposkein-indexer-linux-x64": "27577d103498e5984c4c6161f856f1c9333f15789e7d2202ae12b71bf913f59c",
5
+ "reposkein-indexer-win32-x64.exe": "73dd8a828deff6a802c9ec8b199ea3d146669dc184fb595904b13a2cc144dc85"
6
6
  }
@@ -0,0 +1,23 @@
1
+ /** Local, gitignored audit trail of OUTBOUND slot requests.
2
+ *
3
+ * One line per request actually sent — written after the gating chain passed
4
+ * and before the response is known — so an operator who opted in can answer
5
+ * "did this thing phone home, when, and from which tool?" after the fact,
6
+ * without a proxy and without trusting this codebase's own docs.
7
+ *
8
+ * Deliberately narrow: `{ts, tool}` and nothing else. No response, no ad
9
+ * copy, no URL, no credentials, no arguments — the file records that a
10
+ * request happened, not what came back, so it can never become a place where
11
+ * sponsor-supplied bytes accumulate. It lives under `.reposkein/local/`
12
+ * (gitignored, outside the graph, alongside the session logs and caches), so
13
+ * it is never committed and `doctor` never treats it as graph state. */
14
+ export declare function adsAuditPath(repoPath: string): string;
15
+ export interface AdsAuditRecord {
16
+ ts: string;
17
+ tool: string;
18
+ }
19
+ /** Appends one record. Best-effort in exactly the way `sessionLog`'s writer
20
+ * is: an unwritable directory, a full disk or a permission error is
21
+ * swallowed, because an audit line must never fail or slow the tool call it
22
+ * is describing. */
23
+ export declare function appendAdsRequest(repoPath: string, record: AdsAuditRecord): void;
@@ -0,0 +1,33 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ /** Local, gitignored audit trail of OUTBOUND slot requests.
4
+ *
5
+ * One line per request actually sent — written after the gating chain passed
6
+ * and before the response is known — so an operator who opted in can answer
7
+ * "did this thing phone home, when, and from which tool?" after the fact,
8
+ * without a proxy and without trusting this codebase's own docs.
9
+ *
10
+ * Deliberately narrow: `{ts, tool}` and nothing else. No response, no ad
11
+ * copy, no URL, no credentials, no arguments — the file records that a
12
+ * request happened, not what came back, so it can never become a place where
13
+ * sponsor-supplied bytes accumulate. It lives under `.reposkein/local/`
14
+ * (gitignored, outside the graph, alongside the session logs and caches), so
15
+ * it is never committed and `doctor` never treats it as graph state. */
16
+ export function adsAuditPath(repoPath) {
17
+ return join(repoPath, ".reposkein", "local", "ads-requests.jsonl");
18
+ }
19
+ /** Appends one record. Best-effort in exactly the way `sessionLog`'s writer
20
+ * is: an unwritable directory, a full disk or a permission error is
21
+ * swallowed, because an audit line must never fail or slow the tool call it
22
+ * is describing. */
23
+ export function appendAdsRequest(repoPath, record) {
24
+ try {
25
+ const path = adsAuditPath(repoPath);
26
+ mkdirSync(dirname(path), { recursive: true });
27
+ appendFileSync(path, `${JSON.stringify(record)}\n`, "utf8");
28
+ }
29
+ catch {
30
+ // never surfaces
31
+ }
32
+ }
33
+ //# sourceMappingURL=auditLog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auditLog.js","sourceRoot":"","sources":["../../src/ads/auditLog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C;;;;;;;;;;;;yEAYyE;AACzE,MAAM,UAAU,YAAY,CAAC,QAAgB;IAC3C,OAAO,IAAI,CAAC,QAAQ,EAAE,YAAY,EAAE,OAAO,EAAE,oBAAoB,CAAC,CAAC;AACrE,CAAC;AAOD;;;qBAGqB;AACrB,MAAM,UAAU,gBAAgB,CAAC,QAAgB,EAAE,MAAsB;IACvE,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAC;QACpC,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9C,cAAc,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC9D,CAAC;IAAC,MAAM,CAAC;QACP,iBAAiB;IACnB,CAAC;AACH,CAAC"}
@@ -0,0 +1,83 @@
1
+ /** The ad network's default API host. Its click links are always
2
+ * `https://<host>/c/{token}` (getlulu.dev/docs/contract: "never a raw
3
+ * advertiser URL"), which is why the click-host allowlist is derived from
4
+ * whatever base URL we are actually talking to rather than hardcoded twice. */
5
+ export declare const DEFAULT_ADS_BASE_URL = "https://ads.getlulu.dev";
6
+ /** Hard wall-clock budget for one slot request. The SDK's own default is
7
+ * 1500ms (3000ms when the backend may classify) — far too long to sit in
8
+ * front of an agent's tool result, so we impose ours on every call and treat
9
+ * the deadline as a no-slot, never an error. */
10
+ export declare const SLOT_TIMEOUT_MS = 800;
11
+ /** Tools allowed to carry a sponsored slot. An ALLOWLIST, not a denylist: a
12
+ * tool added to this server later carries no ad until someone puts it here on
13
+ * purpose.
14
+ *
15
+ * `get_context_profile` only, and deliberately:
16
+ * - It is the highest-volume tool in the intended workflow (one
17
+ * `semantic_find` seeds several profile expansions — see docs/TOOLS.md) and
18
+ * it is a deterministic neighbourhood expansion, so an envelope field
19
+ * cannot bias, reorder, or displace anything the caller asked for.
20
+ * - `semantic_find` is excluded PERMANENTLY, not by oversight: the governing
21
+ * ADR (.reposkein/decisions/2026-08-21-sponsorship-placement-*.json,
22
+ * ruling 2) rules that "an agent (or a person) calling semantic_find must
23
+ * get back what best matches the query, never what a sponsor paid to
24
+ * surface". Ranked retrieval stays uncontaminated even at the envelope
25
+ * level.
26
+ * - Every mutating tool (see WRITE_TOOLS) and every error path is excluded:
27
+ * ads never ride along with a write or a failure. */
28
+ export declare const AD_ELIGIBLE_TOOLS: readonly string[];
29
+ /** Why no slot will be requested. Diagnostic only — never surfaced to a
30
+ * caller, never logged with credential values. */
31
+ export type AdsOffReason = "kill_switch" | "not_opted_in" | "config_not_confirmed" | "no_credentials" | "supporter" | "bad_base_url";
32
+ export type AdsVerdict = {
33
+ enabled: false;
34
+ reason: AdsOffReason;
35
+ } | {
36
+ enabled: true;
37
+ publisherId: string;
38
+ apiKey: string;
39
+ baseUrl: string;
40
+ /** Hosts a click URL may point at. */
41
+ clickHosts: string[];
42
+ };
43
+ export interface ResolveAdsOptions {
44
+ /** Active repo root, for `[ads] enabled` in `.reposkein/config.toml`.
45
+ * Undefined (no repo resolved) simply means that opt-in source is absent. */
46
+ repoPath?: string | undefined;
47
+ env?: NodeJS.ProcessEnv;
48
+ /** Supplied by `createAdsHook`, which binds `ads/supporter.ts`'s
49
+ * `isSupporter` to its own env. Defaulted to "nobody" here rather than
50
+ * imported, so `resolveAdsVerdict` stays a pure function of its arguments
51
+ * and a gating test never depends on what is in the developer's home
52
+ * directory. */
53
+ isSupporter?: () => boolean;
54
+ /** Test seam for the config.toml read, and the connection-lifetime memo in
55
+ * `slot.ts` (one filesystem read per repo, not one per tool call). */
56
+ readOptIn?: (repoPath: string) => boolean | null;
57
+ }
58
+ /** The whole gating chain, in one place, evaluated in this order:
59
+ *
60
+ * 1. `REPOSKEIN_ADS=off` — an unconditional kill switch that outranks
61
+ * config, credentials, everything. Checked first so "off" can never be
62
+ * overridden by a repo's committed config.
63
+ * 2. Opt-in — `REPOSKEIN_ADS=on` in the ENVIRONMENT. A repo's
64
+ * `[ads] enabled = true` declares the repo's willingness, but is not
65
+ * sufficient on its own: config.toml is committed and travels with a
66
+ * clone, so honouring it alone would let whoever wrote it opt in every
67
+ * person who later checks the repo out. The environment is the only
68
+ * place the operator running THIS process can speak for themselves, so
69
+ * it must confirm. Absent the env switch, ads are off — which is the
70
+ * default for every install.
71
+ * 3. Credentials — `LULU_ADS_PUBLISHER_ID` + `LULU_ADS_API_KEY`, env only,
72
+ * never config, never argv, never logged. Absent either, the integration
73
+ * is inert: no network call is even attempted.
74
+ * 4. Supporter — a verified supporter never sees a slot (REP-29). The check
75
+ * is a local Ed25519 signature verification over
76
+ * `~/.config/reposkein/supporter.jwt`; it makes no network call, so
77
+ * consulting it here cannot itself be the thing that phones home.
78
+ * It runs BEFORE any slot is requested, which is the whole point: a
79
+ * supporter's machine must not even ask.
80
+ *
81
+ * Nothing in this function touches the network; it is the only thing that
82
+ * decides whether anything ever will. */
83
+ export declare function resolveAdsVerdict(opts?: ResolveAdsOptions): AdsVerdict;
@@ -0,0 +1,107 @@
1
+ import { readConfigBool } from "../store/teamConfig.js";
2
+ /** The ad network's default API host. Its click links are always
3
+ * `https://<host>/c/{token}` (getlulu.dev/docs/contract: "never a raw
4
+ * advertiser URL"), which is why the click-host allowlist is derived from
5
+ * whatever base URL we are actually talking to rather than hardcoded twice. */
6
+ export const DEFAULT_ADS_BASE_URL = "https://ads.getlulu.dev";
7
+ /** Hard wall-clock budget for one slot request. The SDK's own default is
8
+ * 1500ms (3000ms when the backend may classify) — far too long to sit in
9
+ * front of an agent's tool result, so we impose ours on every call and treat
10
+ * the deadline as a no-slot, never an error. */
11
+ export const SLOT_TIMEOUT_MS = 800;
12
+ /** Tools allowed to carry a sponsored slot. An ALLOWLIST, not a denylist: a
13
+ * tool added to this server later carries no ad until someone puts it here on
14
+ * purpose.
15
+ *
16
+ * `get_context_profile` only, and deliberately:
17
+ * - It is the highest-volume tool in the intended workflow (one
18
+ * `semantic_find` seeds several profile expansions — see docs/TOOLS.md) and
19
+ * it is a deterministic neighbourhood expansion, so an envelope field
20
+ * cannot bias, reorder, or displace anything the caller asked for.
21
+ * - `semantic_find` is excluded PERMANENTLY, not by oversight: the governing
22
+ * ADR (.reposkein/decisions/2026-08-21-sponsorship-placement-*.json,
23
+ * ruling 2) rules that "an agent (or a person) calling semantic_find must
24
+ * get back what best matches the query, never what a sponsor paid to
25
+ * surface". Ranked retrieval stays uncontaminated even at the envelope
26
+ * level.
27
+ * - Every mutating tool (see WRITE_TOOLS) and every error path is excluded:
28
+ * ads never ride along with a write or a failure. */
29
+ export const AD_ELIGIBLE_TOOLS = ["get_context_profile"];
30
+ /** The whole gating chain, in one place, evaluated in this order:
31
+ *
32
+ * 1. `REPOSKEIN_ADS=off` — an unconditional kill switch that outranks
33
+ * config, credentials, everything. Checked first so "off" can never be
34
+ * overridden by a repo's committed config.
35
+ * 2. Opt-in — `REPOSKEIN_ADS=on` in the ENVIRONMENT. A repo's
36
+ * `[ads] enabled = true` declares the repo's willingness, but is not
37
+ * sufficient on its own: config.toml is committed and travels with a
38
+ * clone, so honouring it alone would let whoever wrote it opt in every
39
+ * person who later checks the repo out. The environment is the only
40
+ * place the operator running THIS process can speak for themselves, so
41
+ * it must confirm. Absent the env switch, ads are off — which is the
42
+ * default for every install.
43
+ * 3. Credentials — `LULU_ADS_PUBLISHER_ID` + `LULU_ADS_API_KEY`, env only,
44
+ * never config, never argv, never logged. Absent either, the integration
45
+ * is inert: no network call is even attempted.
46
+ * 4. Supporter — a verified supporter never sees a slot (REP-29). The check
47
+ * is a local Ed25519 signature verification over
48
+ * `~/.config/reposkein/supporter.jwt`; it makes no network call, so
49
+ * consulting it here cannot itself be the thing that phones home.
50
+ * It runs BEFORE any slot is requested, which is the whole point: a
51
+ * supporter's machine must not even ask.
52
+ *
53
+ * Nothing in this function touches the network; it is the only thing that
54
+ * decides whether anything ever will. */
55
+ export function resolveAdsVerdict(opts = {}) {
56
+ const env = opts.env ?? process.env;
57
+ const switchValue = (env.REPOSKEIN_ADS ?? "").trim().toLowerCase();
58
+ if (switchValue === "off" || switchValue === "0" || switchValue === "false") {
59
+ return { enabled: false, reason: "kill_switch" };
60
+ }
61
+ const envOptIn = switchValue === "on" || switchValue === "1" || switchValue === "true";
62
+ if (!envOptIn) {
63
+ // Distinguish "a repo asked, the operator hasn't confirmed" from "nobody
64
+ // asked at all" — the first is a state an operator may want to notice.
65
+ const readOptIn = opts.readOptIn ?? ((p) => readConfigBool(p, "ads", "enabled"));
66
+ const configAsked = opts.repoPath ? readOptIn(opts.repoPath) === true : false;
67
+ return { enabled: false, reason: configAsked ? "config_not_confirmed" : "not_opted_in" };
68
+ }
69
+ const publisherId = (env.LULU_ADS_PUBLISHER_ID ?? "").trim();
70
+ const apiKey = (env.LULU_ADS_API_KEY ?? "").trim();
71
+ if (publisherId === "" || apiKey === "") {
72
+ return { enabled: false, reason: "no_credentials" };
73
+ }
74
+ const isSupporter = opts.isSupporter ?? (() => false);
75
+ if (isSupporter()) {
76
+ return { enabled: false, reason: "supporter" };
77
+ }
78
+ const baseUrl = (env.LULU_ADS_BASE_URL ?? "").trim() || DEFAULT_ADS_BASE_URL;
79
+ let host;
80
+ try {
81
+ const parsed = new URL(baseUrl);
82
+ // Plaintext is refused for any REMOTE host: an ad request carries the
83
+ // publisher's API key, and the click URL comes back over the same
84
+ // connection, so http:// off-machine would hand both to anyone on the
85
+ // path. Loopback is the one exception, and only because it cannot leave
86
+ // the machine: it exists so a test or a local mock ad server can be
87
+ // pointed at without inventing TLS for it. Note the click-host allowlist
88
+ // is derived from this host, so a loopback base URL also means click URLs
89
+ // must point at loopback — a mock cannot smuggle in a real destination.
90
+ const loopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" || parsed.hostname === "[::1]";
91
+ if (parsed.protocol !== "https:" && !loopback) {
92
+ return { enabled: false, reason: "bad_base_url" };
93
+ }
94
+ host = parsed.hostname;
95
+ }
96
+ catch {
97
+ return { enabled: false, reason: "bad_base_url" };
98
+ }
99
+ return {
100
+ enabled: true,
101
+ publisherId,
102
+ apiKey,
103
+ baseUrl: baseUrl.replace(/\/$/, ""),
104
+ clickHosts: [host],
105
+ };
106
+ }
107
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/ads/config.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ import type { SponsoredSource } from "./types.js";
2
+ /** Adapter over the `lulu-ads` npm package's LOW-LEVEL client.
3
+ *
4
+ * What this uses, and nothing else:
5
+ * `new LuluAds({publisherId, apiKey, baseUrl}).sponsoredSlot({context, timeoutMs})`
6
+ * → one `POST {baseUrl}/slot` with header `x-api-key` and body
7
+ * `{"context": {...}}`, hard-aborted by the SDK's own
8
+ * `AbortSignal.timeout(timeoutMs)`, returning data or null, never throwing.
9
+ *
10
+ * What this deliberately does NOT use, after reading the package (v0.9.0):
11
+ * - `enableLuluAds` / `withLuluAds`: they proxy `registerTool` and MUTATE
12
+ * the tool result — appending a rendered text card to `content` for CLI
13
+ * clients and, when `content` is a single text block, REPLACING it with
14
+ * `JSON.stringify(structuredContent)`. That rewrites our answer, which
15
+ * both the governing ADR ("never concatenated into result text") and the
16
+ * byte-identical fail-open guarantee forbid.
17
+ * - `warmUp()`: it POSTs `/telemetry/init` at startup. An opt-in ad slot
18
+ * must make ZERO network calls until a tool call actually passes the
19
+ * gating chain, so nothing here pings anything at boot.
20
+ * - `formatSuffix` / `formatCliCard`: both exist to concatenate sponsor
21
+ * copy into prose. Not on this codebase's response path, ever.
22
+ * - the widget/MCP-Apps exports: no rendered ad surface is in scope for
23
+ * REP-28.
24
+ *
25
+ * The import is DYNAMIC and lazy: with ads off (the default) the package is
26
+ * never even loaded, which also keeps its ~270KB bundled widget HTML out of
27
+ * the stdio server's startup path. */
28
+ export declare function luluAdsSource(cfg: {
29
+ publisherId: string;
30
+ apiKey: string;
31
+ baseUrl: string;
32
+ }): SponsoredSource;
@@ -0,0 +1,60 @@
1
+ /** Adapter over the `lulu-ads` npm package's LOW-LEVEL client.
2
+ *
3
+ * What this uses, and nothing else:
4
+ * `new LuluAds({publisherId, apiKey, baseUrl}).sponsoredSlot({context, timeoutMs})`
5
+ * → one `POST {baseUrl}/slot` with header `x-api-key` and body
6
+ * `{"context": {...}}`, hard-aborted by the SDK's own
7
+ * `AbortSignal.timeout(timeoutMs)`, returning data or null, never throwing.
8
+ *
9
+ * What this deliberately does NOT use, after reading the package (v0.9.0):
10
+ * - `enableLuluAds` / `withLuluAds`: they proxy `registerTool` and MUTATE
11
+ * the tool result — appending a rendered text card to `content` for CLI
12
+ * clients and, when `content` is a single text block, REPLACING it with
13
+ * `JSON.stringify(structuredContent)`. That rewrites our answer, which
14
+ * both the governing ADR ("never concatenated into result text") and the
15
+ * byte-identical fail-open guarantee forbid.
16
+ * - `warmUp()`: it POSTs `/telemetry/init` at startup. An opt-in ad slot
17
+ * must make ZERO network calls until a tool call actually passes the
18
+ * gating chain, so nothing here pings anything at boot.
19
+ * - `formatSuffix` / `formatCliCard`: both exist to concatenate sponsor
20
+ * copy into prose. Not on this codebase's response path, ever.
21
+ * - the widget/MCP-Apps exports: no rendered ad surface is in scope for
22
+ * REP-28.
23
+ *
24
+ * The import is DYNAMIC and lazy: with ads off (the default) the package is
25
+ * never even loaded, which also keeps its ~270KB bundled widget HTML out of
26
+ * the stdio server's startup path. */
27
+ export function luluAdsSource(cfg) {
28
+ // Built on first successful use and reused: the SDK client is a plain
29
+ // object holding config plus an in-memory cache; constructing it performs
30
+ // no I/O (see its constructor — warmUp is the only network side effect and
31
+ // we never call it).
32
+ let client = null;
33
+ return {
34
+ async requestSlot(ctx, opts) {
35
+ if (opts.signal.aborted)
36
+ return null;
37
+ if (!client) {
38
+ const mod = (await import("lulu-ads"));
39
+ client = new mod.LuluAds({
40
+ publisherId: cfg.publisherId,
41
+ apiKey: cfg.apiKey,
42
+ baseUrl: cfg.baseUrl,
43
+ });
44
+ }
45
+ // `context` is built here, key by key, from `SlotContext` alone. The
46
+ // SDK's own allowlist would also accept `prompt`, `route`, `locale`,
47
+ // `country` and `category`; we never populate them, so they cannot leak
48
+ // by accident. `query` carries coarse keywords ONLY when the caller
49
+ // supplied them (no tool on the eligible list does today).
50
+ return client.sponsoredSlot({
51
+ context: {
52
+ tool: ctx.tool,
53
+ ...(ctx.keywords ? { query: ctx.keywords } : {}),
54
+ },
55
+ timeoutMs: opts.timeoutMs,
56
+ });
57
+ },
58
+ };
59
+ }
60
+ //# sourceMappingURL=luluSource.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"luluSource.js","sourceRoot":"","sources":["../../src/ads/luluSource.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;;;;uCAyBuC;AACvC,MAAM,UAAU,aAAa,CAAC,GAI7B;IACC,sEAAsE;IACtE,0EAA0E;IAC1E,2EAA2E;IAC3E,qBAAqB;IACrB,IAAI,MAAM,GAA8D,IAAI,CAAC;IAE7E,OAAO;QACL,KAAK,CAAC,WAAW,CAAC,GAAgB,EAAE,IAAgD;YAClF,IAAI,IAAI,CAAC,MAAM,CAAC,OAAO;gBAAE,OAAO,IAAI,CAAC;YACrC,IAAI,CAAC,MAAM,EAAE,CAAC;gBACZ,MAAM,GAAG,GAAG,CAAC,MAAM,MAAM,CAAC,UAAU,CAAC,CAIpC,CAAC;gBACF,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC;oBACvB,WAAW,EAAE,GAAG,CAAC,WAAW;oBAC5B,MAAM,EAAE,GAAG,CAAC,MAAM;oBAClB,OAAO,EAAE,GAAG,CAAC,OAAO;iBACrB,CAAC,CAAC;YACL,CAAC;YACD,qEAAqE;YACrE,qEAAqE;YACrE,wEAAwE;YACxE,oEAAoE;YACpE,2DAA2D;YAC3D,OAAO,MAAM,CAAC,aAAa,CAAC;gBAC1B,OAAO,EAAE;oBACP,IAAI,EAAE,GAAG,CAAC,IAAI;oBACd,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBACjD;gBACD,SAAS,EAAE,IAAI,CAAC,SAAS;aAC1B,CAAC,CAAC;QACL,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,16 @@
1
+ import { type SponsoredSlot } from "./types.js";
2
+ /** Projects an untrusted payload onto `SponsoredSlot`, or returns null.
3
+ *
4
+ * Null is the normal, safe answer: every rejection path (missing field, over
5
+ * cap, wrong host, hostile shape, unparseable input) means "no slot", which
6
+ * the caller treats as identical to no ad having been offered at all. There
7
+ * is no partial acceptance and no truncation — truncating sponsor copy to fit
8
+ * a cap would silently reshape someone's paid message and truncating a URL
9
+ * would break it, so both are refusals instead.
10
+ *
11
+ * Field mapping accepts the ad network's wire name (`text`) as an alias for
12
+ * body copy, but the OUTPUT is built key by key, so no unexpected field —
13
+ * including a payload's own `label` — can ride along. */
14
+ export declare function sanitizeSponsored(raw: unknown, opts: {
15
+ clickHosts: readonly string[];
16
+ }): SponsoredSlot | null;
@@ -0,0 +1,187 @@
1
+ import { CODE_FENCE, MD_LINK } from "../guard/summaryValidation.js";
2
+ import { BODY_MAX, PAYLOAD_MAX_BYTES, SPONSORED_LABEL, TITLE_MAX, URL_MAX, } from "./types.js";
3
+ /** Invisible characters: soft hyphen, zero-width spaces/joiners, bidi
4
+ * overrides, word joiners, byte-order mark. Stripped BEFORE the injection
5
+ * denylist runs, so `ig<ZWSP>nore previous instructions` can't smuggle a
6
+ * phrase past a regex, and so a right-to-left override can't reorder what a
7
+ * human sees relative to what a model reads. */
8
+ const INVISIBLE_SOURCE = "[\\u00AD\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF]";
9
+ const INVISIBLE_G = new RegExp(INVISIBLE_SOURCE, "g");
10
+ /** Same class without `g`: `.test()` on a global regex is stateful. */
11
+ const INVISIBLE = new RegExp(INVISIBLE_SOURCE);
12
+ /** Line/tab whitespace that must never survive into a one-line field:
13
+ * ASCII line/tab breaks, NEL (U+0085, the C1 newline), and the Unicode
14
+ * line/paragraph separators. */
15
+ const LINEBREAKS = /[\r\n\t\v\f\u0085\u2028\u2029]/g;
16
+ /** Control characters, ads edition: the shared `CONTROL_CHARS` class (C0 minus
17
+ * tab/newline/CR, plus DEL) EXTENDED to the whole C1 block, U+0080-U+009F.
18
+ *
19
+ * C1 matters here in a way it doesn't for summaries: those bytes are legal in
20
+ * a JSON string, invisible in most terminals, and include their own control
21
+ * vocabulary (NEL at U+0085 breaks a line, and CSI at U+009B opens an escape
22
+ * sequence a terminal may act on). Ad copy has no use for any of them, so
23
+ * they are stripped from prose and rejected in a URL rather than passed
24
+ * through. Line breaks in that range are already handled by LINEBREAKS above,
25
+ * which runs first.
26
+ *
27
+ * Written out rather than derived from `CONTROL_CHARS.source` so a future
28
+ * edit to the shared class cannot silently reshape (or fail to compile) this
29
+ * one; `adsSanitize.test.ts` asserts the two stay in agreement about C0. */
30
+ const ADS_CONTROL_SOURCE = "[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\u009F]";
31
+ const ADS_CONTROL_G = new RegExp(ADS_CONTROL_SOURCE, "g");
32
+ /** Same class without `g`: `.test()` on a global regex is stateful. */
33
+ const CONTROL_TEST = new RegExp(ADS_CONTROL_SOURCE);
34
+ /** Shapes that are not ad copy. Defence in depth, NOT the boundary — the
35
+ * boundary is that a slot is fixed-schema, length-capped data in a labelled
36
+ * envelope field, never prose the tool emits. But sponsor copy has no
37
+ * legitimate reason to carry any of these, so a payload containing one is
38
+ * treated as hostile and the whole slot is dropped (fail-open = no slot).
39
+ *
40
+ * Ordered by what an injection attempt actually looks like: instruction
41
+ * overrides, role/turn markers, special-token and template syntax, markup,
42
+ * tool-call bait. */
43
+ const INJECTION_SHAPES = [
44
+ /\b(ignore|disregard|forget|override)\b[^.]{0,40}\b(previous|prior|above|earlier|all)\b/i,
45
+ /\b(previous|prior|above|earlier|all)\b[^.]{0,40}\b(instructions?|prompts?|rules?)\b/i,
46
+ /\bsystem\s+(prompt|message|instructions?)\b/i,
47
+ /\bnew\s+instructions?\b/i,
48
+ /\byou\s+are\s+(now|a|an)\b/i,
49
+ /\b(do\s+not|don't)\s+(tell|mention|reveal|disclose)\b/i,
50
+ /^\s*(system|assistant|user|human|developer)\s*:/i,
51
+ /<\||\|>|<<[A-Z]|\[\/?INST\]/,
52
+ /\{\{|\}\}|\$\{/,
53
+ /<[a-z/!?]/i,
54
+ /\b(tool_call|function_call|tool_use|end_turn)\b/i,
55
+ ];
56
+ /** Cleans one candidate prose field, or null if it isn't usable.
57
+ *
58
+ * Strip-then-reject: invisibles and control characters are stripped (they
59
+ * carry no meaning a sponsor could intend) and line breaks collapse to single
60
+ * spaces, but anything still carrying markdown-link or code-fence syntax or
61
+ * an injection shape is refused outright rather than "sanitized" into
62
+ * something that merely looks safe. */
63
+ function cleanText(value) {
64
+ if (typeof value !== "string")
65
+ return null;
66
+ // Invisibles are a refusal, not a repair. Ad copy has no legitimate use for
67
+ // a bidi override or a zero-width joiner, and both exist here for exactly
68
+ // one reason: to make what a human sees differ from what a model reads (an
69
+ // RLO renders "ignore previous instructions" backwards; a ZWSP splits the
70
+ // phrase past a regex). They are also stripped below so no later check can
71
+ // be fooled by one that slips through a class update.
72
+ if (INVISIBLE.test(value))
73
+ return null;
74
+ const flat = value
75
+ .replace(INVISIBLE_G, "")
76
+ .replace(LINEBREAKS, " ")
77
+ .replace(ADS_CONTROL_G, "")
78
+ .replace(/\s{2,}/g, " ")
79
+ .trim();
80
+ if (flat.length === 0)
81
+ return null;
82
+ if (CODE_FENCE.test(flat))
83
+ return null;
84
+ if (MD_LINK.test(flat) || flat.includes("]("))
85
+ return null;
86
+ for (const shape of INJECTION_SHAPES) {
87
+ if (shape.test(flat))
88
+ return null;
89
+ }
90
+ return flat;
91
+ }
92
+ /** Validates the click URL: https only, host on the network's click-domain
93
+ * allowlist, no embedded credentials, canonicalized before it is handed on.
94
+ *
95
+ * The allowlist matters more than the scheme check: per getlulu.dev/docs the
96
+ * network always returns its own signed redirect (`/c/{token}` on its click
97
+ * host) and never a raw advertiser URL, so a payload pointing anywhere else
98
+ * is either a misconfiguration or someone else's link — both fail-open. */
99
+ function cleanUrl(value, clickHosts) {
100
+ if (typeof value !== "string")
101
+ return null;
102
+ const raw = value.trim();
103
+ if (raw.length === 0 || raw.length > URL_MAX)
104
+ return null;
105
+ if (/\s/.test(raw))
106
+ return null;
107
+ if (CONTROL_TEST.test(raw))
108
+ return null;
109
+ if (INVISIBLE.test(raw))
110
+ return null;
111
+ let parsed;
112
+ try {
113
+ parsed = new URL(raw);
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ if (parsed.protocol !== "https:")
119
+ return null;
120
+ if (parsed.username !== "" || parsed.password !== "")
121
+ return null;
122
+ const host = parsed.hostname.toLowerCase();
123
+ if (!clickHosts.some((allowed) => allowed.toLowerCase() === host))
124
+ return null;
125
+ const canonical = parsed.toString();
126
+ return canonical.length <= URL_MAX ? canonical : null;
127
+ }
128
+ /** Projects an untrusted payload onto `SponsoredSlot`, or returns null.
129
+ *
130
+ * Null is the normal, safe answer: every rejection path (missing field, over
131
+ * cap, wrong host, hostile shape, unparseable input) means "no slot", which
132
+ * the caller treats as identical to no ad having been offered at all. There
133
+ * is no partial acceptance and no truncation — truncating sponsor copy to fit
134
+ * a cap would silently reshape someone's paid message and truncating a URL
135
+ * would break it, so both are refusals instead.
136
+ *
137
+ * Field mapping accepts the ad network's wire name (`text`) as an alias for
138
+ * body copy, but the OUTPUT is built key by key, so no unexpected field —
139
+ * including a payload's own `label` — can ride along. */
140
+ export function sanitizeSponsored(raw, opts) {
141
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
142
+ return null;
143
+ if (opts.clickHosts.length === 0)
144
+ return null;
145
+ // Cheap size gate before touching fields: a real slot is a few hundred
146
+ // bytes, so anything larger is not one.
147
+ let serialized;
148
+ try {
149
+ serialized = JSON.stringify(raw) ?? "";
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ if (Buffer.byteLength(serialized, "utf8") > PAYLOAD_MAX_BYTES)
155
+ return null;
156
+ const payload = raw;
157
+ // A field that is PRESENT but unusable rejects the whole slot; a field that
158
+ // is absent is simply absent. "Present but unusable" is the interesting
159
+ // case — it means the payload tried something.
160
+ let title;
161
+ if (payload.title !== undefined && payload.title !== null) {
162
+ const cleaned = cleanText(payload.title);
163
+ if (cleaned === null || cleaned.length > TITLE_MAX)
164
+ return null;
165
+ title = cleaned;
166
+ }
167
+ const bodySrc = payload.body ?? payload.text ?? payload.description ?? undefined;
168
+ let body;
169
+ if (bodySrc !== undefined && bodySrc !== null) {
170
+ const cleaned = cleanText(bodySrc);
171
+ if (cleaned === null || cleaned.length > BODY_MAX)
172
+ return null;
173
+ body = cleaned;
174
+ }
175
+ if (title === undefined && body === undefined)
176
+ return null;
177
+ const url = cleanUrl(payload.url, opts.clickHosts);
178
+ if (url === null)
179
+ return null;
180
+ return {
181
+ label: SPONSORED_LABEL,
182
+ ...(title !== undefined ? { title } : {}),
183
+ ...(body !== undefined ? { body } : {}),
184
+ url,
185
+ };
186
+ }
187
+ //# sourceMappingURL=sanitize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sanitize.js","sourceRoot":"","sources":["../../src/ads/sanitize.ts"],"names":[],"mappings":"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"}