@indigoai-us/hq-cli 5.345.198 → 5.345.199

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 (102) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/command-catalog.generated.d.ts +106 -6
  3. package/dist/command-catalog.generated.js +130 -6
  4. package/dist/commands/bot-events.d.ts +19 -0
  5. package/dist/commands/bot-events.js +46 -0
  6. package/dist/commands/bot-jobs.d.ts +2 -0
  7. package/dist/commands/bot-jobs.js +43 -2
  8. package/dist/commands/bot.d.ts +5 -0
  9. package/dist/commands/bot.js +33 -1
  10. package/dist/commands/lanes-report.d.ts +2 -1
  11. package/dist/commands/lanes-report.js +44 -1
  12. package/dist/commands/lanes.d.ts +9 -0
  13. package/dist/commands/lanes.js +95 -9
  14. package/dist/commands/meetings.js +1 -21
  15. package/dist/commands/signals-run.d.ts +2 -0
  16. package/dist/commands/signals-run.js +6 -1
  17. package/dist/lanes-drain-hook.js +9 -0
  18. package/dist/lib/bot/api.d.ts +5 -0
  19. package/dist/lib/bot/claude-login.d.ts +31 -0
  20. package/dist/lib/bot/claude-login.js +160 -0
  21. package/dist/lib/bot/events.d.ts +18 -0
  22. package/dist/lib/bot/events.js +160 -0
  23. package/dist/lib/bot/executive-settings.d.ts +10 -0
  24. package/dist/lib/bot/executive-settings.js +43 -0
  25. package/dist/lib/bot/fleet-cron.js +4 -0
  26. package/dist/lib/bot/fleet-local.js +10 -10
  27. package/dist/lib/bot/job-run.js +1 -1
  28. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
  29. package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
  30. package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
  31. package/dist/lib/bot/jobs/meeting-detect.js +176 -0
  32. package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
  33. package/dist/lib/bot/jobs/meeting-rider.js +284 -0
  34. package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
  35. package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
  36. package/dist/lib/bot/local-jobs.d.ts +15 -2
  37. package/dist/lib/bot/local-jobs.js +24 -7
  38. package/dist/lib/bot/run.d.ts +7 -49
  39. package/dist/lib/bot/run.js +100 -4
  40. package/dist/lib/bot/status.d.ts +21 -0
  41. package/dist/lib/doctor/checks/bots.d.ts +6 -0
  42. package/dist/lib/doctor/checks/bots.js +47 -1
  43. package/dist/lib/hot-channels/adapters/meetings.d.ts +1 -0
  44. package/dist/lib/hot-channels/adapters/meetings.js +3 -1
  45. package/dist/lib/hot-channels/meeting-source-ref.d.ts +3 -0
  46. package/dist/lib/hot-channels/meeting-source-ref.js +7 -0
  47. package/dist/lib/hot-channels/schedule.js +2 -2
  48. package/dist/lib/hot-channels/signals-pass.d.ts +9 -32
  49. package/dist/lib/hot-channels/signals-pass.js +51 -17
  50. package/dist/lib/lanes/admission.d.ts +2 -0
  51. package/dist/lib/lanes/admission.js +4 -0
  52. package/dist/lib/lanes/approval-gate.js +4 -1
  53. package/dist/lib/lanes/links.d.ts +8 -1
  54. package/dist/lib/lanes/links.js +84 -3
  55. package/dist/lib/lanes/outcome.d.ts +154 -0
  56. package/dist/lib/lanes/outcome.js +151 -0
  57. package/dist/lib/lanes/reconcile.js +1 -0
  58. package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
  59. package/dist/lib/lanes/signal-brief-template.js +40 -0
  60. package/dist/lib/lanes/signal-lane.d.ts +24 -0
  61. package/dist/lib/lanes/signal-lane.js +99 -0
  62. package/dist/lib/lanes/source-report.d.ts +37 -0
  63. package/dist/lib/lanes/source-report.js +66 -0
  64. package/dist/lib/lanes/spawn.d.ts +6 -0
  65. package/dist/lib/lanes/spawn.js +21 -0
  66. package/dist/lib/lanes/store.d.ts +3 -0
  67. package/dist/lib/lanes/store.js +64 -0
  68. package/dist/lib/lanes/story-ledger.d.ts +3 -0
  69. package/dist/lib/lanes/story-ledger.js +1 -0
  70. package/dist/lib/lanes/types.d.ts +137 -0
  71. package/dist/lib/lanes/types.js +17 -2
  72. package/dist/lib/lanes/watcher.js +4 -0
  73. package/dist/lib/live-meetings.d.ts +19 -0
  74. package/dist/lib/live-meetings.js +23 -0
  75. package/dist/lib/mesh/live/daemon/doctor.js +6 -7
  76. package/dist/lib/mesh/live/spool.d.ts +8 -0
  77. package/dist/lib/mesh/live/spool.js +59 -40
  78. package/dist/lib/plan-limit-nag.d.ts +30 -4
  79. package/dist/lib/plan-limit-nag.js +68 -17
  80. package/dist/lib/signals/engine.d.ts +4 -0
  81. package/dist/lib/signals/engine.js +25 -5
  82. package/dist/lib/signals/incremental.d.ts +42 -0
  83. package/dist/lib/signals/incremental.js +191 -0
  84. package/dist/lib/signals/prior-signals.d.ts +11 -0
  85. package/dist/lib/signals/prior-signals.js +44 -0
  86. package/dist/lib/signals/reply.js +6 -1
  87. package/dist/lib/signals/settings.d.ts +5 -0
  88. package/dist/lib/signals/settings.js +17 -0
  89. package/dist/lib/signals/steps/extract.d.ts +23 -2
  90. package/dist/lib/signals/steps/extract.js +142 -9
  91. package/dist/lib/signals/steps/types.d.ts +1 -0
  92. package/dist/lib/signals/write-guard.js +7 -0
  93. package/dist/utils/cognito-callback-port.d.ts +15 -0
  94. package/dist/utils/cognito-callback-port.js +51 -0
  95. package/dist/utils/cognito-config.js +2 -3
  96. package/dist/utils/cognito-session.d.ts +7 -6
  97. package/dist/utils/cognito-session.js +14 -14
  98. package/dist/utils/interactive-caller.d.ts +15 -0
  99. package/dist/utils/interactive-caller.js +70 -0
  100. package/dist/utils/login-browser.js +3 -1
  101. package/dist/utils/resident-package-preload.js +4 -0
  102. package/package.json +7 -7
