instar 1.3.753 → 1.3.755

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 (39) hide show
  1. package/dist/commands/server.d.ts +30 -1
  2. package/dist/commands/server.d.ts.map +1 -1
  3. package/dist/commands/server.js +238 -19
  4. package/dist/commands/server.js.map +1 -1
  5. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  6. package/dist/config/ConfigDefaults.js +40 -0
  7. package/dist/config/ConfigDefaults.js.map +1 -1
  8. package/dist/core/ProfileIntentClassifier.d.ts +167 -0
  9. package/dist/core/ProfileIntentClassifier.d.ts.map +1 -0
  10. package/dist/core/ProfileIntentClassifier.js +421 -0
  11. package/dist/core/ProfileIntentClassifier.js.map +1 -0
  12. package/dist/core/componentCategories.d.ts.map +1 -1
  13. package/dist/core/componentCategories.js +11 -0
  14. package/dist/core/componentCategories.js.map +1 -1
  15. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  16. package/dist/core/devGatedFeatures.js +12 -0
  17. package/dist/core/devGatedFeatures.js.map +1 -1
  18. package/dist/core/topicProfileIngress.d.ts +14 -2
  19. package/dist/core/topicProfileIngress.d.ts.map +1 -1
  20. package/dist/core/topicProfileIngress.js +15 -44
  21. package/dist/core/topicProfileIngress.js.map +1 -1
  22. package/dist/data/llmBenchCoverage.d.ts.map +1 -1
  23. package/dist/data/llmBenchCoverage.js +15 -0
  24. package/dist/data/llmBenchCoverage.js.map +1 -1
  25. package/dist/threadline/HubIntentClassifier.d.ts +150 -0
  26. package/dist/threadline/HubIntentClassifier.d.ts.map +1 -0
  27. package/dist/threadline/HubIntentClassifier.js +322 -0
  28. package/dist/threadline/HubIntentClassifier.js.map +1 -0
  29. package/dist/threadline/hubCommands.d.ts +16 -12
  30. package/dist/threadline/hubCommands.d.ts.map +1 -1
  31. package/dist/threadline/hubCommands.js +16 -30
  32. package/dist/threadline/hubCommands.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/data/builtin-manifest.json +2 -2
  35. package/src/data/llmBenchCoverage.ts +16 -0
  36. package/upgrades/1.3.754.md +46 -0
  37. package/upgrades/1.3.755.md +75 -0
  38. package/upgrades/side-effects/keyword-intent-conversion-3-hubcommands.md +170 -0
  39. package/upgrades/side-effects/topicprofile-intent-llm-rebuild.md +87 -0
@@ -11,6 +11,7 @@ import { SessionManager } from '../core/SessionManager.js';
11
11
  import { TelegramAdapter } from '../messaging/TelegramAdapter.js';
12
12
  import { TopicMemory } from '../memory/TopicMemory.js';
13
13
  import { QuotaTracker } from '../monitoring/QuotaTracker.js';
14
+ import type { IntelligenceProvider, InstarConfig } from '../core/types.js';
14
15
  import { UserManager } from '../users/UserManager.js';
15
16
  /**
16
17
  * Pure: should the emergency "fix command" gate intercept this message?
@@ -38,7 +39,35 @@ interface StartOptions {
38
39
  * Commander maps --no-telegram to telegram: false. */
39
40
  telegram?: boolean;
40
41
  }
