@whittlelabs/sifter 0.2.0 → 0.3.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 (3) hide show
  1. package/bin.js +463 -76
  2. package/bin.js.map +4 -4
  3. package/package.json +1 -1
package/bin.js CHANGED
@@ -3514,8 +3514,9 @@ var require_init = __commonJS({
3514
3514
  var store_1 = require_store();
3515
3515
  var spend_store_1 = require_spend_store();
3516
3516
  function buildInitCommand(brand) {
3517
- return new commander_1.Command("init").description(`Pair this ${brand.product.title} with Keep and seed local state`).option("--pairing-code <code>", "Pairing code from the product UI").option("--keep-url <url>", "Override the Keep API URL from BrandConfig").option("--jobs-url <url>", "Override the Jobs API URL from BrandConfig").action(async (options) => {
3517
+ return new commander_1.Command("init").description(`Pair this ${brand.product.title} with Keep and seed local state`).option("--pairing-code <code>", "Pairing code from the product UI").option("--keep-url <url>", "Override the Keep API URL from BrandConfig").option("--jobs-url <url>", "Override the Jobs API URL from BrandConfig").option("--executor <type>", `Executor to write into ${brand.product.id}.yaml (e.g. claude-code). Must be in the brand's executor allowlist.`).option("--poll-interval <ms>", "Run-loop poll interval in milliseconds").option("--daily-cap <tokens>", "Daily token cap; overrides the brand default").option("--monthly-cap <tokens>", "Monthly token cap; overrides the brand default").option("--cwd <path>", "Working directory for executors that run a local subprocess (e.g. claude-code)").action(async (options) => {
3518
3518
  try {
3519
+ const setupOptions = parseSetupOptions(brand, options);
3519
3520
  const effectiveBrand = options.keepUrl || options.jobsUrl ? {
3520
3521
  ...brand,
3521
3522
  keepRegistration: {
@@ -3531,7 +3532,8 @@ var require_init = __commonJS({
3531
3532
  await step.run({
3532
3533
  brand: effectiveBrand,
3533
3534
  configDir: paths.configDir,
3534
- log: (line) => console.log(line)
3535
+ log: (line) => console.log(line),
3536
+ setupOptions
3535
3537
  });
3536
3538
  }
3537
3539
  }
@@ -3543,8 +3545,8 @@ var require_init = __commonJS({
3543
3545
  await store.write(pairing);
3544
3546
  const spendStore = new spend_store_1.SpendStore(paths.spendStateFile);
3545
3547
  const seedCaps = {
3546
- dailyTokens: effectiveBrand.defaultCaps.dailyTokens,
3547
- monthlyTokens: effectiveBrand.defaultCaps.monthlyTokens,
3548
+ dailyTokens: setupOptions.dailyCap ?? effectiveBrand.defaultCaps.dailyTokens,
3549
+ monthlyTokens: setupOptions.monthlyCap ?? effectiveBrand.defaultCaps.monthlyTokens,
3548
3550
  dailyUsd: effectiveBrand.defaultCaps.dailyUsd,
3549
3551
  monthlyUsd: effectiveBrand.defaultCaps.monthlyUsd
3550
3552
  };
@@ -3554,7 +3556,8 @@ var require_init = __commonJS({
3554
3556
  await step.run({
3555
3557
  brand: effectiveBrand,
3556
3558
  configDir: paths.configDir,
3557
- log: (line) => console.log(line)
3559
+ log: (line) => console.log(line),
3560
+ setupOptions
3558
3561
  });
3559
3562
  }
3560
3563
  }
@@ -3572,6 +3575,44 @@ var require_init = __commonJS({
3572
3575
  }
3573
3576
  });
3574
3577
  }
3578
+ function parseSetupOptions(brand, raw) {
3579
+ const out = {};
3580
+ if (raw.executor !== void 0) {
3581
+ const allowed = brand.executorAllowlist;
3582
+ const wildcard = allowed.includes("*");
3583
+ if (!wildcard && !allowed.includes(raw.executor)) {
3584
+ throw new Error(`--executor=${raw.executor} is not in this brand's allowlist (${allowed.join(", ")})`);
3585
+ }
3586
+ out.executor = raw.executor;
3587
+ }
3588
+ if (raw.pollInterval !== void 0) {
3589
+ out.pollIntervalMs = requirePositiveInt("--poll-interval", raw.pollInterval);
3590
+ }
3591
+ if (raw.dailyCap !== void 0) {
3592
+ out.dailyCap = requireNonNegativeInt("--daily-cap", raw.dailyCap);
3593
+ }
3594
+ if (raw.monthlyCap !== void 0) {
3595
+ out.monthlyCap = requireNonNegativeInt("--monthly-cap", raw.monthlyCap);
3596
+ }
3597
+ if (raw.cwd !== void 0) {
3598
+ out.cwd = raw.cwd;
3599
+ }
3600
+ return out;
3601
+ }
3602
+ function requirePositiveInt(flag, value) {
3603
+ const n = Number(value);
3604
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
3605
+ throw new Error(`${flag} must be a positive integer, got "${value}"`);
3606
+ }
3607
+ return n;
3608
+ }
3609
+ function requireNonNegativeInt(flag, value) {
3610
+ const n = Number(value);
3611
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
3612
+ throw new Error(`${flag} must be a non-negative integer, got "${value}"`);
3613
+ }
3614
+ return n;
3615
+ }
3575
3616
  }
3576
3617
  });
3577
3618
 
@@ -15068,7 +15109,7 @@ var require_schema4 = __commonJS({
15068
15109
  message: 'Each pool must specify at least one of "id" or "name"'
15069
15110
  });
15070
15111
  var executorSchema = zod_1.z.object({
15071
- type: zod_1.z.enum(["claude-code", "http-api", "webhook", "custom-script"])
15112
+ type: zod_1.z.enum(["claude-code", "http-api", "anthropic-api", "webhook", "custom-script"])
15072
15113
  }).passthrough();
15073
15114
  var shuttleConfigSchema = zod_1.z.object({
15074
15115
  auth: zod_1.z.object({
@@ -16249,6 +16290,32 @@ var require_client2 = __commonJS({
16249
16290
  ttlSeconds: request.ttlSeconds
16250
16291
  });
16251
16292
  }
16293
+ // ── Managed Sifter ──────────────────────────────────────────────
16294
+ /**
16295
+ * Reveal a hosted-Sifter customer's plaintext Anthropic API key for
16296
+ * one dispatch. Service-token only; requires the
16297
+ * `keep:managed-sifter-keys:reveal` scope, granted only to the
16298
+ * `whittle-hosted-sifter` service identity.
16299
+ *
16300
+ * Callers must use the returned key for a single Anthropic request
16301
+ * and drop it from memory afterwards. Keep stamps `last_used_at` on
16302
+ * every call and is the audit-of-record for who decrypted whose key
16303
+ * when.
16304
+ */
16305
+ async revealManagedSifterKey(userId) {
16306
+ return this.request("POST", `/api/managed-sifters/${encodeURIComponent(userId)}/reveal-key`);
16307
+ }
16308
+ /**
16309
+ * Read a user's hosted-Sifter opt-in status. Returns `{enabled:
16310
+ * false}` for users who never opted in (no row) and users who
16311
+ * opted in then disabled. Service-to-service only; requires the
16312
+ * `keep:managed-sifter-keys:read-status` scope, which is distinct
16313
+ * from `:reveal` so a service identity can hold one without the
16314
+ * other.
16315
+ */
16316
+ async getManagedSifterStatus(userId) {
16317
+ return this.request("GET", `/api/users/${encodeURIComponent(userId)}/managed-sifter-status`);
16318
+ }
16252
16319
  // ── HTTP ────────────────────────────────────────────────────────
16253
16320
  async request(method, path, body) {
16254
16321
  const url = `${this.baseUrl}${path}`;
@@ -16342,12 +16409,75 @@ var require_service_token = __commonJS({
16342
16409
  }
16343
16410
  });
16344
16411
 
16412
+ // ../../packages/keep/dist/agent-token.js
16413
+ var require_agent_token = __commonJS({
16414
+ "../../packages/keep/dist/agent-token.js"(exports2) {
16415
+ "use strict";
16416
+ Object.defineProperty(exports2, "__esModule", { value: true });
16417
+ exports2.AgentTokenManager = void 0;
16418
+ var AgentTokenManager = class {
16419
+ token = null;
16420
+ expiresAt = 0;
16421
+ pendingExchange = null;
16422
+ keepApiUrl;
16423
+ clientId;
16424
+ clientSecret;
16425
+ enabled;
16426
+ onResponse;
16427
+ constructor(config) {
16428
+ this.keepApiUrl = config.keepApiUrl.replace(/\/$/, "");
16429
+ this.clientId = config.clientId;
16430
+ this.clientSecret = config.clientSecret;
16431
+ this.enabled = !!(config.clientId && config.clientSecret && config.keepApiUrl);
16432
+ this.onResponse = config.onResponse;
16433
+ }
16434
+ async getToken() {
16435
+ if (!this.enabled)
16436
+ return null;
16437
+ if (this.token && Date.now() < this.expiresAt - 3e4) {
16438
+ return this.token;
16439
+ }
16440
+ if (this.pendingExchange) {
16441
+ return this.pendingExchange;
16442
+ }
16443
+ this.pendingExchange = this.exchange();
16444
+ try {
16445
+ return await this.pendingExchange;
16446
+ } finally {
16447
+ this.pendingExchange = null;
16448
+ }
16449
+ }
16450
+ async exchange() {
16451
+ const url = `${this.keepApiUrl}/api/agent-tokens`;
16452
+ const response = await fetch(url, {
16453
+ method: "POST",
16454
+ headers: { "Content-Type": "application/json" },
16455
+ body: JSON.stringify({
16456
+ clientId: this.clientId,
16457
+ clientSecret: this.clientSecret
16458
+ })
16459
+ });
16460
+ this.onResponse?.(response);
16461
+ const json = await response.json();
16462
+ if (!response.ok || !json.success) {
16463
+ const msg = json.error?.message ?? `Agent token exchange failed with status ${response.status}`;
16464
+ throw new Error(`[AgentTokenManager] ${msg}`);
16465
+ }
16466
+ this.token = json.data.token;
16467
+ this.expiresAt = Date.now() + json.data.expiresIn * 1e3;
16468
+ return this.token;
16469
+ }
16470
+ };
16471
+ exports2.AgentTokenManager = AgentTokenManager;
16472
+ }
16473
+ });
16474
+
16345
16475
  // ../../packages/keep/dist/index.js
16346
16476
  var require_dist3 = __commonJS({
16347
16477
  "../../packages/keep/dist/index.js"(exports2) {
16348
16478
  "use strict";
16349
16479
  Object.defineProperty(exports2, "__esModule", { value: true });
16350
- exports2.ServiceTokenManager = exports2.KeepClient = void 0;
16480
+ exports2.AgentTokenManager = exports2.ServiceTokenManager = exports2.KeepClient = void 0;
16351
16481
  var client_1 = require_client2();
16352
16482
  Object.defineProperty(exports2, "KeepClient", { enumerable: true, get: function() {
16353
16483
  return client_1.KeepClient;
@@ -16356,6 +16486,10 @@ var require_dist3 = __commonJS({
16356
16486
  Object.defineProperty(exports2, "ServiceTokenManager", { enumerable: true, get: function() {
16357
16487
  return service_token_1.ServiceTokenManager;
16358
16488
  } });
16489
+ var agent_token_1 = require_agent_token();
16490
+ Object.defineProperty(exports2, "AgentTokenManager", { enumerable: true, get: function() {
16491
+ return agent_token_1.AgentTokenManager;
16492
+ } });
16359
16493
  }
16360
16494
  });
16361
16495
 
@@ -16746,6 +16880,206 @@ var require_http_api = __commonJS({
16746
16880
  }
16747
16881
  });
16748
16882
 
16883
+ // ../../packages/shuttle/dist/config/env-ref.js
16884
+ var require_env_ref = __commonJS({
16885
+ "../../packages/shuttle/dist/config/env-ref.js"(exports2) {
16886
+ "use strict";
16887
+ Object.defineProperty(exports2, "__esModule", { value: true });
16888
+ exports2.resolveEnvRef = resolveEnvRef;
16889
+ exports2.isEnvRef = isEnvRef;
16890
+ var ENV_REF_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/;
16891
+ function resolveEnvRef(value, fieldName) {
16892
+ const match = ENV_REF_PATTERN.exec(value);
16893
+ if (!match)
16894
+ return value;
16895
+ const varName = match[1];
16896
+ const resolved = process.env[varName];
16897
+ if (resolved === void 0 || resolved === "") {
16898
+ const subject = fieldName ? `${fieldName} (${value})` : value;
16899
+ throw new Error(`Environment variable "${varName}" referenced by ${subject} is not set. Export it before starting the Sifter, or hard-code the value in the YAML.`);
16900
+ }
16901
+ return resolved;
16902
+ }
16903
+ function isEnvRef(value) {
16904
+ return ENV_REF_PATTERN.test(value);
16905
+ }
16906
+ }
16907
+ });
16908
+
16909
+ // ../../packages/shuttle/dist/executors/anthropic-api.js
16910
+ var require_anthropic_api = __commonJS({
16911
+ "../../packages/shuttle/dist/executors/anthropic-api.js"(exports2) {
16912
+ "use strict";
16913
+ Object.defineProperty(exports2, "__esModule", { value: true });
16914
+ exports2.createAnthropicApiExecutor = createAnthropicApiExecutor;
16915
+ var keep_1 = require_dist3();
16916
+ var env_ref_1 = require_env_ref();
16917
+ var DEFAULT_BASE_URL = "https://api.anthropic.com/v1/messages";
16918
+ var DEFAULT_MODEL = "claude-sonnet-4-6";
16919
+ var DEFAULT_MAX_TOKENS = 4096;
16920
+ var DEFAULT_TIMEOUT_MS = 12e4;
16921
+ var ANTHROPIC_API_VERSION = "2023-06-01";
16922
+ var AnthropicApiExecutor = class {
16923
+ capability;
16924
+ instance;
16925
+ constructor(instance) {
16926
+ this.instance = instance;
16927
+ this.capability = {
16928
+ id: instance.capabilityId,
16929
+ jobTypes: ["prompt-execution"]
16930
+ };
16931
+ }
16932
+ async dispatch(req, signal) {
16933
+ const apiKey = await this.instance.resolveKey(req);
16934
+ const body = {
16935
+ model: this.instance.model,
16936
+ max_tokens: this.instance.maxTokens,
16937
+ messages: [{ role: "user", content: req.prompt }]
16938
+ };
16939
+ const timeoutController = new AbortController();
16940
+ const timer = setTimeout(() => timeoutController.abort(), this.instance.timeoutMs);
16941
+ function onAbort() {
16942
+ timeoutController.abort();
16943
+ }
16944
+ signal.addEventListener("abort", onAbort, { once: true });
16945
+ try {
16946
+ const response = await fetch(this.instance.baseUrl, {
16947
+ method: "POST",
16948
+ headers: {
16949
+ "content-type": "application/json",
16950
+ "x-api-key": apiKey,
16951
+ "anthropic-version": ANTHROPIC_API_VERSION
16952
+ },
16953
+ body: JSON.stringify(body),
16954
+ signal: timeoutController.signal
16955
+ });
16956
+ if (!response.ok) {
16957
+ let bodyText = "";
16958
+ try {
16959
+ bodyText = await response.text();
16960
+ } catch {
16961
+ }
16962
+ throw new Error(`HTTP ${response.status}${bodyText ? `: ${bodyText}` : ""}`);
16963
+ }
16964
+ const data = await response.json();
16965
+ const content = data.content;
16966
+ const rawText = content?.[0]?.text ?? "";
16967
+ const u = data.usage;
16968
+ const usage = u ? {
16969
+ inputTokens: u.input_tokens,
16970
+ outputTokens: u.output_tokens,
16971
+ aiProvider: "anthropic",
16972
+ aiModel: this.instance.model
16973
+ } : void 0;
16974
+ return {
16975
+ rawText,
16976
+ parsed: safeJsonParse(rawText),
16977
+ ...usage ? { usage } : {}
16978
+ };
16979
+ } finally {
16980
+ clearTimeout(timer);
16981
+ signal.removeEventListener("abort", onAbort);
16982
+ }
16983
+ }
16984
+ };
16985
+ function createAnthropicApiExecutor(config, capabilityId = "anthropic-api", deps = {}) {
16986
+ const rawApiKeyFrom = config.apiKeyFrom;
16987
+ const rawApiKey = config.apiKey;
16988
+ if (rawApiKeyFrom !== void 0 && rawApiKey !== void 0) {
16989
+ throw new Error('AnthropicApiExecutor accepts either "apiKey" (static) or "apiKeyFrom" (dynamic), not both');
16990
+ }
16991
+ const resolveKey = rawApiKeyFrom !== void 0 ? buildDynamicResolver(config, deps) : buildStaticResolver(rawApiKey);
16992
+ let baseUrl = DEFAULT_BASE_URL;
16993
+ if (config.baseUrl !== void 0) {
16994
+ if (typeof config.baseUrl !== "string" || config.baseUrl.length === 0) {
16995
+ throw new Error('"baseUrl" must be a non-empty string when set');
16996
+ }
16997
+ baseUrl = config.baseUrl;
16998
+ }
16999
+ let model = DEFAULT_MODEL;
17000
+ if (config.model !== void 0) {
17001
+ if (typeof config.model !== "string" || config.model.length === 0) {
17002
+ throw new Error('"model" must be a non-empty string when set');
17003
+ }
17004
+ model = config.model;
17005
+ }
17006
+ let maxTokens = DEFAULT_MAX_TOKENS;
17007
+ if (config.maxTokens !== void 0) {
17008
+ if (typeof config.maxTokens !== "number" || config.maxTokens <= 0) {
17009
+ throw new Error('"maxTokens" must be a positive number');
17010
+ }
17011
+ maxTokens = config.maxTokens;
17012
+ }
17013
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
17014
+ if (config.timeout !== void 0) {
17015
+ if (typeof config.timeout !== "number" || config.timeout <= 0) {
17016
+ throw new Error('"timeout" must be a positive number');
17017
+ }
17018
+ timeoutMs = config.timeout;
17019
+ }
17020
+ return new AnthropicApiExecutor({
17021
+ resolveKey,
17022
+ baseUrl,
17023
+ model,
17024
+ maxTokens,
17025
+ timeoutMs,
17026
+ capabilityId
17027
+ });
17028
+ }
17029
+ function buildStaticResolver(rawApiKey) {
17030
+ if (typeof rawApiKey !== "string" || rawApiKey.length === 0) {
17031
+ throw new Error('AnthropicApiExecutor requires a non-empty "apiKey" string in config (or "apiKeyFrom: \\"job-metadata\\"" for dynamic key mode)');
17032
+ }
17033
+ const resolved = (0, env_ref_1.resolveEnvRef)(rawApiKey, "apiKey");
17034
+ return async () => resolved;
17035
+ }
17036
+ function buildDynamicResolver(config, deps) {
17037
+ if (config.apiKeyFrom !== "job-metadata") {
17038
+ throw new Error(`Unsupported "apiKeyFrom" value: ${JSON.stringify(config.apiKeyFrom)}. Only "job-metadata" is supported in v0.2.`);
17039
+ }
17040
+ const keepApiUrl = resolveStringConfig(config, "keepApiUrl", "KEEP_API_URL");
17041
+ const clientId = resolveStringConfig(config, "keepClientId", "KEEP_CLIENT_ID");
17042
+ const clientSecret = resolveStringConfig(config, "keepClientSecret", "KEEP_CLIENT_SECRET");
17043
+ const stmFactory = deps.buildServiceTokenManager ?? ((cfg) => new keep_1.ServiceTokenManager(cfg));
17044
+ const stm = stmFactory({ keepApiUrl, clientId, clientSecret });
17045
+ const keepClientFactory = deps.buildKeepClient ?? ((cfg) => new keep_1.KeepClient(cfg));
17046
+ return async (req) => {
17047
+ const hints = req.providerHints ?? {};
17048
+ const customerId = typeof hints.customerId === "string" ? hints.customerId : void 0;
17049
+ if (!customerId) {
17050
+ throw new Error("AnthropicApiExecutor dynamic-key mode requires providerHints.customerId on every job");
17051
+ }
17052
+ const serviceToken = await stm.getToken();
17053
+ if (!serviceToken) {
17054
+ throw new Error("AnthropicApiExecutor dynamic-key mode failed to exchange a Keep service token. Check KEEP_CLIENT_ID / KEEP_CLIENT_SECRET.");
17055
+ }
17056
+ const keep = keepClientFactory({ baseUrl: keepApiUrl, serviceToken });
17057
+ const reveal = await keep.revealManagedSifterKey(customerId);
17058
+ return reveal.apiKey;
17059
+ };
17060
+ }
17061
+ function resolveStringConfig(config, field, envFallback) {
17062
+ const raw = config[field];
17063
+ if (typeof raw === "string" && raw.length > 0) {
17064
+ return (0, env_ref_1.resolveEnvRef)(raw, field);
17065
+ }
17066
+ const fromEnv = process.env[envFallback];
17067
+ if (fromEnv && fromEnv.length > 0) {
17068
+ return fromEnv;
17069
+ }
17070
+ throw new Error(`AnthropicApiExecutor dynamic-key mode requires "${field}" in config or the ${envFallback} env var`);
17071
+ }
17072
+ function safeJsonParse(s) {
17073
+ try {
17074
+ const parsed = JSON.parse(s);
17075
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
17076
+ } catch {
17077
+ return void 0;
17078
+ }
17079
+ }
17080
+ }
17081
+ });
17082
+
16749
17083
  // ../../packages/shuttle/dist/executors/webhook.js
16750
17084
  var require_webhook = __commonJS({
16751
17085
  "../../packages/shuttle/dist/executors/webhook.js"(exports2) {
@@ -17458,6 +17792,7 @@ var require_shuttle = __commonJS({
17458
17792
  var registry_1 = require_registry();
17459
17793
  var claude_code_1 = require_claude_code();
17460
17794
  var http_api_1 = require_http_api();
17795
+ var anthropic_api_1 = require_anthropic_api();
17461
17796
  var webhook_1 = require_webhook();
17462
17797
  var custom_script_1 = require_custom_script();
17463
17798
  var logger_2 = require_logger2();
@@ -17555,6 +17890,7 @@ var require_shuttle = __commonJS({
17555
17890
  const all = [
17556
17891
  ["claude-code", claude_code_1.createClaudeCodeExecutor],
17557
17892
  ["http-api", http_api_1.createHttpApiExecutor],
17893
+ ["anthropic-api", anthropic_api_1.createAnthropicApiExecutor],
17558
17894
  ["webhook", webhook_1.createWebhookExecutor],
17559
17895
  ["custom-script", custom_script_1.createCustomScriptExecutor]
17560
17896
  ];
@@ -17569,7 +17905,7 @@ var require_shuttle = __commonJS({
17569
17905
  const store = new store_1.PairingConfigStore(pairingFile);
17570
17906
  if (store.exists()) {
17571
17907
  const pairing = store.read();
17572
- const tokens = new keep_1.ServiceTokenManager({
17908
+ const tokens = new keep_1.AgentTokenManager({
17573
17909
  keepApiUrl: pairing.keepApiUrl,
17574
17910
  clientId: pairing.credentials.clientId,
17575
17911
  clientSecret: pairing.credentials.clientSecret,
@@ -18099,69 +18435,67 @@ var require_dist4 = __commonJS({
18099
18435
  }
18100
18436
  });
18101
18437
 
18102
- // package.json
18103
- var require_package = __commonJS({
18104
- "package.json"(exports2, module2) {
18105
- module2.exports = {
18106
- name: "@whittlelabs/sifter",
18107
- version: "0.2.0",
18108
- description: "Whittle Sifter: paired AI reviewer for Whittle Sift attention pools.",
18109
- bin: {
18110
- "whittle-sifter": "./dist/bin.js"
18111
- },
18112
- main: "dist/bin.js",
18113
- engines: {
18114
- node: ">=20"
18115
- },
18116
- scripts: {
18117
- dev: "tsx watch src/bin.ts",
18118
- build: "node scripts/bundle.mjs",
18119
- typecheck: "tsc --noEmit",
18120
- start: "node dist/bin.js",
18121
- lint: "eslint src/"
18122
- },
18123
- publishConfig: {
18124
- registry: "https://registry.npmjs.org/",
18125
- access: "public"
18126
- },
18127
- repository: {
18128
- type: "git",
18129
- url: "https://github.com/whittlelabs/whittlelabs.git",
18130
- directory: "apps/whittle-sifter"
18131
- },
18132
- author: "Whittle Labs",
18133
- license: "UNLICENSED",
18134
- private: false,
18135
- dependencies: {
18136
- "@whittlelabs/shuttle": "workspace:*",
18137
- yaml: "^2.7.1"
18138
- },
18139
- devDependencies: {
18140
- "@types/node": "^20.10.5",
18141
- esbuild: "^0.25.0",
18142
- tsx: "^4.21.0",
18143
- typescript: "^5.3.3"
18144
- }
18145
- };
18146
- }
18147
- });
18148
-
18149
18438
  // src/bin.ts
18150
18439
  var import_shuttle2 = __toESM(require_dist4());
18151
18440
 
18152
18441
  // src/brand.ts
18153
18442
  var import_path = require("path");
18154
18443
  var import_promises = require("fs/promises");
18444
+ var import_readline = require("readline");
18155
18445
  var import_yaml = __toESM(require_dist());
18156
18446
  var import_shuttle = __toESM(require_dist4());
18157
- var pkg = require_package();
18447
+
18448
+ // package.json
18449
+ var package_default = {
18450
+ name: "@whittlelabs/sifter",
18451
+ version: "0.3.1",
18452
+ description: "Whittle Sifter: paired AI reviewer for Whittle Sift attention pools.",
18453
+ bin: {
18454
+ "whittle-sifter": "./dist/bin.js"
18455
+ },
18456
+ main: "dist/bin.js",
18457
+ engines: {
18458
+ node: ">=20"
18459
+ },
18460
+ scripts: {
18461
+ dev: "tsx watch src/bin.ts",
18462
+ build: "node scripts/bundle.mjs",
18463
+ typecheck: "tsc --noEmit",
18464
+ start: "node dist/bin.js",
18465
+ lint: "eslint src/"
18466
+ },
18467
+ publishConfig: {
18468
+ registry: "https://registry.npmjs.org/",
18469
+ access: "public"
18470
+ },
18471
+ repository: {
18472
+ type: "git",
18473
+ url: "https://github.com/whittlelabs/whittlelabs.git",
18474
+ directory: "apps/whittle-sifter"
18475
+ },
18476
+ author: "Whittle Labs",
18477
+ license: "UNLICENSED",
18478
+ private: false,
18479
+ dependencies: {
18480
+ "@whittlelabs/shuttle": "workspace:*",
18481
+ yaml: "^2.7.1"
18482
+ },
18483
+ devDependencies: {
18484
+ "@types/node": "^20.10.5",
18485
+ esbuild: "^0.25.0",
18486
+ tsx: "^4.21.0",
18487
+ typescript: "^5.3.3"
18488
+ }
18489
+ };
18490
+
18491
+ // src/brand.ts
18158
18492
  var sifterBrand = {
18159
18493
  product: {
18160
18494
  id: "sifter",
18161
18495
  title: "Whittle Sifter",
18162
18496
  cliBinary: "whittle-sifter",
18163
18497
  packageName: "@whittlelabs/sifter",
18164
- version: pkg.version,
18498
+ version: package_default.version,
18165
18499
  description: "Pairs with Whittle Sift to run AI code reviews on your hardware with your credentials."
18166
18500
  },
18167
18501
  paths: {
@@ -18172,10 +18506,10 @@ var sifterBrand = {
18172
18506
  // shared config across multiple machines.
18173
18507
  configSearchPaths: ["~/.sifter/sifter.yaml"]
18174
18508
  },
18175
- executorAllowlist: ["claude-code", "http-api", "webhook", "custom-script"],
18509
+ executorAllowlist: ["claude-code", "anthropic-api", "http-api", "webhook", "custom-script"],
18176
18510
  keepRegistration: {
18177
- keepApiUrl: process.env.KEEP_API_URL ?? "https://keep.staging.whittlelabs.com",
18178
- jobsApiUrl: process.env.JOBS_API_URL ?? "https://jobs.staging.whittlelabs.com",
18511
+ keepApiUrl: process.env.KEEP_API_URL ?? "https://keep.stage.whittlelabs.com",
18512
+ jobsApiUrl: process.env.JOBS_API_URL ?? "https://jobs.stage.whittlelabs.com",
18179
18513
  oauthClientId: process.env.SIFTER_OAUTH_CLIENT_ID ?? "sifter",
18180
18514
  orgSlug: "whittle-labs",
18181
18515
  roleSlug: "sift-sifter",
@@ -18191,20 +18525,39 @@ var sifterBrand = {
18191
18525
  id: "scaffold-run-config",
18192
18526
  phase: "post-pair",
18193
18527
  // The substrate's run command requires a YAML describing pools,
18194
- // executors, and Jobs auth. At v0.1 the Sifter has no per-user
18195
- // executor variation worth surfacing, so we synthesize the YAML
18196
- // here from the pairing config that init just wrote. The user
18197
- // never has to author the file by hand. If the file already
18198
- // exists from a prior pair, leave it alone so any local tweaks
18199
- // survive re-pairing.
18200
- run: async ({ configDir }) => {
18201
- const yamlPath = (0, import_path.join)(configDir, "sifter.yaml");
18202
- const pairing = new import_shuttle.PairingConfigStore((0, import_path.join)(configDir, "config.json")).read();
18528
+ // executors, and Jobs auth. The web form that minted the pair command
18529
+ // bakes the user's product-level choices (executor type, poll interval)
18530
+ // into the init flags; this step reads them from `ctx.setupOptions`.
18531
+ //
18532
+ // A re-pair must refresh the pairing-derived fields (Jobs URL, service
18533
+ // identity, pools) even when sifter.yaml already exists — otherwise a
18534
+ // stale file silently shadows the new pairing (exactly how a leftover
18535
+ // `localhost:3005` and an old `cwd` once survived a fresh pair). So we
18536
+ // merge rather than skip: rebuild the pairing fields from `pairing`, carry
18537
+ // over the user's executor block (e.g. claude-code `cwd`) and polling
18538
+ // unless a fresh flag overrides them, and log what happened. We only
18539
+ // (re)scaffold an executor block — which may prompt for `cwd` — when there
18540
+ // isn't one to preserve.
18541
+ run: async (ctx) => {
18542
+ const yamlPath = (0, import_path.join)(ctx.configDir, "sifter.yaml");
18543
+ const pairing = new import_shuttle.PairingConfigStore((0, import_path.join)(ctx.configDir, "config.json")).read();
18203
18544
  if (pairing.attentionPools.length === 0) {
18204
18545
  throw new Error(
18205
18546
  "Pairing returned no attention pools. The Sift backend should have provisioned one for this user during pre-pair."
18206
18547
  );
18207
18548
  }
18549
+ let existing = {};
18550
+ try {
18551
+ existing = (0, import_yaml.parse)(await (0, import_promises.readFile)(yamlPath, "utf8")) ?? {};
18552
+ } catch (err) {
18553
+ if (err.code !== "ENOENT") throw err;
18554
+ }
18555
+ const isUpdate = Object.keys(existing).length > 0;
18556
+ const existingExecutors = existing.executors ?? {};
18557
+ const existingPools = Array.isArray(existing.pools) ? existing.pools : [];
18558
+ const priorExecutor = typeof existingPools[0]?.executor === "string" ? existingPools[0].executor : void 0;
18559
+ const executor = ctx.setupOptions.executor ?? priorExecutor ?? "claude-code";
18560
+ const executorBlock = !ctx.setupOptions.executor && existingExecutors[executor] !== void 0 ? existingExecutors[executor] : await buildExecutorBlock(executor, ctx);
18208
18561
  const doc = {
18209
18562
  auth: {
18210
18563
  jobsApiUrl: pairing.jobsApiUrl
@@ -18216,21 +18569,55 @@ var sifterBrand = {
18216
18569
  pools: pairing.attentionPools.map((p) => ({
18217
18570
  id: p.id,
18218
18571
  name: p.name,
18219
- executor: "claude-code"
18572
+ executor
18220
18573
  })),
18221
18574
  executors: {
18222
- "claude-code": { type: "claude-code" }
18575
+ ...existingExecutors,
18576
+ [executor]: executorBlock
18223
18577
  }
18224
18578
  };
18225
- await (0, import_promises.mkdir)(configDir, { recursive: true });
18226
- await (0, import_promises.writeFile)(yamlPath, (0, import_yaml.stringify)(doc), { flag: "wx" }).catch((err) => {
18227
- if (err.code === "EEXIST") return;
18228
- throw err;
18229
- });
18579
+ const pollIntervalMs = ctx.setupOptions.pollIntervalMs ?? existing.polling?.intervalMs;
18580
+ if (pollIntervalMs !== void 0) {
18581
+ doc.polling = { intervalMs: pollIntervalMs };
18582
+ }
18583
+ await (0, import_promises.mkdir)(ctx.configDir, { recursive: true });
18584
+ await (0, import_promises.writeFile)(yamlPath, (0, import_yaml.stringify)(doc));
18585
+ ctx.log(
18586
+ isUpdate ? `Refreshed ${yamlPath} from this pairing (Jobs URL, identity, pools); kept your executor and polling settings.` : `Wrote ${yamlPath}.`
18587
+ );
18230
18588
  }
18231
18589
  }
18232
18590
  ]
18233
18591
  };
18592
+ async function buildExecutorBlock(executor, ctx) {
18593
+ switch (executor) {
18594
+ case "claude-code": {
18595
+ const cwd = ctx.setupOptions.cwd ?? await promptForCwd(`Where should Claude Code run? [${process.cwd()}]: `, process.cwd());
18596
+ return { type: "claude-code", cwd };
18597
+ }
18598
+ case "anthropic-api": {
18599
+ return {
18600
+ type: "anthropic-api",
18601
+ apiKey: "${ANTHROPIC_API_KEY}"
18602
+ };
18603
+ }
18604
+ default:
18605
+ return { type: executor };
18606
+ }
18607
+ }
18608
+ async function promptForCwd(prompt, fallback) {
18609
+ if (!process.stdin.isTTY) return fallback;
18610
+ const rl = (0, import_readline.createInterface)({ input: process.stdin, output: process.stdout });
18611
+ try {
18612
+ const answer = await new Promise((resolve) => {
18613
+ rl.question(prompt, (input) => resolve(input));
18614
+ });
18615
+ const trimmed = answer.trim();
18616
+ return trimmed.length === 0 ? fallback : trimmed;
18617
+ } finally {
18618
+ rl.close();
18619
+ }
18620
+ }
18234
18621
 
18235
18622
  // src/bin.ts
18236
18623
  (0, import_shuttle2.createCli)(sifterBrand).parseAsync(process.argv).catch((err) => {