@@ -63,6 +63,52 @@ export const PLAN_LIMIT_DIMENSION_LABELS = {
63
63
  deployments: "deployments",
64
64
  storageBytes: "storage",
65
65
  };
66
+ /**
67
+ * Bytes in one GB as plan limits count them: 2^30. hq-pro stores the "10 GB"
68
+ * and "2 GB" storage ceilings as GiB and its limit emails print them as
69
+ * "10 GB" / "2 GB"; `lib/billing/usage-cost.ts` uses the same unit
70
+ * (BYTES_PER_GB). Kept local so this hot-path module loads no billing code.
71
+ */
72
+ const STORAGE_BYTES_PER_GB = 1024 ** 3;
73
+ /**
74
+ * Render one amount of a resource. Storage arrives in bytes; printed raw it
75
+ * reads as "47012700666 of 10737418240". It is shown in GB with one decimal,
76
+ * so a 10 GB ceiling reads "10.0 GB", matching the plan's own wording.
77
+ */
78
+ export function formatPlanLimitAmount(key, n) {
79
+ if (key !== "storageBytes")
80
+ return String(n);
81
+ return `${(n / STORAGE_BYTES_PER_GB).toFixed(1)} GB`;
82
+ }
83
+ /**
84
+ * The account as a noun phrase: "your personal account", "the acme company",
85
+ * or "this account" when an older hq-pro sent no label.
86
+ */
87
+ export function planLimitAccountPhrase(account) {
88
+ if (account?.kind === "person")
89
+ return "your personal account";
90
+ if (account?.kind === "company" && account.slug)
91
+ return `the ${account.slug} company`;
92
+ return "this account";
93
+ }
94
+ function parseAccount(body) {
95
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
96
+ return null;
97
+ }
98
+ const raw = body.account;
99
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
100
+ return null;
101
+ const rec = raw;
102
+ if (rec.kind === "person")
103
+ return { kind: "person" };
104
+ if (rec.kind === "company") {
105
+ const slug = typeof rec.slug === "string" && rec.slug.trim().length > 0
106
+ ? rec.slug.trim()
107
+ : null;
108
+ return { kind: "company", slug };
109
+ }
110
+ return null;
111
+ }
66
112
  function dimensionSuffix(key) {
67
113
  const label = PLAN_LIMIT_DIMENSION_LABELS[key];
68
114
  return label && label !== key ? ` (${label})` : "";
@@ -252,14 +298,15 @@ export function recordPlanLimitStatus(body, opts = {}) {
252
298
  legacyUrl: status?.upgradeUrl ?? PLAN_LIMIT_UPGRADE_URL,
253
299
  });
254
300
  }
301
+ const account = parseAccount(body);
255
302
  if (status !== null) {
256
303
  const anyOver = Object.values(status.limits).some((e) => e.over);
257
- lastSeen = { ...status, anyOver, upgradeUrl: resolvedUpgradeUrl };
304
+ lastSeen = { ...status, anyOver, upgradeUrl: resolvedUpgradeUrl, account };
258
305
  }
259
306
  // Independent of `planLimits`: the server can announce a crossing on a
260
307
  // response whose nag map this CLI could not parse, and the notice is the
261
308
  // one surface that must land inside a session that is already running.
262
- emitPlanLimitThresholdNotice(crossings, resolvedUpgradeUrl, opts);
309
+ emitPlanLimitThresholdNotice(crossings, resolvedUpgradeUrl, { ...opts, account });
263
310
  }