41
- export declare function wireTelegramRouting(telegram: TelegramAdapter, sessionManager: SessionManager, quotaTracker?: QuotaTracker, topicMemory?: TopicMemory, userManager?: UserManager, fixCommandHandler?: (topicId: number, text: string) => Promise<boolean>, getHubDeps?: () => import('../threadline/hubCommands.js').HubBindDeps | null, getTopicOperatorStore?: () => import('../users/TopicOperatorStore.js').TopicOperatorStore | null, getAttentionTopicId?: () => number | null | undefined): void;
42
+ /**
43
+ * Resolve the hub-intent classifier deps (Conversion #3,
44
+ * docs/specs/keyword-intent-conversions-1-and-3.md) from live config + the shared
45
+ * IntelligenceProvider. `enabled` is DEV-GATED (resolveDevAgentGate): DARK on the
46
+ * fleet, LIVE on a development agent. `dryRun` defaults true (the soak) so a
47
+ * dev-agent classifier RUNS but the message ALWAYS passes through until a
48
+ * deliberate `dryRun:false`. Passed as a late-bound getter so it re-reads config
49
+ * at message time.
50
+ */
51
+ export declare function resolveHubClassifierDeps(config: InstarConfig, intelligence: IntelligenceProvider | null): {
52
+ intelligence: IntelligenceProvider | null;
53
+ enabled: boolean;
54
+ dryRun: boolean;
55
+ minConfidence: number;
56
+ timeoutMs: number;
57
+ contextTurns: number;
58
+ modelTier: 'fast' | 'balanced' | 'capable';
59
+ stateDir: string;
60
+ };
61
+ export declare function wireTelegramRouting(telegram: TelegramAdapter, sessionManager: SessionManager, quotaTracker?: QuotaTracker, topicMemory?: TopicMemory, userManager?: UserManager, fixCommandHandler?: (topicId: number, text: string) => Promise<boolean>, getHubDeps?: () => import('../threadline/hubCommands.js').HubBindDeps | null, getTopicOperatorStore?: () => import('../users/TopicOperatorStore.js').TopicOperatorStore | null, getAttentionTopicId?: () => number | null | undefined, getHubClassifierDeps?: () => {
62
+ intelligence: import('../core/types.js').IntelligenceProvider | null;
63
+ enabled: boolean;
64
+ dryRun: boolean;
65
+ minConfidence: number;
66
+ timeoutMs: number;
67
+ contextTurns: number;
68
+ modelTier: 'fast' | 'balanced' | 'capable';
69
+ stateDir: string;
70
+ } | null): void;
42
71
  export declare function startServer(options: StartOptions): Promise<void>;