264
311
  catch {
265
312
  // Never throw from record path.
@@ -287,7 +334,7 @@ function formatPct(entry) {
287
334
  return `${Math.round((entry.used / entry.limit) * 100)}%`;
288
335
  }
289
336
  function formatEntryLine(key, entry) {
290
- return `${key} at ${entry.used}/${entry.limit} (${formatPct(entry)})${dimensionSuffix(key)}`;
337
+ return `${key} at ${formatPlanLimitAmount(key, entry.used)}/${formatPlanLimitAmount(key, entry.limit)} (${formatPct(entry)})${dimensionSuffix(key)}`;
291
338
  }
292
339
  /**
293
340
  * Read the whole state file. A missing, unreadable or malformed file is an
@@ -360,16 +407,18 @@ function withinNagWindow(shownAt, nowMs) {
360
407
  /**
361
408
  * The over-limit per-turn line (US-036 §2.2). One line, not the old box: it
362
409
  * now repeats up to four times a day, and a multi-line box at that cadence
363
- * reads as breakage rather than as a nag. It states the two paused things and
364
- * never claims the workspace has stopped working.
410
+ * reads as breakage rather than as a nag. It names the account and states the
411
+ * facts. It claims no pause: being over is not being refused. A refusal comes
412
+ * back from hq-pro as its own error with its own sentence, and that is the
413
+ * only place a pause is reported (feedback_b1867fe6, where this line told a
414
+ * person their uploads were paused right after two uploads succeeded).
365
415
  */
366
- export function buildOverLine(overEntries, upgradeUrl) {
416
+ export function buildOverLine(overEntries, upgradeUrl, account = null) {
367
417
  const facts = overEntries
368
- .map(([key, entry]) => `${PLAN_LIMIT_DIMENSION_LABELS[key] ?? key} ${entry.used} of ${entry.limit}`)
418
+ .map(([key, entry]) => `${PLAN_LIMIT_DIMENSION_LABELS[key] ?? key} ${formatPlanLimitAmount(key, entry.used)} of ${formatPlanLimitAmount(key, entry.limit)}`)
369
419
  .join(", ");
370
420
  // US-024: names no plan and offers no upgrade; the link is the Billing page.
371
- return (`⚠ Over this account's limits: ${facts}. New files and new secrets are paused. ` +
372
- `Billing: ${upgradeUrl}`);
421
+ return `⚠ Over ${planLimitAccountPhrase(account)}'s limits: ${facts}. Billing: ${upgradeUrl}`;
373
422
  }
374
423
  // ---------------------------------------------------------------------------
375
424
  // Personal feature notice (US-054, copy per US-024)
@@ -424,12 +473,14 @@ function dimensionName(resource) {
424
473
  * their own story, and a notice that announces a pause nobody is experiencing
425
474
  * reads as a bug.
426
475
  */
427
- export function renderThresholdNotice(crossing, upgradeUrl) {
476
+ export function renderThresholdNotice(crossing, upgradeUrl, account = null) {
428
477
  const name = dimensionName(crossing.resource);
429
- const counts = `${crossing.used} of ${crossing.limit}`;
478
+ const counts = `${formatPlanLimitAmount(crossing.resource, crossing.used)} of ${formatPlanLimitAmount(crossing.resource, crossing.limit)}`;
479
+ // An unlabelled payload (older hq-pro) keeps the original wording.
480
+ const where = account ? ` for ${planLimitAccountPhrase(account)}` : "";
430
481
  const headline = crossing.band === "over"
431
- ? `You are past your ${name} limit (${counts}).`
432
- : `You have reached 80% of your ${name} limit (${counts}).`;
482
+ ? `You are past your ${name} limit${where} (${counts}).`
483
+ : `You have reached 80% of your ${name} limit${where} (${counts}).`;
433
484
  return `${headline} Billing: ${upgradeUrl}`;
434
485
  }
435
486
  /**
@@ -461,7 +512,7 @@ export function emitPlanLimitThresholdNotice(crossings, upgradeUrl, opts = {}) {
461
512
  continue;
462
513
  thresholdsShownThisSession.set(key, crossing.firstSeenAt);
463
514
  printed[key] = crossing.firstSeenAt;
464
- write(chalk.yellow(renderThresholdNotice(crossing, resolvedUpgradeUrl)) + "\n");
515
+ write(chalk.yellow(renderThresholdNotice(crossing, resolvedUpgradeUrl, opts.account ?? null)) + "\n");
465
516
  if (isCliLimitResourceKind(crossing.resource)) {
466
517
  queueCliLimitNoticeExposure(crossing.resource, "advisory");
467
518
  }
@@ -489,7 +540,7 @@ export function emitPlanLimitNag(opts = {}) {
489
540
  const write = opts.write ?? ((s) => process.stderr.write(s));
490
541
  const now = opts.now ?? (() => new Date());
491
542
  const statePath = opts.statePath ?? defaultStatePath();
492
- const { limits, anyOver, upgradeUrl } = lastSeen;
543
+ const { limits, anyOver, upgradeUrl, account } = lastSeen;
493
544
  const resolvedUpgradeUrl = upgradeUrl ??
494
545
  planLimitUpgradeFallbackUrl(resolveProcessFlagCompanyContext().companySlug, PLAN_LIMIT_UPGRADE_URL);
495
546
  const entries = Object.entries(limits);
@@ -504,7 +555,7 @@ export function emitPlanLimitNag(opts = {}) {
504
555
  return;
505
556
  overShownThisSession = true;
506
557
  const overEntries = entries.filter(([, e]) => e.over);
507
- const line = buildOverLine(overEntries, resolvedUpgradeUrl);
558
+ const line = buildOverLine(overEntries, resolvedUpgradeUrl, account);
508
559
  write(chalk.yellow(line) + "\n");
509
560
  const resourceKind = overEntries[0]?.[0];
510
561
  if (resourceKind && isCliLimitResourceKind(resourceKind)) {
@@ -520,7 +571,7 @@ export function emitPlanLimitNag(opts = {}) {
520
571
  if (worst === null)
521
572
  return;
522
573
  warningShownThisSession = true;
523
- const line = `⚠ Nearing this account's limits: ${formatEntryLine(worst.key, worst.entry)}. Make room before you hit the cap. Billing: ${resolvedUpgradeUrl}`;
574
+ const line = `⚠ Nearing ${planLimitAccountPhrase(account)}'s limits: ${formatEntryLine(worst.key, worst.entry)}. Make room before you hit the cap. Billing: ${resolvedUpgradeUrl}`;
524
575
  write(chalk.yellow(line) + "\n");
525
576
  if (isCliLimitResourceKind(worst.key)) {
526
577
  queueCliLimitNoticeExposure(worst.key, "advisory");
@@ -1,3 +1,4 @@
1
+ import { type IncrementalOptions } from "./incremental.js";
1
2
  import { localDate } from "./local-date.js";
2
3
  import { type LockTakeover } from "./run-lock.js";
3
4
  import { type UploadDeps } from "./steps/upload.js";
@@ -130,6 +131,9 @@ export interface RunSignalsResult {
130
131
  alerts?: Record<string, RaiseSignalsFailureAlertsResult>;
131
132
  }
132
133
  export interface RunSignalsOptions {
134
+ source?: string;
135
+ incremental?: boolean;
136
+ incrementalOptions?: IncrementalOptions;
133
137
  hqRoot: string;
134
138
  company?: string;
135
139
  dryRun?: boolean;
@@ -12,7 +12,8 @@ import { spawnSync } from "node:child_process";
12
12
  import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import yaml from "js-yaml";
15
- import { loadSignalsSettings } from "./settings.js";
15
+ import { loadSignalsSettings, loadIncrementalSettings } from "./settings.js";
16
+ import { createIncrementalStep } from "./incremental.js";
16
17
  import { localDate } from "./local-date.js";
17
18
  import { guardedWrite, WriteGuardError } from "./write-guard.js";
18
19
  import { acquireCompanyLock, withSummaryLock } from "./run-lock.js";
@@ -46,6 +47,7 @@ export const defaultSpawn = (cmd, args, opts) => {
46
47
  cwd: opts.cwd,
47
48
  env: opts.env ?? process.env,
48
49
  encoding: "utf8",
50
+ input: opts.input,
49
51
  maxBuffer: 64 * 1024 * 1024,
50
52
  ...(opts.timeoutMs !== undefined ? { timeout: opts.timeoutMs, killSignal: "SIGKILL" } : {}),
51
53
  });
@@ -246,9 +248,22 @@ export async function runSignals(opts) {
246
248
  const stepChoice = opts.step ?? "all";
247
249
  const dryRun = opts.dryRun === true;
248
250
  const settings = loadSignalsSettings(hqRoot);
249
- const maxTurns = opts.maxTurns ?? settings.maxTurns;
250
- const maxBudgetUsd = opts.maxBudgetUsd ?? settings.maxBudgetUsd;
251
+ if (opts.source !== undefined || opts.incremental) {
252
+ if (!opts.source || !opts.incremental || !opts.company)
253
+ throw new SignalsUsageError("--source and --incremental require each other and --company");
254
+ if ((opts.step && opts.step !== "all" && opts.step !== "extract") || opts.redo?.length)
255
+ throw new SignalsUsageError("--incremental cannot be combined with --redo or another step");
256
+ if (!/^[a-zA-Z0-9_-]+:[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(opts.source))
257
+ throw new SignalsUsageError("invalid --source: expected <channel>:<file>");
258
+ }
259
+ const limits = opts.incremental ? loadIncrementalSettings(hqRoot) : settings;
260
+ const maxTurns = opts.maxTurns ?? limits.maxTurns;
261
+ const maxBudgetUsd = opts.maxBudgetUsd ?? limits.maxBudgetUsd;
262
+ if (!Number.isInteger(maxTurns) || maxTurns < 1 || !Number.isFinite(maxBudgetUsd) || maxBudgetUsd < 0)
263
+ throw new SignalsUsageError("invalid model cost limits");
251
264
  const stepImpls = { ...DEFAULT_STEPS, ...(opts.steps ?? {}) };
265
+ if (opts.incremental)
266
+ stepImpls.extract = createIncrementalStep(opts.source, opts.incrementalOptions);
252
267
  if (opts.company !== undefined && !SLUG_RE.test(opts.company)) {
253
268
  throw new SignalsUsageError(`invalid company slug: ${opts.company}`);
254
269
  }
@@ -266,12 +281,14 @@ export async function runSignals(opts) {
266
281
  const companies = opts.company !== undefined ? [opts.company] : listCompanies(hqRoot);
267
282
  const start = now();
268
283
  const date = localDate(start);
269
- const lateStart = trigger === "job" && start.getHours() >= settings.lateStartHour;
284
+ const lateStart = !opts.incremental && trigger === "job" && start.getHours() >= settings.lateStartHour;
270
285
  let plan = stepChoice === "all" ? STEP_ORDER : STEP_ORDER.filter((s) => s === stepChoice);
271
286
  if (stepChoice !== "all")
272
287
  plan = plan.slice(0, 1);
273
288
  if (lateStart)
274
289
  plan = plan.filter((s) => s === "garden").slice(0, 1);
290
+ if (opts.incremental)
291
+ plan = ["extract"];
275
292
  const records = [];
276
293
  const locked = [];
277
294
  for (const company of companies) {
@@ -324,7 +341,10 @@ export async function runSignals(opts) {
324
341
  };
325
342
  const finish = () => {
326
343
  const decidedSkip = record.skipReason;
344
+ const previousJobRanAt = record.jobRanAt;
327
345
  finalizeRecord(record, { lateStart, lateStartHour: settings.lateStartHour, finishedAt: now() });
346
+ if (opts.incremental)
347
+ record.jobRanAt = previousJobRanAt;
328
348
  if (dryRun) {
329
349
  if (!decidedSkip) {
330
350
  record.status = "dry-run";
@@ -343,7 +363,7 @@ export async function runSignals(opts) {
343
363
  // 2. The day's job already ran: do nothing and leave the record as it is.
344
364
  const prior = readJson(hqRoot, rel);
345
365
  const priorJobRanAt = typeof prior?.jobRanAt === "string" ? prior.jobRanAt : null;
346
- if (trigger === "job" && !opts.force && prior && priorJobRanAt) {
366
+ if (!opts.incremental && trigger === "job" && !opts.force && prior && priorJobRanAt) {
347
367
  process.stderr.write(`hq signals: ${company}: already-ran (the job ran at ${priorJobRanAt})\n`);
348
368
  records.push({ ...prior, alreadyRanToday: true });
349
369
  continue;
@@ -0,0 +1,42 @@
1
+ import { type PriorSignal } from "./steps/extract.js";
2
+ import type { SignalsStep, StepContext } from "./steps/types.js";
3
+ export { meetingSignalSourceRef } from "../hot-channels/meeting-source-ref.js";
4
+ export interface IncrementalCursor {
5
+ revision: number;
6
+ segment_count: number;
7
+ last_run_at: string;
8
+ signal_ids: string[];
9
+ digest: string;
10
+ pending_events?: SignalWritten[];
11
+ }
12
+ export interface SourceSnapshot {
13
+ sourceRef: string;
14
+ text: string;
15
+ segmentCount: number;
16
+ revision?: number;
17
+ audience: string[];
18
+ }
19
+ export type SourceReader = (ctx: StepContext, id: string) => Promise<SourceSnapshot>;
20
+ export type SignalWritten = {
21
+ event: "signal.written";
22
+ signal_id: string;
23
+ type: string;
24
+ source_ref: string;
25
+ company: string;
26
+ key: string;
27
+ };
28
+ export interface IncrementalOptions {
29
+ readers?: Readonly<Record<string, SourceReader>>;
30
+ /** US-004/005 integration seam: one callback per newly persisted signal. */
31
+ onSignalWritten?: (event: SignalWritten) => Promise<void>;
32
+ }
33
+ export declare const SOURCE_READERS: Readonly<Record<string, SourceReader>>;
34
+ export declare function readIncrementalSource(ctx: StepContext, ref: string, readers?: Readonly<Record<string, SourceReader>>): Promise<SourceSnapshot>;
35
+ export declare function cursorPath(company: string, sourceRef: string): string;
36
+ /** Only read the current audience partition, never another audience's signals. */
37
+ export declare function priorSignalsFor(ctx: StepContext, sourceRef: string, audience: string[]): Array<PriorSignal & {
38
+ key: string;
39
+ live: boolean;
40
+ }>;
41
+ export declare function createIncrementalStep(source: string, options?: IncrementalOptions): SignalsStep;
42
+ //# sourceMappingURL=incremental.d.ts.map
@@ -0,0 +1,191 @@
1
+ /** Incremental source readers and cursor transaction, executed under the engine's company lock. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import yaml from "js-yaml";
6
+ import { ensureCognitoToken } from "../../utils/cognito-session.js";
7
+ import { getCompanyUid, resolveCallerPersonUid, vaultApiFetch } from "../../utils/vault-api.js";
8
+ import { audienceKey } from "../hot-channels/audience.js";
9
+ import { assertSafeSegment } from "../hot-channels/landing.js";
10
+ import { appendSignalIndex, signalId, signalTypeDir, splitFrontmatter } from "../hot-channels/signals-pass.js";
11
+ import { acquireSyncLock } from "../hot-channels/sync-lock.js";
12
+ import { assertAllowedPath } from "./write-guard.js";
13
+ import { createExtractStep } from "./steps/extract.js";
14
+ import { meetingSignalSourceRef } from "../hot-channels/meeting-source-ref.js";
15
+ export { meetingSignalSourceRef } from "../hot-channels/meeting-source-ref.js";
16
+ /** Stable across partial revisions and the adapter's landed transcript. */
17
+ const meetingReader = async (ctx, id) => {
18
+ const sourceRef = meetingSignalSourceRef(id);
19
+ const token = await ensureCognitoToken();
20
+ const companyId = await getCompanyUid(token, ctx.company);
21
+ const response = await vaultApiFetch({ token, path: `/v1/meetings/${encodeURIComponent(id)}`, query: { companyId, view: "live" } });
22
+ if (!response.ok)
23
+ throw new Error("Could not read this live meeting. Check meeting access and retry.");
24
+ const data = await response.json();
25
+ if (!Number.isInteger(data.revision) || !Array.isArray(data.segments) || data.full === false)
26
+ throw new Error("Invalid full live transcript snapshot");
27
+ const lines = data.segments.map((s) => {
28
+ if (!s || typeof s !== "object" || typeof s.text !== "string")
29
+ throw new Error("Invalid live transcript segment");
30
+ const segment = s;
31
+ return `[${typeof segment.startSeconds === "number" ? segment.startSeconds : 0}] ${typeof segment.speaker === "string" ? segment.speaker : "Speaker"}: ${segment.text}`;
32
+ });
33
+ // Never broaden live meeting content to the company. The authenticated reader
34
+ // has access; a final transcript supplies the authoritative attendee audience.
35
+ const person = await resolveCallerPersonUid(token);
36
+ return { sourceRef, text: lines.join("\n"), segmentCount: lines.length, revision: data.revision, audience: [person] };
37
+ };
38
+ export const SOURCE_READERS = { meeting: meetingReader };
39
+ export async function readIncrementalSource(ctx, ref, readers = SOURCE_READERS) {
40
+ const colon = ref.indexOf(":");
41
+ if (colon < 1)
42
+ throw new Error("--source must be <channel>:<file> or meeting:<id>");
43
+ const channel = ref.slice(0, colon);
44
+ const id = ref.slice(colon + 1);
45
+ assertSafeSegment(channel, "source channel");
46
+ assertSafeSegment(id, "source id");
47
+ const reader = Object.hasOwn(readers, channel) ? readers[channel] : undefined;
48
+ if (reader)
49
+ return reader(ctx, id);
50
+ const name = id.endsWith(".md") ? id : `${id}.md`;
51
+ const key = `sources/${channel}/${name}`;
52
+ const file = assertAllowedPath(ctx.hqRoot, `companies/${ctx.company}/${key}`, { company: ctx.company });
53
+ const text = fs.readFileSync(file, "utf8");
54
+ const { frontmatter } = splitFrontmatter(text);
55
+ const audience = Array.isArray(frontmatter.audience) ? frontmatter.audience.filter((p) => typeof p === "string" && p.trim().length > 0) : [];
56
+ if (audience.length === 0)
57
+ throw new Error("Source has no audience; add its intended audience before extracting signals");
58
+ const sourceRef = typeof frontmatter.signal_source_ref === "string" && /^meeting:[a-zA-Z0-9._-]+$/.test(frontmatter.signal_source_ref) ? frontmatter.signal_source_ref : key;
59
+ return { sourceRef, text, segmentCount: text.split(/\r?\n/).filter(Boolean).length, audience };
60
+ }
61
+ export function cursorPath(company, sourceRef) {
62
+ assertSafeSegment(company, "company");
63
+ const safe = createHash("sha256").update(sourceRef).digest("hex");
64
+ return `workspace/executive/signals/${company}/${safe}.cursor.json`;
65
+ }
66
+ function readCursor(ctx, sourceRef) {
67
+ const file = assertAllowedPath(ctx.hqRoot, cursorPath(ctx.company, sourceRef), { company: ctx.company });
68
+ if (!fs.existsSync(file))
69
+ return null;
70
+ const c = JSON.parse(fs.readFileSync(file, "utf8"));
71
+ if (!Number.isInteger(c.revision) || c.revision < 0 || !Number.isInteger(c.segment_count) || c.segment_count < 0 ||
72
+ typeof c.digest !== "string" || !Array.isArray(c.signal_ids) || !c.signal_ids.every((id) => /^[a-f0-9]{64}$/.test(id)))
73
+ throw new Error("Invalid incremental cursor; restore it before retrying");
74
+ if (c.pending_events !== undefined && (!Array.isArray(c.pending_events) || c.pending_events.some((e) => !e || e.event !== "signal.written" || e.company !== ctx.company || e.source_ref !== sourceRef || !c.signal_ids.includes(e.signal_id) || typeof e.type !== "string" || typeof e.key !== "string")))
75
+ throw new Error("Invalid incremental event cursor");
76
+ return c;
77
+ }
78
+ /** Only read the current audience partition, never another audience's signals. */
79
+ export function priorSignalsFor(ctx, sourceRef, audience) {
80
+ const partition = audienceKey(audience);
81
+ const out = [];
82
+ for (const type of ["action_item", "commitment", "decision", "risk", "question", "key_point", "participant_contribution", "summary"]) {
83
+ const dir = signalTypeDir(partition, type);
84
+ const root = assertAllowedPath(ctx.hqRoot, `companies/${ctx.company}/${dir}/probe.md`, { company: ctx.company });
85
+ if (!fs.existsSync(path.dirname(root)))
86
+ continue;
87
+ for (const name of fs.readdirSync(path.dirname(root))) {
88
+ if (!/^[a-f0-9]{64}\.md$/.test(name))
89
+ continue;
90
+ const key = `${dir}/${name}`;
91
+ const file = assertAllowedPath(ctx.hqRoot, `companies/${ctx.company}/${key}`, { company: ctx.company });
92
+ const { frontmatter: fm } = splitFrontmatter(fs.readFileSync(file, "utf8"));
93
+ if (fm.source_ref !== sourceRef || fm.signal_id !== name.slice(0, -3) || fm.type !== type || typeof fm.canonical_content !== "string")
94
+ continue;
95
+ out.push({ signal_id: fm.signal_id, type, title: fm.canonical_content, key, live: fm.origin === "live" });
96
+ }
97
+ }
98
+ return out;
99
+ }
100
+ export function createIncrementalStep(source, options = {}) {
101
+ return {
102
+ name: "extract",
103
+ async run(ctx) {
104
+ const readers = { ...SOURCE_READERS, ...options.readers };
105
+ let snapshot = await readIncrementalSource(ctx, source, readers);
106
+ const vaultRoot = path.join(ctx.hqRoot, "companies", ctx.company);
107
+ const lock = await acquireSyncLock(vaultRoot, `incremental-${createHash("sha256").update(snapshot.sourceRef).digest("hex")}`);
108
+ if (!lock)
109
+ throw new Error("Another incremental run holds this source; retry later");
110
+ try {
111
+ // Resolve the lock identity first, then read content while holding it.
112
+ // A slow initial read must not overwrite a newer run's snapshot.
113
+ const lockedSnapshot = await readIncrementalSource(ctx, source, readers);
114
+ if (lockedSnapshot.sourceRef !== snapshot.sourceRef)
115
+ throw new Error("Source identity changed while acquiring its lock; retry later");
116
+ snapshot = lockedSnapshot;
117
+ const digest = createHash("sha256").update(snapshot.text).update(JSON.stringify(snapshot.audience)).digest("hex");
118
+ const cursor = readCursor(ctx, snapshot.sourceRef);
119
+ // Events are persisted with the cursor before delivery. A failed callback
120
+ // is retried on the next pass without asking the model to recreate a file.
121
+ const deliver = async (state) => {
122
+ while (options.onSignalWritten && state.pending_events?.length) {
123
+ await options.onSignalWritten(state.pending_events[0]);
124
+ await lock.assertHeld();
125
+ state.pending_events.shift();
126
+ ctx.write(cursorPath(ctx.company, snapshot.sourceRef), `${JSON.stringify(state, null, 2)}\n`);
127
+ }
128
+ };
129
+ if (cursor)
130
+ await deliver(cursor);
131
+ const empty = { status: "ok", itemsIn: 0, itemsOut: 0, modelTurns: 0, estimatedSpendUsd: 0, detail: { new_segments: 0, signals_written: 0 } };
132
+ if (cursor?.digest === digest || (!cursor && !snapshot.text.trim()))
133
+ return empty;
134
+ const revision = (cursor?.revision ?? 0) + 1;
135
+ const prior = priorSignalsFor(ctx, snapshot.sourceRef, snapshot.audience);
136
+ const result = await createExtractStep({ documentOverride: { ...snapshot, partial: true, revision }, priorSignals: prior.map(({ signal_id, type, title }) => ({ signal_id, type, title })) }).run(ctx);
137
+ if (result.status !== "ok")
138
+ return result;
139
+ const signals = result.detail?.signals;
140
+ for (const signal of signals) {
141
+ if (signal.update && !prior.some((p) => p.signal_id === signal.update && p.type === signal.type))
142
+ throw new Error("Update id is not a prior signal of this source and type");
143
+ }
144
+ await lock.assertHeld();
145
+ const partition = audienceKey(snapshot.audience);
146
+ const at = ctx.now().toISOString();
147
+ const added = [];
148
+ const ids = new Set(cursor?.signal_ids ?? []);
149
+ if (partition !== "company")
150
+ ctx.write(`companies/${ctx.company}/signals/@${partition}/_audience.yaml`, yaml.dump({ principals: snapshot.audience, audience_source: "incremental" }));
151
+ const rows = [];
152
+ // Recover files persisted before a crash prevented the cursor/index commit.
153
+ // The model may correctly return no repeats, so recovery cannot depend on
154
+ // it returning those signals again.
155
+ const recovered = prior.filter((p) => p.live && !ids.has(p.signal_id)).map((p) => ({ event: "signal.written", signal_id: p.signal_id, type: p.type, source_ref: snapshot.sourceRef, company: ctx.company, key: p.key }));
156
+ for (const event of recovered) {
157
+ ids.add(event.signal_id);
158
+ if (!rows.some((row) => row.signal_id === event.signal_id))
159
+ rows.push({ signal_id: event.signal_id, type: event.type, source_ref: event.source_ref, key: event.key, written_at: at });
160
+ }
161
+ for (const signal of signals) {
162
+ const id = signal.update ?? signalId(snapshot.sourceRef, signal.type, signal.title);
163
+ ids.add(id);
164
+ const key = `${signalTypeDir(partition, signal.type)}/${id}.md`;
165
+ const rel = `companies/${ctx.company}/${key}`;
166
+ const file = assertAllowedPath(ctx.hqRoot, rel, { company: ctx.company });
167
+ const existed = fs.existsSync(file);
168
+ if (existed && !signal.update)
169
+ continue;
170
+ const fm = { signal_id: id, type: signal.type, source_ref: snapshot.sourceRef, canonical_content: signal.title, audience_key: partition, origin: "live", partial_revision: revision, written_at: at };
171
+ ctx.write(rel, `---\n${yaml.dump(fm, { lineWidth: -1 })}---\n${signal.body}\n`);
172
+ const event = { event: "signal.written", signal_id: id, type: signal.type, source_ref: snapshot.sourceRef, company: ctx.company, key };
173
+ if (!existed)
174
+ added.push(event);
175
+ if (!rows.some((row) => row.signal_id === id))
176
+ rows.push({ signal_id: id, type: signal.type, source_ref: snapshot.sourceRef, key, written_at: at });
177
+ }
178
+ await appendSignalIndex(vaultRoot, at.slice(0, 10), rows);
179
+ await lock.assertHeld();
180
+ const next = { revision, segment_count: snapshot.segmentCount, last_run_at: at, signal_ids: [...ids], digest, pending_events: [...(cursor?.pending_events ?? []), ...recovered, ...added] };
181
+ ctx.write(cursorPath(ctx.company, snapshot.sourceRef), `${JSON.stringify(next, null, 2)}\n`);
182
+ await deliver(next);
183
+ return { ...result, itemsOut: added.length, detail: { new_segments: Math.max(0, snapshot.segmentCount - (cursor?.segment_count ?? 0)), signals_written: added.length } };
184
+ }
185
+ finally {
186
+ await lock.release();
187
+ }
188
+ },
189
+ };
190
+ }
191
+ //# sourceMappingURL=incremental.js.map
@@ -0,0 +1,11 @@
1
+ export interface StoredPriorSignal {
2
+ signal_id: string;
3
+ type: string;
4
+ title: string;
5
+ origin: string;
6
+ key: string;
7
+ }
8
+ export declare function priorSignalPath(hqRoot: string, company: string, key: string): string;
9
+ export declare function findPriorSignals(hqRoot: string, company: string, sourceRef: string, now?: Date, audienceKey?: string): StoredPriorSignal[];
10
+ export declare function normalizeSignalTitle(title: string): string;
11
+ //# sourceMappingURL=prior-signals.d.ts.map
@@ -0,0 +1,44 @@
1
+ /** Source-scoped lookup for the final pass. Index keys are untrusted vault data. */
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { splitFrontmatter } from "../hot-channels/signals-pass.js";
5
+ import { assertAllowedPath } from "./write-guard.js";
6
+ export function priorSignalPath(hqRoot, company, key) {
7
+ if (!/^signals\/(?:@[a-zA-Z0-9_-]+\/)?[a-z_]+\/[a-f0-9]{64}\.md$/.test(key))
8
+ throw new Error("Invalid prior signal key");
9
+ return assertAllowedPath(hqRoot, `companies/${company}/${key}`, { company });
10
+ }
11
+ export function findPriorSignals(hqRoot, company, sourceRef, now = new Date(), audienceKey) {
12
+ const result = new Map();
13
+ for (let days = 0; days < 14; days++) {
14
+ const date = new Date(now);
15
+ date.setUTCDate(date.getUTCDate() - days);
16
+ const index = assertAllowedPath(hqRoot, `companies/${company}/signals/_index/${date.toISOString().slice(0, 10)}.json`, { company });
17
+ if (!fs.existsSync(index))
18
+ continue;
19
+ const rows = JSON.parse(fs.readFileSync(index, "utf8"));
20
+ if (!Array.isArray(rows))
21
+ throw new Error("Invalid signal index");
22
+ for (const row of rows) {
23
+ if (!row || row.source_ref !== sourceRef || typeof row.key !== "string" || typeof row.type !== "string" || typeof row.signal_id !== "string" || !/^[a-f0-9]{64}$/.test(row.signal_id))
24
+ continue;
25
+ const file = priorSignalPath(hqRoot, company, row.key);
26
+ if (!fs.existsSync(file))
27
+ continue;
28
+ const { frontmatter: fm } = splitFrontmatter(fs.readFileSync(file, "utf8"));
29
+ if (fm.source_ref !== sourceRef || fm.signal_id !== row.signal_id || fm.type !== row.type || path.basename(file) !== `${row.signal_id}.md`)
30
+ continue;
31
+ if (audienceKey !== undefined && fm.audience_key !== audienceKey)
32
+ continue;
33
+ const title = typeof fm.title === "string" ? fm.title : fm.canonical_content;
34
+ if (typeof title !== "string" || !title.trim())
35
+ continue;
36
+ result.set(row.signal_id, { signal_id: row.signal_id, type: row.type, title, origin: typeof fm.origin === "string" ? fm.origin : "", key: row.key });
37
+ }
38
+ }
39
+ return [...result.values()];
40
+ }
41
+ export function normalizeSignalTitle(title) {
42
+ return title.toLowerCase().replace(/\s+/g, " ").trim().replace(/[\p{P}\s]+$/gu, "");
43
+ }
44
+ //# sourceMappingURL=prior-signals.js.map
@@ -106,8 +106,13 @@ export function runCountItems(record, summary, opts) {
106
106
  const breakdown = [...parts].map(([k, v]) => `${k} ${v}`).join(", ");
107
107
  items.push({ key: "Sources read", value: `${plural(read, "item")}${breakdown ? ` (${breakdown})` : ""}` });
108
108
  }
109
- if (extract)
109
+ if (extract) {
110
110
  items.push({ key: "Signals found", value: String(extract.itemsOut) });
111
+ const counts = extract.detail;
112
+ if (counts && ["new", "updated", "skipped"].every((k) => typeof counts[k] === "number")) {
113
+ items.push({ key: "Signal changes", value: `new: ${counts.new}, updated: ${counts.updated}, skipped: ${counts.skipped}` });
114
+ }
115
+ }
111
116
  const garden = steps(record, "garden").filter((s) => s.status !== "skipped");
112
117
  if (garden.length) {
113
118
  const tidied = garden.reduce((n, s) => n + s.itemsIn, 0);
@@ -7,4 +7,9 @@ export interface SignalsSettings {
7
7
  export declare const DEFAULT_SIGNALS_SETTINGS: SignalsSettings;
8
8
  export declare const SIGNALS_SETTINGS_REL: string;
9
9
  export declare function loadSignalsSettings(hqRoot: string): SignalsSettings;
10
+ /** Per-pass limits for a growing source; normal runs retain their own defaults. */
11
+ export declare function loadIncrementalSettings(hqRoot: string): {
12
+ maxTurns: number;
13
+ maxBudgetUsd: number;
14
+ };
10
15
  //# sourceMappingURL=settings.d.ts.map
@@ -45,4 +45,21 @@ export function loadSignalsSettings(hqRoot) {
45
45
  }
46
46
  return out;
47
47
  }
48
+ /** Per-pass limits for a growing source; normal runs retain their own defaults. */
49
+ export function loadIncrementalSettings(hqRoot) {
50
+ const out = { maxTurns: 12, maxBudgetUsd: 0.4 };
51
+ try {
52
+ const raw = yaml.load(fs.readFileSync(path.join(hqRoot, SIGNALS_SETTINGS_REL), "utf8"));
53
+ const v = raw?.incremental;
54
+ if (typeof v?.max_turns === "number" && VALID.maxTurns(v.max_turns))
55
+ out.maxTurns = v.max_turns;
56
+ if (typeof v?.max_budget_usd === "number" && VALID.maxBudgetUsd(v.max_budget_usd))
57
+ out.maxBudgetUsd = v.max_budget_usd;
58
+ }
59
+ catch (err) {
60
+ if (err.code !== "ENOENT")
61
+ throw err;
62
+ }
63
+ return out;
64
+ }
48
65
  //# sourceMappingURL=settings.js.map