43
72
  export declare function stopServer(options: {
44
73
  dir?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAiDH,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAU3D,OAAO,EAAE,eAAe,EAAiC,MAAM,iCAAiC,CAAC;AAyBjG,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAGvD,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AA8H7D,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAgFtD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,gBAAgB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC1C,OAAO,CAUT;AAyID,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA2/CD,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,eAAe,EACzB,cAAc,EAAE,cAAc,EAC9B,YAAY,CAAC,EAAE,YAAY,EAC3B,WAAW,CAAC,EAAE,WAAW,EACzB,WAAW,CAAC,EAAE,WAAW,EACzB,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,EAGvE,UAAU,CAAC,EAAE,MAAM,OAAO,8BAA8B,EAAE,WAAW,GAAG,IAAI,EAK5E,qBAAqB,CAAC,EAAE,MAAM,OAAO,gCAAgC,EAAE,kBAAkB,GAAG,IAAI,EAKhG,mBAAmB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,GACpD,IAAI,CAijBN;AA2lBD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA4ylBtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAuDH,OAAO,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAC;AAU3D,OAAO,EAAE,eAAe,EAAiC,MAAM,iCAAiC,CAAC;AAyBjG,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAGvD,OAAO,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AA6H7D,OAAO,KAAK,EAAW,oBAAoB,EAAe,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACjG,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAgFtD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,gBAAgB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC1C,OAAO,CAUT;AAyID,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAgmDD;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,MAAM,EAAE,YAAY,EACpB,YAAY,EAAE,oBAAoB,GAAG,IAAI,GACxC;IACD,YAAY,EAAE,oBAAoB,GAAG,IAAI,CAAC;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB,CAqBA;AAED,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,eAAe,EACzB,cAAc,EAAE,cAAc,EAC9B,YAAY,CAAC,EAAE,YAAY,EAC3B,WAAW,CAAC,EAAE,WAAW,EACzB,WAAW,CAAC,EAAE,WAAW,EACzB,iBAAiB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,EAGvE,UAAU,CAAC,EAAE,MAAM,OAAO,8BAA8B,EAAE,WAAW,GAAG,IAAI,EAK5E,qBAAqB,CAAC,EAAE,MAAM,OAAO,gCAAgC,EAAE,kBAAkB,GAAG,IAAI,EAKhG,mBAAmB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,EAQrD,oBAAoB,CAAC,EAAE,MAAM;IAC3B,YAAY,EAAE,OAAO,kBAAkB,EAAE,oBAAoB,GAAG,IAAI,CAAC;IACrE,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB,GAAG,IAAI,GACP,IAAI,CAonBN;AA2lBD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAs0lBtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
@@ -32,6 +32,7 @@ import { PrHandLease } from '../core/PrHandLease.js';
32
32
  import { ConversationRegistry } from '../core/ConversationRegistry.js';
33
33
  import { claimSuspensionExcludesPin } from '../core/TopicClaimAnnotationStore.js';
34
34
  import { parseProfileTrigger, platformMessageIdFrom } from '../core/topicProfileIngress.js';
35
+ import { classifyProfileIntent, toProfilePatch, } from '../core/ProfileIntentClassifier.js';
35
36
  import { slugifyChannelName } from '../messaging/slack/sanitize.js';
36
37
  import { TopicProfileOrchestrator, resolvedToApplied, } from '../core/TopicProfileOrchestrator.js';
37
38
  import { CodexResumeMap } from '../core/CodexResumeMap.js';
@@ -704,6 +705,20 @@ function appendTopicProfileAudit(stateDir, event) {
704
705
  }
705
706
  let _projectDir = process.cwd();
706
707
  let _sharedIntelligence = null;
708
+ // Offender #1 conversion (docs/specs/keyword-intent-conversions-1-and-3.md): the
709
+ // framework/model/thinking WRITE decision is inferred by the ProfileIntentClassifier
710
+ // (LLM + recent conversation), replacing the removed keyword regexes in
711
+ // parseProfileTrigger. Dev-gated dark (resolveDevAgentGate) + dry-run FIRST: while
712
+ // dark or dry-run the message ALWAYS passes through (never actuated); the classifier
713
+ // still runs on a dev agent and LOGS would-actuate vs would-pass to
714
+ // logs/profile-intent.jsonl for the soak. Resolved once in startServer.
715
+ let _profileIntentEnabled = false;
716
+ let _profileIntentDryRun = true;
717
+ let _profileIntentMinConfidence = 0.85;
718
+ let _profileIntentTimeoutMs = 4000;
719
+ let _profileIntentContextTurns = 6;
720
+ let _profileIntentModelTier = 'fast';
721
+ let _profileIntentStateDir = '';
707
722
  let _selfKnowledgeTree = null;
708
723
  let _slackAdapter = null;
709
724
  // WS1.1 dispatch-to-owner (Slack arm): the owner-side bridge reconstructs a Slack
@@ -1669,6 +1684,42 @@ function messageToPipeline(msg, topicName) {
1669
1684
  * a message carrying platform forward metadata falls through as normal
1670
1685
  * conversation regardless of sender.
1671
1686
  */
1687
+ /**
1688
+ * Offender #1 conversion soak log: one JSONL line per LLM-engaged
1689
+ * ProfileIntentClassifier decision (docs/specs/keyword-intent-conversions-1-and-3.md).
1690
+ * A prefilter-skip is always a would-pass and the bulk of traffic — skipping it
1691
+ * cuts volume AND the privacy surface to messages that named a profile signal and
1692
+ * actually reached the classifier. Observability only — never gates the message.
1693
+ */
1694
+ function logProfileIntent(topicId, text, result, acted) {
1695
+ try {
1696
+ if (result.source === 'prefilter-skip')
1697
+ return;
1698
+ if (!_profileIntentStateDir)
1699
+ return;
1700
+ const logDir = path.join(_profileIntentStateDir, 'logs');
1701
+ fs.mkdirSync(logDir, { recursive: true });
1702
+ const decision = result.isChange ? (acted ? 'actuate' : 'would-actuate') : 'pass';
1703
+ const line = JSON.stringify({
1704
+ ts: new Date().toISOString(),
1705
+ topicId,
1706
+ decision,
1707
+ dryRun: _profileIntentDryRun,
1708
+ source: result.source,
1709
+ intent: result.intent,
1710
+ value: result.value,
1711
+ confidence: result.confidence,
1712
+ reason: result.reason,
1713
+ // NO raw message content: the enum-bounded fields above are what the FP-rate
1714
+ // soak needs, and the resolved `value` is already a closed-enum member. We
1715
+ // record only the message LENGTH (non-content) — mirroring the codebase's
1716
+ // dev-soak convention of never persisting a raw operator quote.
1717
+ textLen: typeof text === 'string' ? text.length : 0,
1718
+ });
1719
+ fs.appendFileSync(path.join(logDir, 'profile-intent.jsonl'), line + '\n');
1720
+ }
1721
+ catch { /* @silent-fallback-ok: the soak audit is observability-only — a log write fault must never gate or throw out of the message path (the classifier decision already stands) */ }
1722
+ }
1672
1723
  async function handleTopicProfileIngress(telegram, topicId, text, telegramUserId, msg) {
1673
1724
  const surface = _topicProfileWriteSurface;
1674
1725
  const slots = _topicProfileConfirmSlots;
@@ -1683,10 +1734,65 @@ async function handleTopicProfileIngress(telegram, topicId, text, telegramUserId
1683
1734
  catch { /* @silent-fallback-ok: a trust-floor read fault fails toward NOT-a-trigger (deny-by-default) — the conversational profile parse never runs for an unauthorized turn (TOPIC-PROFILE-SPEC §10.1) */ }
1684
1735
  if (!authorized)
1685
1736
  return false;
1686
- const trigger = parseProfileTrigger(text);
1687
- if (!trigger)
1688
- return false;
1689
1737
  const forwarded = msg.metadata?.forwarded === true;
1738
+ const trigger = parseProfileTrigger(text);
1739
+ if (!trigger) {
1740
+ // Offender #1 conversion (docs/specs/keyword-intent-conversions-1-and-3.md):
1741
+ // the framework/model/thinking WRITE decision moved OUT of parseProfileTrigger
1742
+ // (keyword regexes) into the ProfileIntentClassifier — an LLM reasoning over
1743
+ // the message + recent conversation with structured-enum output, per the
1744
+ // constitutional standard "Intelligence Infers, Keywords Only Guard".
1745
+ // Dev-gated dark + dry-run FIRST; fail-OPEN on every uncertainty.
1746
+ if (forwarded)
1747
+ return false; // forwarded content is never a command
1748
+ if (!_profileIntentEnabled)
1749
+ return false; // fleet-dark → pass to the agent
1750
+ let conversationContext = [];
1751
+ try {
1752
+ // Fetch one extra turn, then drop a trailing turn equal to THIS message —
1753
+ // getTopicHistory (oldest→newest) usually already contains the just-logged
1754
+ // inbound row, so without this the model would see the message twice (once
1755
+ // as MESSAGE, once as the newest CONTEXT turn).
1756
+ const hist = telegram.getTopicHistory?.(topicId, _profileIntentContextTurns + 1) ?? [];
1757
+ conversationContext = hist.map((h) => ({ fromUser: !!h.fromUser, text: String(h.text ?? '') }));
1758
+ const trimmed = text.trim();
1759
+ while (conversationContext.length > 0
1760
+ && conversationContext[conversationContext.length - 1].fromUser
1761
+ && conversationContext[conversationContext.length - 1].text.trim() === trimmed) {
1762
+ conversationContext.pop();
1763
+ }
1764
+ conversationContext = conversationContext.slice(-_profileIntentContextTurns);
1765
+ }
1766
+ catch { /* @silent-fallback-ok — context is best-effort; classify on the message alone */ }
1767
+ const result = await classifyProfileIntent({
1768
+ text,
1769
+ conversationContext,
1770
+ intelligence: _sharedIntelligence,
1771
+ minConfidence: _profileIntentMinConfidence,
1772
+ timeoutMs: _profileIntentTimeoutMs,
1773
+ maxContextTurns: _profileIntentContextTurns,
1774
+ modelTier: _profileIntentModelTier,
1775
+ });
1776
+ const willAct = result.isChange && !_profileIntentDryRun;
1777
+ logProfileIntent(topicId, text, result, willAct);
1778
+ // Dry-run (the soak) / fail-open / pass-through: log would-actuate vs
1779
+ // would-pass, but pass the message through untouched. Real actuation needs a
1780
+ // deliberate dryRun:false, AND fail-open means uncertainty never actuates.
1781
+ if (!willAct)
1782
+ return false;
1783
+ const patch = toProfilePatch(result);
1784
+ if (!patch)
1785
+ return false;
1786
+ const applyPrincipal = { kind: 'operator', platform: 'telegram', uid: String(telegramUserId) };
1787
+ const applyResult = await surface.applyWrite({
1788
+ topicKey: String(topicId), patch, principal: applyPrincipal, origin: 'conversational', discloseInReply: true,
1789
+ });
1790
+ try {
1791
+ await telegram.sendToTopic(topicId, applyResult.reply);
1792
+ }
1793
+ catch { /* @silent-fallback-ok: best-effort disclosure send (TOPIC-PROFILE-SPEC §10.1) */ }
1794
+ return true;
1795
+ }
1690
1796
  if (forwarded)
1691
1797
  return false;
1692
1798
  const topicKey = String(topicId);
@@ -1833,6 +1939,28 @@ async function handleProfileConfirm(telegram, surface, slots, topicId, principal
1833
1939
  await send('That switch is no longer pending.');
1834
1940
  return true;
1835
1941
  }
1942
+ /**
1943
+ * Resolve the hub-intent classifier deps (Conversion #3,
1944
+ * docs/specs/keyword-intent-conversions-1-and-3.md) from live config + the shared
1945
+ * IntelligenceProvider. `enabled` is DEV-GATED (resolveDevAgentGate): DARK on the
1946
+ * fleet, LIVE on a development agent. `dryRun` defaults true (the soak) so a
1947
+ * dev-agent classifier RUNS but the message ALWAYS passes through until a
1948
+ * deliberate `dryRun:false`. Passed as a late-bound getter so it re-reads config
1949
+ * at message time.
1950
+ */
1951
+ export function resolveHubClassifierDeps(config, intelligence) {
1952
+ const cfg = config.threadline?.hubIntent;
1953
+ return {
1954
+ intelligence,
1955
+ enabled: resolveDevAgentGate(cfg?.enabled, config),
1956
+ dryRun: cfg?.dryRun ?? true,
1957
+ minConfidence: cfg?.minConfidence ?? 0.85,
1958
+ timeoutMs: cfg?.timeoutMs ?? 4000,
1959
+ contextTurns: cfg?.contextWindowTurns ?? 6,
1960
+ modelTier: cfg?.modelTier ?? 'fast',
1961
+ stateDir: config.stateDir,
1962
+ };
1963
+ }
1836
1964
  export function wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManager, fixCommandHandler,
1837
1965
  // Late-bound: the threadline hub deps are constructed AFTER this is wired, so
1838
1966
  // resolve them at message-time (CMT-529 deterministic "open this" intercept).
@@ -1846,7 +1974,15 @@ getTopicOperatorStore,
1846
1974
  // fix-command gate (below) only fires in that topic; everywhere else a
1847
1975
  // message starting with restart/fix/clean is normal conversation and must
1848
1976
  // route to the session instead of being swallowed.
1849
- getAttentionTopicId) {
1977
+ getAttentionTopicId,
1978
+ // Late-bound (same reason as getHubDeps): the hub-intent classifier deps —
1979
+ // the config-resolved dev-gate/settings + the shared IntelligenceProvider.
1980
+ // Conversion #3 (docs/specs/keyword-intent-conversions-1-and-3.md): the
1981
+ // "open this"/"tie this to <topic>" DECISION is inferred by an LLM over the
1982
+ // message + recent conversation (HubIntentClassifier), NOT a regex — the
1983
+ // regex SWALLOWED the message before the agent saw it, so a misread ATE it.
1984
+ // Resolved at the callsite where config + sharedIntelligence are in scope.
1985
+ getHubClassifierDeps) {
1850
1986
  // Guard: tracks which topic IDs have a spawn in progress.
1851
1987
  // Prevents duplicate concurrent spawns for the same topic when messages
1852
1988
  // arrive faster than the async spawn completes.
@@ -1942,10 +2078,19 @@ getAttentionTopicId) {
1942
2078
  return;
1943
2079
  }
1944
2080
  // ── Threadline hub commands: "open this" / "tie this to <topic>" (CMT-529) ──
1945
- // When a message in the Threadline HUB topic is a deterministic hub command,
1946
- // bind the conversation to a topic STRUCTURALLY — before any agent interprets
1947
- // it. This onTopicMessage seam is the convergence both inbound paths reach
2081
+ // When a message in the Threadline HUB topic is a bind command, bind the
2082
+ // conversation to a topic STRUCTURALLY — before any agent interprets it. This
2083
+ // onTopicMessage seam is the convergence both inbound paths reach
1948
2084
  // (lifeline-forward AND server-polling), so one intercept covers both modes.
2085
+ //
2086
+ // Conversion #3 (docs/specs/keyword-intent-conversions-1-and-3.md): the
2087
+ // "is this a bind command?" DECISION is inferred by an LLM over the message +
2088
+ // recent conversation (HubIntentClassifier), NOT a regex. The old regex
2089
+ // SWALLOWED the message before the agent saw it, so a misread silently ATE a
2090
+ // real message. Dev-gated dark (resolveDevAgentGate at the callsite) +
2091
+ // dry-run FIRST: while dark or dry-run the message ALWAYS passes through
2092
+ // (never swallowed); the classifier still runs on a dev agent and LOGS
2093
+ // would-swallow vs would-pass for the soak. Fail-OPEN on any uncertainty.
1949
2094
  // FAIL-OPEN: any error falls through to normal routing.
1950
2095
  const hubDeps = getHubDeps?.() ?? null;
1951
2096
  if (hubDeps) {
@@ -1958,17 +2103,78 @@ getAttentionTopicId) {
1958
2103
  hubTopicId = undefined;
1959
2104
  }
1960
2105
  if (hubTopicId !== undefined && Number(topicId) === Number(hubTopicId)) {
1961
- const { parseHubCommand, bindHubConversation } = await import('../threadline/hubCommands.js');
1962
- const cmd = parseHubCommand(text);
1963
- if (cmd) {
1964
- const result = await bindHubConversation(hubDeps, { ...cmd, autoPick: true }); // human "open this" → auto-pick most-recent, never 409
1965
- if (!result.ok) {
1966
- await telegram.sendToTopic(topicId, result.status === 404
1967
- ? 'Nothing waiting in the hub to open right now.'
1968
- : `Couldn't open that ${result.error}`).catch(() => { });
2106
+ const clsDeps = getHubClassifierDeps?.() ?? null;
2107
+ // Dark gate: when the classifier isn't enabled (fleet default), never
2108
+ // swallow — pass the message through to the agent (no regex fallback).
2109
+ if (clsDeps?.enabled) {
2110
+ const { bindHubConversation } = await import('../threadline/hubCommands.js');
2111
+ const { classifyHubIntent, toHubCommand } = await import('../threadline/HubIntentClassifier.js');
2112
+ // The enum of bindable topics (real existing topics) — the model can
2113
+ // only tie to one of these (structured-output guardrail).
2114
+ let bindableTopics = [];
2115
+ try {
2116
+ bindableTopics = telegram.getAllTopicMappings()
2117
+ .map((m) => ({ topicId: m.topicId, topicName: m.topicName ?? `topic ${m.topicId}` }));
2118
+ }
2119
+ catch { /* best-effort — an empty enum simply means only "open" is possible */ }
2120
+ // Bounded window of recent turns so context-dependent commands ("yes,
2121
+ // tie it to that one") can resolve their target. Best-effort.
2122
+ let conversationContext = [];
2123
+ try {
2124
+ const hist = telegram.getTopicHistory(topicId, clsDeps.contextTurns) ?? [];
2125
+ conversationContext = hist.map((h) => ({ fromUser: !!h.fromUser, text: String(h.text ?? '') }));
2126
+ }
2127
+ catch { /* context is best-effort; classify on the message alone */ }
2128
+ const result = await classifyHubIntent({
2129
+ text,
2130
+ bindableTopics,
2131
+ conversationContext,
2132
+ intelligence: clsDeps.intelligence,
2133
+ minConfidence: clsDeps.minConfidence,
2134
+ timeoutMs: clsDeps.timeoutMs,
2135
+ maxContextTurns: clsDeps.contextTurns,
2136
+ modelTier: clsDeps.modelTier,
2137
+ });
2138
+ const willAct = result.isCommand && !clsDeps.dryRun;
2139
+ // Audit the soak: log LLM-engaged decisions (a prefilter-skip is the
2140
+ // bulk of traffic and always a would-pass — skipping it cuts volume
2141
+ // AND the message-preview privacy surface). Never gates the message.
2142
+ if (result.source !== 'prefilter-skip') {
2143
+ try {
2144
+ const logDir = path.join(clsDeps.stateDir, 'logs');
2145
+ fs.mkdirSync(logDir, { recursive: true });
2146
+ const decision = result.isCommand ? (willAct ? 'swallow' : 'would-swallow') : 'pass';
2147
+ fs.appendFileSync(path.join(logDir, 'hub-intent.jsonl'), JSON.stringify({
2148
+ ts: new Date().toISOString(),
2149
+ topicId,
2150
+ decision,
2151
+ dryRun: clsDeps.dryRun,
2152
+ source: result.source,
2153
+ intent: result.intent,
2154
+ targetTopicId: result.targetTopicId,
2155
+ confidence: result.confidence,
2156
+ reason: result.reason,
2157
+ textPreview: String(text).replace(/\s+/g, ' ').trim().slice(0, 80),
2158
+ }) + '\n');
2159
+ }
2160
+ catch { /* audit is observability — never gates the message path */ }
2161
+ }
2162
+ // Dry-run (the soak) OR not-a-command OR fail-open → pass the message
2163
+ // through untouched. Real swallowing needs a deliberate dryRun:false,
2164
+ // AND fail-open means uncertainty never swallows.
2165
+ if (willAct) {
2166
+ const cmd = toHubCommand(result);
2167
+ if (cmd) {
2168
+ const bindResult = await bindHubConversation(hubDeps, { ...cmd, autoPick: true }); // human "open this" → auto-pick most-recent, never 409
2169
+ if (!bindResult.ok) {
2170
+ await telegram.sendToTopic(topicId, bindResult.status === 404
2171
+ ? 'Nothing waiting in the hub to open right now.'
2172
+ : `Couldn't open that — ${bindResult.error}`).catch(() => { });
2173
+ }
2174
+ // On success bindHubConversation already posted the hub confirmation.
2175
+ return; // structural: never inject the command into a session
2176
+ }
1969
2177
  }
1970
- // On success bindHubConversation already posted the hub confirmation.
1971
- return; // structural: never inject the command into a session
1972
2178
  }
1973
2179
  }
1974
2180
  }
@@ -5229,6 +5435,19 @@ export async function startServer(options) {
5229
5435
  const { TopicProfileResolver } = await import('../core/TopicProfileResolver.js');
5230
5436
  const { normalizeTierEscalationConfig } = await import('../core/ModelTierEscalation.js');
5231
5437
  const topicProfilesCfg = config.topicProfiles;
5438
+ // Offender #1 conversion: resolve the ProfileIntentClassifier settings ONCE
5439
+ // (the module-level ingress handler has no config in scope). `enabled` rides
5440
+ // resolveDevAgentGate (dark on the fleet, live on a dev agent — registered in
5441
+ // DEV_GATED_FEATURES, configPath topicProfiles.intentClassifier.enabled);
5442
+ // dryRun ships true (the dry-run canary — actuates only on a deliberate flip).
5443
+ const _intentCfg = topicProfilesCfg?.intentClassifier;
5444
+ _profileIntentEnabled = resolveDevAgentGate(_intentCfg?.enabled, config);
5445
+ _profileIntentDryRun = _intentCfg?.dryRun ?? true;
5446
+ _profileIntentMinConfidence = _intentCfg?.minConfidence ?? 0.85;
5447
+ _profileIntentTimeoutMs = _intentCfg?.timeoutMs ?? 4000;
5448
+ _profileIntentContextTurns = _intentCfg?.contextWindowTurns ?? 6;
5449
+ _profileIntentModelTier = _intentCfg?.modelTier ?? 'fast';
5450
+ _profileIntentStateDir = config.stateDir;
5232
5451
  _topicProfileStore = new TopicProfileStore({
5233
5452
  stateFilePath: path.join(config.stateDir, 'state', 'topic-profiles.json'),
5234
5453
  legacyFrameworksPath: path.join(config.stateDir, 'state', 'topic-frameworks.json'),
@@ -6198,7 +6417,7 @@ export async function startServer(options) {
6198
6417
  const userManagerSendOnly = new UserManager(config.stateDir, config.users);
6199
6418
  attachUserReplication(userManagerSendOnly); // WS2.6 send-side (dark by default)
6200
6419
  _fixDeps = { state, liveConfig, sessionManager, telegram, config };
6201
- wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManagerSendOnly, (topicId, text) => handleFixCommand(topicId, text, _fixDeps), () => (collaborationSurfacer && conversationStore && telegram) ? { collaborationSurfacer, conversationStore, commitmentTracker, telegram, brief: briefDeps } : null, () => _agentServerRef?.getTopicOperatorStore() ?? null, () => state.get('agent-attention-topic'));
6420
+ wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManagerSendOnly, (topicId, text) => handleFixCommand(topicId, text, _fixDeps), () => (collaborationSurfacer && conversationStore && telegram) ? { collaborationSurfacer, conversationStore, commitmentTracker, telegram, brief: briefDeps } : null, () => _agentServerRef?.getTopicOperatorStore() ?? null, () => state.get('agent-attention-topic'), () => resolveHubClassifierDeps(config, _sharedIntelligence));
6202
6421
  wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, undefined, config.sessions.claudePath, topicMemory);
6203
6422
  console.log(pc.green(' Telegram routing + command callbacks wired (send-only)'));
6204
6423
  }
@@ -6330,7 +6549,7 @@ export async function startServer(options) {
6330
6549
  config,
6331
6550
  };
6332
6551
  // Wire up topic → session routing and session management callbacks
6333
- wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManager, (topicId, text) => handleFixCommand(topicId, text, _fixDeps), () => (collaborationSurfacer && conversationStore && telegram) ? { collaborationSurfacer, conversationStore, commitmentTracker, telegram, brief: briefDeps } : null, () => _agentServerRef?.getTopicOperatorStore() ?? null, () => state.get('agent-attention-topic'));
6552
+ wireTelegramRouting(telegram, sessionManager, quotaTracker, topicMemory, userManager, (topicId, text) => handleFixCommand(topicId, text, _fixDeps), () => (collaborationSurfacer && conversationStore && telegram) ? { collaborationSurfacer, conversationStore, commitmentTracker, telegram, brief: briefDeps } : null, () => _agentServerRef?.getTopicOperatorStore() ?? null, () => state.get('agent-attention-topic'), () => resolveHubClassifierDeps(config, _sharedIntelligence));
6334
6553
  wireTelegramCallbacks(telegram, sessionManager, state, quotaTracker, accountSwitcher, config.sessions.claudePath, topicMemory);
6335
6554
  // Wire up unknown-user handling (Multi-User Setup Wizard Phase 4.5)
6336
6555
  telegram.onGetRegistrationPolicy = () => ({