@rubytech/create-maxy-code 0.1.498 → 0.1.499

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 (41) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/active-rules/dist/index.d.ts +3 -7
  3. package/payload/platform/lib/active-rules/dist/index.d.ts.map +1 -1
  4. package/payload/platform/lib/active-rules/dist/index.js +20 -52
  5. package/payload/platform/lib/active-rules/dist/index.js.map +1 -1
  6. package/payload/platform/lib/active-rules/src/index.test.ts +35 -69
  7. package/payload/platform/lib/active-rules/src/index.ts +17 -58
  8. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -3
  9. package/payload/platform/plugins/docs/references/internals.md +2 -2
  10. package/payload/platform/plugins/email/mcp/dist/__tests__/compose-signature.test.d.ts +2 -0
  11. package/payload/platform/plugins/email/mcp/dist/__tests__/compose-signature.test.d.ts.map +1 -0
  12. package/payload/platform/plugins/email/mcp/dist/__tests__/compose-signature.test.js +53 -0
  13. package/payload/platform/plugins/email/mcp/dist/__tests__/compose-signature.test.js.map +1 -0
  14. package/payload/platform/plugins/email/mcp/dist/__tests__/signature.test.d.ts +2 -0
  15. package/payload/platform/plugins/email/mcp/dist/__tests__/signature.test.d.ts.map +1 -0
  16. package/payload/platform/plugins/email/mcp/dist/__tests__/signature.test.js +50 -0
  17. package/payload/platform/plugins/email/mcp/dist/__tests__/signature.test.js.map +1 -0
  18. package/payload/platform/plugins/email/mcp/dist/lib/compose.d.ts.map +1 -1
  19. package/payload/platform/plugins/email/mcp/dist/lib/compose.js +15 -2
  20. package/payload/platform/plugins/email/mcp/dist/lib/compose.js.map +1 -1
  21. package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts +29 -0
  22. package/payload/platform/plugins/email/mcp/dist/lib/signature.d.ts.map +1 -0
  23. package/payload/platform/plugins/email/mcp/dist/lib/signature.js +55 -0
  24. package/payload/platform/plugins/email/mcp/dist/lib/signature.js.map +1 -0
  25. package/payload/platform/plugins/email/references/email-reference.md +10 -0
  26. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-read-active-rules.test.js +10 -9
  27. package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-read-active-rules.test.js.map +1 -1
  28. package/payload/platform/plugins/memory/skills/conversational-memory/SKILL.md +2 -0
  29. package/payload/platform/plugins/memory/skills/preference-audit/SKILL.md +16 -0
  30. package/payload/platform/services/claude-session-manager/dist/preference-reconciliation-audit.d.ts +19 -19
  31. package/payload/platform/services/claude-session-manager/dist/preference-reconciliation-audit.d.ts.map +1 -1
  32. package/payload/platform/services/claude-session-manager/dist/preference-reconciliation-audit.js +28 -34
  33. package/payload/platform/services/claude-session-manager/dist/preference-reconciliation-audit.js.map +1 -1
  34. package/payload/server/public/assets/{chat-qtOiJX-Y.js → chat-MvnxPJZ5.js} +1 -1
  35. package/payload/server/public/assets/{operator-icJwMkZF.js → operator-DCNWUKP_.js} +1 -1
  36. package/payload/server/public/assets/{page-iG57nKOg.js → page-C4TrTU6r.js} +1 -1
  37. package/payload/server/public/assets/{public-CkOumzpf.js → public-DXJXQPwe.js} +1 -1
  38. package/payload/server/public/chat.html +2 -2
  39. package/payload/server/public/operator.html +2 -2
  40. package/payload/server/public/public.html +2 -2
  41. package/payload/server/server.js +47 -57
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.498",
3
+ "version": "0.1.499",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -1,7 +1,5 @@
1
1
  export declare const SAME_SUBJECT_COSINE = 0.85;
2
- export declare const INJECTION_RULE_CAP = 12;
3
2
  export declare const ACTIVE_CANDIDATE_FLOOR = 0.4;
4
- export declare const ACTIVE_CANDIDATE_LIMIT = 30;
5
3
  export interface RuleCandidate {
6
4
  preferenceId: string;
7
5
  category: string;
@@ -19,9 +17,7 @@ export interface ActiveRule {
19
17
  confidence: number;
20
18
  observedAt: string;
21
19
  }
22
- export declare function cosine(a: number[], b: number[]): number;
23
- export declare function deconflictBySubject(records: RuleCandidate[], cosineThreshold?: number): ActiveRule[];
24
- export declare function formatStandingRulesBlock(rules: ActiveRule[], cap?: number): string;
20
+ export declare function formatStandingRulesBlock(rules: ActiveRule[]): string;
25
21
  interface MinimalSession {
26
22
  run: (q: string, p: Record<string, unknown>) => Promise<{
27
23
  records: Array<{
@@ -43,7 +39,7 @@ export interface StandingRulesInjection {
43
39
  }
44
40
  export declare function resolveOwnerUserId(session: MinimalSession, accountId: string): Promise<string | null>;
45
41
  export declare function queryActiveRuleCandidates(session: MinimalSession, accountId: string): Promise<RuleCandidate[]>;
46
- export declare function resolveActiveRules(session: MinimalSession, accountId: string, cap?: number): Promise<ActiveRuleResolution>;
47
- export declare function activeRuleSet(session: MinimalSession, accountId: string, cap?: number): Promise<ActiveRule[]>;
42
+ export declare function resolveActiveRules(session: MinimalSession, accountId: string): Promise<ActiveRuleResolution>;
43
+ export declare function activeRuleSet(session: MinimalSession, accountId: string): Promise<ActiveRule[]>;
48
44
  export {};
49
45
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,mBAAmB,OAAO,CAAC;AACxC,eAAO,MAAM,kBAAkB,KAAK,CAAC;AACrC,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAC1C,eAAO,MAAM,sBAAsB,KAAK,CAAC;AAEzC,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,MAAM,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,CAYvD;AAOD,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,aAAa,EAAE,EACxB,eAAe,GAAE,MAA4B,GAC5C,UAAU,EAAE,CAgBd;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,UAAU,EAAE,EACnB,GAAG,GAAE,MAA2B,GAC/B,MAAM,CAIR;AAED,UAAU,cAAc;IACtB,GAAG,EAAE,CACH,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACvB,OAAO,CAAC;QAAE,OAAO,EAAE,KAAK,CAAC;YAAE,GAAG,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/D;AAMD,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,UAAU,GAAG,aAAa,CAAC;AACpE,MAAM,MAAM,eAAe,GAAG,gBAAgB,GAAG,aAAa,CAAC;AAE/D,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAID,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,EAAE,eAAe,CAAC;CACzB;AAOD,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAaxB;AAoED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,aAAa,EAAE,CAAC,CAE1B;AAGD,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,GAAG,GAAE,MAA2B,GAC/B,OAAO,CAAC,oBAAoB,CAAC,CAG/B;AAID,wBAAsB,aAAa,CACjC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,EACjB,GAAG,GAAE,MAA2B,GAC/B,OAAO,CAAC,UAAU,EAAE,CAAC,CAEvB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,mBAAmB,OAAO,CAAC;AACxC,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C,MAAM,WAAW,aAAa;IAC5B,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,UAAU;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAKD,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,UAAU,EAAE,GAAG,MAAM,CAIpE;AAED,UAAU,cAAc;IACtB,GAAG,EAAE,CACH,CAAC,EAAE,MAAM,EACT,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KACvB,OAAO,CAAC;QAAE,OAAO,EAAE,KAAK,CAAC;YAAE,GAAG,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC,CAAC;CAC/D;AAMD,MAAM,MAAM,gBAAgB,GAAG,OAAO,GAAG,UAAU,GAAG,aAAa,CAAC;AACpE,MAAM,MAAM,eAAe,GAAG,gBAAgB,GAAG,aAAa,CAAC;AAE/D,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;CAC1B;AAID,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,MAAM,EAAE,eAAe,CAAC;CACzB;AAOD,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAaxB;AAgED,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,aAAa,EAAE,CAAC,CAE1B;AAKD,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,oBAAoB,CAAC,CAI/B;AAID,wBAAsB,aAAa,CACjC,OAAO,EAAE,cAAc,EACvB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,UAAU,EAAE,CAAC,CAEvB"}
@@ -5,56 +5,25 @@
5
5
  // free of neo4j-driver types so both an MCP process and the UI can import the
6
6
  // built dist.
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.ACTIVE_CANDIDATE_LIMIT = exports.ACTIVE_CANDIDATE_FLOOR = exports.INJECTION_RULE_CAP = exports.SAME_SUBJECT_COSINE = void 0;
9
- exports.cosine = cosine;
10
- exports.deconflictBySubject = deconflictBySubject;
8
+ exports.ACTIVE_CANDIDATE_FLOOR = exports.SAME_SUBJECT_COSINE = void 0;
11
9
  exports.formatStandingRulesBlock = formatStandingRulesBlock;
12
10
  exports.resolveOwnerUserId = resolveOwnerUserId;
13
11
  exports.queryActiveRuleCandidates = queryActiveRuleCandidates;
14
12
  exports.resolveActiveRules = resolveActiveRules;
15
13
  exports.activeRuleSet = activeRuleSet;
14
+ // Write-time only: profile-update routes a same-subject write (a neighbour at
15
+ // or above this raw-cosine threshold) into the existing node instead of minting
16
+ // a duplicate. Injection no longer dedups — it surfaces every stored active
17
+ // preference verbatim (Task 1951) — so this constant has no read-side use.
16
18
  exports.SAME_SUBJECT_COSINE = 0.85;
17
- exports.INJECTION_RULE_CAP = 12;
18
19
  exports.ACTIVE_CANDIDATE_FLOOR = 0.4;
19
- exports.ACTIVE_CANDIDATE_LIMIT = 30;
20
- function cosine(a, b) {
21
- if (!a.length || !b.length || a.length !== b.length)
22
- return 0;
23
- let dot = 0;
24
- let na = 0;
25
- let nb = 0;
26
- for (let i = 0; i < a.length; i++) {
27
- dot += a[i] * b[i];
28
- na += a[i] * a[i];
29
- nb += b[i] * b[i];
30
- }
31
- if (na === 0 || nb === 0)
32
- return 0;
33
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
34
- }
35
- // records MUST be confidence-desc on entry. Greedy: the first record of a
36
- // cluster is the winner; any later record within cosineThreshold of an
37
- // already-kept winner is a rephrasing and is dropped. A record with no usable
38
- // embedding is never folded — it is kept as its own subject (we cannot prove it
39
- // is a duplicate).
40
- function deconflictBySubject(records, cosineThreshold = exports.SAME_SUBJECT_COSINE) {
41
- const winners = [];
42
- for (const r of records) {
43
- const emb = r.embedding;
44
- const isDup = Array.isArray(emb) &&
45
- emb.length > 0 &&
46
- winners.some((w) => Array.isArray(w.embedding) &&
47
- w.embedding.length > 0 &&
48
- cosine(emb, w.embedding) >= cosineThreshold);
49
- if (!isDup)
50
- winners.push(r);
51
- }
52
- return winners.map(({ embedding: _embedding, ...rest }) => rest);
53
- }
54
- function formatStandingRulesBlock(rules, cap = exports.INJECTION_RULE_CAP) {
20
+ // Render every rule verbatim. Injection has no cap (Task 1951): concision is
21
+ // enforced at write time by the conversational-memory skill so each stored
22
+ // value is already short, not by truncating the block here.
23
+ function formatStandingRulesBlock(rules) {
55
24
  if (rules.length === 0)
56
25
  return "";
57
- const lines = rules.slice(0, cap).map((r) => `- ${r.value}`);
26
+ const lines = rules.map((r) => `- ${r.value}`);
58
27
  return `## Standing rules\n${lines.join("\n")}`;
59
28
  }
60
29
  // The account's owner identity (Task 1461). One `AdminUser {role:'owner'}` is
@@ -80,11 +49,8 @@ async function resolveOwnerUserId(session, accountId) {
80
49
  // most-recent wins de-confliction. The trashed predicate mirrors graph-trash's
81
50
  // `notTrashed()` exactly (label + deletedAt) so this lib stays dependency-free.
82
51
  async function queryOwnerCandidates(session, accountId, ownerUserId) {
83
- // ACTIVE_CANDIDATE_LIMIT is inlined as a Cypher literal rather than bound as a
84
- // param: a plain JS number bound to `LIMIT $p` serializes as a FLOAT and Neo4j
85
- // rejects it ("not a valid value. Must be a non-negative integer"). This lib
86
- // is dependency-free (no neo4j-driver), so it cannot call `neo4j.int()`; the
87
- // constant is a hardcoded integer, so inlining it is safe and gate-clean.
52
+ // No LIMIT every owner admin preference above the floor is a candidate
53
+ // (Task 1951). The result is bounded by how many the owner has, not by a cap.
88
54
  const result = await session.run(`
89
55
  MATCH (up:UserProfile {accountId: $accountId, userId: $ownerUserId})-[:HAS_PREFERENCE]->(pref:Preference {scope: 'admin'})
90
56
  WHERE NOT pref:Trashed AND pref.deletedAt IS NULL
@@ -94,7 +60,6 @@ async function queryOwnerCandidates(session, accountId, ownerUserId) {
94
60
  pref.key AS key, pref.value AS value, pref.confidence AS confidence,
95
61
  pref.observedAt AS observedAt, pref.embedding AS embedding
96
62
  ORDER BY pref.confidence DESC, pref.observedAt DESC
97
- LIMIT ${exports.ACTIVE_CANDIDATE_LIMIT}
98
63
  `, { accountId, ownerUserId, floor: exports.ACTIVE_CANDIDATE_FLOOR });
99
64
  return result.records.map((r) => ({
100
65
  preferenceId: r.get("preferenceId"),
@@ -128,14 +93,17 @@ async function resolveCandidates(session, accountId) {
128
93
  async function queryActiveRuleCandidates(session, accountId) {
129
94
  return (await resolveCandidates(session, accountId)).candidates;
130
95
  }
131
- // The account's active, de-conflicted, bounded rule-set plus its provenance.
132
- async function resolveActiveRules(session, accountId, cap = exports.INJECTION_RULE_CAP) {
96
+ // The account's active rule-set plus its provenance. Every owner candidate is
97
+ // returned verbatim, in `confidence DESC, observedAt DESC` order — no cap, no
98
+ // de-confliction (Task 1951). Only the read-facing `embedding` is dropped.
99
+ async function resolveActiveRules(session, accountId) {
133
100
  const { candidates, ownerUserId, source } = await resolveCandidates(session, accountId);
134
- return { rules: deconflictBySubject(candidates).slice(0, cap), ownerUserId, source };
101
+ const rules = candidates.map(({ embedding: _embedding, ...rest }) => rest);
102
+ return { rules, ownerUserId, source };
135
103
  }
136
104
  // The rule array alone — the unchanged call boundary for profile-read and the
137
105
  // ui/server injection wrapper.
138
- async function activeRuleSet(session, accountId, cap = exports.INJECTION_RULE_CAP) {
139
- return (await resolveActiveRules(session, accountId, cap)).rules;
106
+ async function activeRuleSet(session, accountId) {
107
+ return (await resolveActiveRules(session, accountId)).rules;
140
108
  }
141
109
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,uEAAuE;AACvE,sEAAsE;AACtE,gFAAgF;AAChF,8EAA8E;AAC9E,cAAc;;;AA0Bd,wBAYC;AAOD,kDAmBC;AAED,4DAOC;AAmCD,gDAgBC;AAoED,8DAKC;AAGD,gDAOC;AAID,sCAMC;AAvNY,QAAA,mBAAmB,GAAG,IAAI,CAAC;AAC3B,QAAA,kBAAkB,GAAG,EAAE,CAAC;AACxB,QAAA,sBAAsB,GAAG,GAAG,CAAC;AAC7B,QAAA,sBAAsB,GAAG,EAAE,CAAC;AAqBzC,SAAgB,MAAM,CAAC,CAAW,EAAE,CAAW;IAC7C,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,MAAM;QAAE,OAAO,CAAC,CAAC;IAC9D,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,IAAI,EAAE,GAAG,CAAC,CAAC;IACX,IAAI,EAAE,GAAG,CAAC,CAAC;IACX,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QAClC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QACnB,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAClB,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACpB,CAAC;IACD,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC;QAAE,OAAO,CAAC,CAAC;IACnC,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC;AAC/C,CAAC;AAED,0EAA0E;AAC1E,uEAAuE;AACvE,8EAA8E;AAC9E,gFAAgF;AAChF,mBAAmB;AACnB,SAAgB,mBAAmB,CACjC,OAAwB,EACxB,kBAA0B,2BAAmB;IAE7C,MAAM,OAAO,GAAoB,EAAE,CAAC;IACpC,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,MAAM,GAAG,GAAG,CAAC,CAAC,SAAS,CAAC;QACxB,MAAM,KAAK,GACT,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;YAClB,GAAG,CAAC,MAAM,GAAG,CAAC;YACd,OAAO,CAAC,IAAI,CACV,CAAC,CAAC,EAAE,EAAE,CACJ,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;gBAC1B,CAAC,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC;gBACtB,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,SAAS,CAAC,IAAI,eAAe,CAC9C,CAAC;QACJ,IAAI,CAAC,KAAK;YAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAC9B,CAAC;IACD,OAAO,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,GAAG,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,CAAC;AACnE,CAAC;AAED,SAAgB,wBAAwB,CACtC,KAAmB,EACnB,MAAc,0BAAkB;IAEhC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAClC,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;IAC7D,OAAO,sBAAsB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;AAClD,CAAC;AA8BD,8EAA8E;AAC9E,2EAA2E;AAC3E,6EAA6E;AAC7E,yEAAyE;AACzE,4EAA4E;AACrE,KAAK,UAAU,kBAAkB,CACtC,OAAuB,EACvB,SAAiB;IAEjB,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,GAAG,CAC3B;;;;;KAKC,EACD,EAAE,SAAS,EAAE,CACd,CAAC;IACF,MAAM,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC3B,MAAM,MAAM,GAAG,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC9C,OAAO,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC;AACzE,CAAC;AAED,mEAAmE;AACnE,8EAA8E;AAC9E,6EAA6E;AAC7E,8EAA8E;AAC9E,+EAA+E;AAC/E,gFAAgF;AAChF,KAAK,UAAU,oBAAoB,CACjC,OAAuB,EACvB,SAAiB,EACjB,WAAmB;IAEnB,+EAA+E;IAC/E,+EAA+E;IAC/E,6EAA6E;IAC7E,6EAA6E;IAC7E,0EAA0E;IAC1E,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,GAAG,CAC9B;;;;;;;;;YASQ,8BAAsB;KAC7B,EACD,EAAE,SAAS,EAAE,WAAW,EAAE,KAAK,EAAE,8BAAsB,EAAE,CAC1D,CAAC;IACF,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;QAChC,YAAY,EAAE,CAAC,CAAC,GAAG,CAAC,cAAc,CAAW;QAC7C,QAAQ,EAAE,CAAC,CAAC,GAAG,CAAC,UAAU,CAAW;QACrC,GAAG,EAAE,CAAC,CAAC,GAAG,CAAC,KAAK,CAAW;QAC3B,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,CAAW;QAC/B,UAAU,EAAE,CAAC,CAAC,GAAG,CAAC,YAAY,CAAW;QACzC,UAAU,EAAG,CAAC,CAAC,GAAG,CAAC,YAAY,CAAY,IAAI,EAAE;QACjD,SAAS,EAAG,CAAC,CAAC,GAAG,CAAC,WAAW,CAAqB,IAAI,IAAI;KAC3D,CAAC,CAAC,CAAC;AACN,CAAC;AAED,gFAAgF;AAChF,8EAA8E;AAC9E,6EAA6E;AAC7E,+DAA+D;AAC/D,KAAK,UAAU,iBAAiB,CAC9B,OAAuB,EACvB,SAAiB;IAMjB,IAAI,WAA0B,CAAC;IAC/B,IAAI,CAAC;QACH,WAAW,GAAG,MAAM,kBAAkB,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IAC7D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;IACtE,CAAC;IACD,IAAI,CAAC,WAAW;QAAE,OAAO,EAAE,UAAU,EAAE,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;IACnF,MAAM,UAAU,GAAG,MAAM,oBAAoB,CAAC,OAAO,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;IAC/E,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC;AACtD,CAAC;AAED,2EAA2E;AAC3E,uEAAuE;AAChE,KAAK,UAAU,yBAAyB,CAC7C,OAAuB,EACvB,SAAiB;IAEjB,OAAO,CAAC,MAAM,iBAAiB,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC;AAClE,CAAC;AAED,6EAA6E;AACtE,KAAK,UAAU,kBAAkB,CACtC,OAAuB,EACvB,SAAiB,EACjB,MAAc,0BAAkB;IAEhC,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,MAAM,iBAAiB,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC;IACxF,OAAO,EAAE,KAAK,EAAE,mBAAmB,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,WAAW,EAAE,MAAM,EAAE,CAAC;AACvF,CAAC;AAED,8EAA8E;AAC9E,+BAA+B;AACxB,KAAK,UAAU,aAAa,CACjC,OAAuB,EACvB,SAAiB,EACjB,MAAc,0BAAkB;IAEhC,OAAO,CAAC,MAAM,kBAAkB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC;AACnE,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -1,76 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import {
3
- cosine,
4
- deconflictBySubject,
5
3
  formatStandingRulesBlock,
6
4
  resolveActiveRules,
7
5
  activeRuleSet,
8
6
  queryActiveRuleCandidates,
9
- INJECTION_RULE_CAP,
10
7
  type RuleCandidate,
11
8
  } from "./index.js";
12
9
 
13
- const vec = (n: number, fill: number): number[] => Array(n).fill(fill);
14
-
15
- function cand(over: Partial<RuleCandidate>): RuleCandidate {
16
- return {
17
- preferenceId: "p",
18
- category: "communication",
19
- key: "k",
20
- value: "v",
21
- confidence: 0.5,
22
- observedAt: "2026-07-01T00:00:00Z",
23
- embedding: vec(4, 1),
24
- ...over,
25
- };
26
- }
27
-
28
- describe("cosine", () => {
29
- it("is 1 for identical vectors and 0 for an empty one", () => {
30
- expect(cosine([1, 0, 0], [1, 0, 0])).toBeCloseTo(1, 6);
31
- expect(cosine([], [1, 2, 3])).toBe(0);
32
- });
33
- });
34
-
35
- describe("deconflictBySubject", () => {
36
- it("folds a near-duplicate into the higher-confidence winner", () => {
37
- const a = cand({
38
- preferenceId: "a",
39
- key: "header_rule",
40
- value: "use header A",
41
- confidence: 0.8,
42
- embedding: [1, 0, 0, 0],
43
- });
44
- const b = cand({
45
- preferenceId: "b",
46
- key: "doc_header",
47
- value: "use header A restated",
48
- confidence: 0.5,
49
- embedding: [0.99, 0.01, 0, 0],
50
- });
51
- const out = deconflictBySubject([a, b], 0.85);
52
- expect(out.map((r) => r.preferenceId)).toEqual(["a"]);
53
- });
54
-
55
- it("keeps genuinely different subjects", () => {
56
- const a = cand({ preferenceId: "a", embedding: [1, 0, 0, 0] });
57
- const b = cand({ preferenceId: "b", embedding: [0, 1, 0, 0] });
58
- expect(deconflictBySubject([a, b], 0.85).map((r) => r.preferenceId)).toEqual([
59
- "a",
60
- "b",
61
- ]);
62
- });
63
-
64
- it("never folds a candidate with no embedding", () => {
65
- const a = cand({ preferenceId: "a", embedding: [1, 0, 0, 0] });
66
- const b = cand({ preferenceId: "b", embedding: null });
67
- expect(deconflictBySubject([a, b], 0.85).map((r) => r.preferenceId)).toEqual([
68
- "a",
69
- "b",
70
- ]);
71
- });
72
- });
73
-
74
10
  // A fake Neo4j session that dispatches on query text: the owner-resolution
75
11
  // query (matches `role: 'owner'`) returns a configurable owner userId or none;
76
12
  // the candidate query (matches HAS_PREFERENCE) returns configurable pref rows
@@ -115,7 +51,7 @@ const prefRow = (over: Partial<RuleCandidate>): Record<string, unknown> => ({
115
51
  ...over,
116
52
  });
117
53
 
118
- describe("resolveActiveRules — owner-scoped selection", () => {
54
+ describe("resolveActiveRules — owner-scoped, uncapped, verbatim", () => {
119
55
  it("returns the owner's prefs with source=owner when an owner exists", async () => {
120
56
  const s = fakeSession({
121
57
  ownerId: "owner-35fed05e",
@@ -132,6 +68,36 @@ describe("resolveActiveRules — owner-scoped selection", () => {
132
68
  expect(s.candidateRuns).toBe(1);
133
69
  });
134
70
 
71
+ it("returns MORE than 12 candidates with no cap and no truncation", async () => {
72
+ const rows = Array.from({ length: 20 }, (_, i) =>
73
+ prefRow({
74
+ preferenceId: `p${i}`,
75
+ value: `rule ${i}`,
76
+ confidence: 1 - i * 0.01,
77
+ }),
78
+ );
79
+ const s = fakeSession({ ownerId: "owner-x", prefRows: rows });
80
+ const res = await resolveActiveRules(s, "acct-1");
81
+ expect(res.rules.length).toBe(20);
82
+ expect(res.rules.map((r) => r.preferenceId)).toEqual(
83
+ rows.map((r) => r.preferenceId),
84
+ );
85
+ });
86
+
87
+ it("preserves the candidate order verbatim (no reordering, no dedup)", async () => {
88
+ // Two rows that a cosine dedup would once have folded (identical embeddings)
89
+ // are BOTH returned now — injection never dedups.
90
+ const s = fakeSession({
91
+ ownerId: "owner-1",
92
+ prefRows: [
93
+ prefRow({ preferenceId: "a", value: "use header A", embedding: [1, 0, 0, 0] }),
94
+ prefRow({ preferenceId: "b", value: "use header A restated", embedding: [1, 0, 0, 0] }),
95
+ ],
96
+ });
97
+ const res = await resolveActiveRules(s, "acct-1");
98
+ expect(res.rules.map((r) => r.preferenceId)).toEqual(["a", "b"]);
99
+ });
100
+
135
101
  it("returns empty with source=no-owner and never runs the candidate query", async () => {
136
102
  const s = fakeSession({ ownerId: null });
137
103
  const res = await resolveActiveRules(s, "acct-1");
@@ -176,10 +142,10 @@ describe("resolveActiveRules — owner-scoped selection", () => {
176
142
 
177
143
  describe("formatStandingRulesBlock", () => {
178
144
  it("returns empty string for no rules", () => {
179
- expect(formatStandingRulesBlock([], INJECTION_RULE_CAP)).toBe("");
145
+ expect(formatStandingRulesBlock([])).toBe("");
180
146
  });
181
147
 
182
- it("caps the number of rendered rules", () => {
148
+ it("renders every rule, with no cap", () => {
183
149
  const rules = Array.from({ length: 20 }, (_, i) => ({
184
150
  preferenceId: `p${i}`,
185
151
  category: "communication",
@@ -188,8 +154,8 @@ describe("formatStandingRulesBlock", () => {
188
154
  confidence: 1 - i * 0.01,
189
155
  observedAt: "2026-07-01T00:00:00Z",
190
156
  }));
191
- const block = formatStandingRulesBlock(rules, 12);
157
+ const block = formatStandingRulesBlock(rules);
192
158
  expect(block).toContain("## Standing rules");
193
- expect((block.match(/^- /gm) ?? []).length).toBe(12);
159
+ expect((block.match(/^- /gm) ?? []).length).toBe(20);
194
160
  });
195
161
  });
@@ -4,10 +4,12 @@
4
4
  // free of neo4j-driver types so both an MCP process and the UI can import the
5
5
  // built dist.
6
6
 
7
+ // Write-time only: profile-update routes a same-subject write (a neighbour at
8
+ // or above this raw-cosine threshold) into the existing node instead of minting
9
+ // a duplicate. Injection no longer dedups — it surfaces every stored active
10
+ // preference verbatim (Task 1951) — so this constant has no read-side use.
7
11
  export const SAME_SUBJECT_COSINE = 0.85;
8
- export const INJECTION_RULE_CAP = 12;
9
12
  export const ACTIVE_CANDIDATE_FLOOR = 0.4;
10
- export const ACTIVE_CANDIDATE_LIMIT = 30;
11
13
 
12
14
  export interface RuleCandidate {
13
15
  preferenceId: string;
@@ -28,52 +30,12 @@ export interface ActiveRule {
28
30
  observedAt: string;
29
31
  }
30
32
 
31
- export function cosine(a: number[], b: number[]): number {
32
- if (!a.length || !b.length || a.length !== b.length) return 0;
33
- let dot = 0;
34
- let na = 0;
35
- let nb = 0;
36
- for (let i = 0; i < a.length; i++) {
37
- dot += a[i] * b[i];
38
- na += a[i] * a[i];
39
- nb += b[i] * b[i];
40
- }
41
- if (na === 0 || nb === 0) return 0;
42
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
43
- }
44
-
45
- // records MUST be confidence-desc on entry. Greedy: the first record of a
46
- // cluster is the winner; any later record within cosineThreshold of an
47
- // already-kept winner is a rephrasing and is dropped. A record with no usable
48
- // embedding is never folded — it is kept as its own subject (we cannot prove it
49
- // is a duplicate).
50
- export function deconflictBySubject(
51
- records: RuleCandidate[],
52
- cosineThreshold: number = SAME_SUBJECT_COSINE,
53
- ): ActiveRule[] {
54
- const winners: RuleCandidate[] = [];
55
- for (const r of records) {
56
- const emb = r.embedding;
57
- const isDup =
58
- Array.isArray(emb) &&
59
- emb.length > 0 &&
60
- winners.some(
61
- (w) =>
62
- Array.isArray(w.embedding) &&
63
- w.embedding.length > 0 &&
64
- cosine(emb, w.embedding) >= cosineThreshold,
65
- );
66
- if (!isDup) winners.push(r);
67
- }
68
- return winners.map(({ embedding: _embedding, ...rest }) => rest);
69
- }
70
-
71
- export function formatStandingRulesBlock(
72
- rules: ActiveRule[],
73
- cap: number = INJECTION_RULE_CAP,
74
- ): string {
33
+ // Render every rule verbatim. Injection has no cap (Task 1951): concision is
34
+ // enforced at write time by the conversational-memory skill so each stored
35
+ // value is already short, not by truncating the block here.
36
+ export function formatStandingRulesBlock(rules: ActiveRule[]): string {
75
37
  if (rules.length === 0) return "";
76
- const lines = rules.slice(0, cap).map((r) => `- ${r.value}`);
38
+ const lines = rules.map((r) => `- ${r.value}`);
77
39
  return `## Standing rules\n${lines.join("\n")}`;
78
40
  }
79
41
 
@@ -139,11 +101,8 @@ async function queryOwnerCandidates(
139
101
  accountId: string,
140
102
  ownerUserId: string,
141
103
  ): Promise<RuleCandidate[]> {
142
- // ACTIVE_CANDIDATE_LIMIT is inlined as a Cypher literal rather than bound as a
143
- // param: a plain JS number bound to `LIMIT $p` serializes as a FLOAT and Neo4j
144
- // rejects it ("not a valid value. Must be a non-negative integer"). This lib
145
- // is dependency-free (no neo4j-driver), so it cannot call `neo4j.int()`; the
146
- // constant is a hardcoded integer, so inlining it is safe and gate-clean.
104
+ // No LIMIT every owner admin preference above the floor is a candidate
105
+ // (Task 1951). The result is bounded by how many the owner has, not by a cap.
147
106
  const result = await session.run(
148
107
  `
149
108
  MATCH (up:UserProfile {accountId: $accountId, userId: $ownerUserId})-[:HAS_PREFERENCE]->(pref:Preference {scope: 'admin'})
@@ -154,7 +113,6 @@ async function queryOwnerCandidates(
154
113
  pref.key AS key, pref.value AS value, pref.confidence AS confidence,
155
114
  pref.observedAt AS observedAt, pref.embedding AS embedding
156
115
  ORDER BY pref.confidence DESC, pref.observedAt DESC
157
- LIMIT ${ACTIVE_CANDIDATE_LIMIT}
158
116
  `,
159
117
  { accountId, ownerUserId, floor: ACTIVE_CANDIDATE_FLOOR },
160
118
  );
@@ -201,14 +159,16 @@ export async function queryActiveRuleCandidates(
201
159
  return (await resolveCandidates(session, accountId)).candidates;
202
160
  }
203
161
 
204
- // The account's active, de-conflicted, bounded rule-set plus its provenance.
162
+ // The account's active rule-set plus its provenance. Every owner candidate is
163
+ // returned verbatim, in `confidence DESC, observedAt DESC` order — no cap, no
164
+ // de-confliction (Task 1951). Only the read-facing `embedding` is dropped.
205
165
  export async function resolveActiveRules(
206
166
  session: MinimalSession,
207
167
  accountId: string,
208
- cap: number = INJECTION_RULE_CAP,
209
168
  ): Promise<ActiveRuleResolution> {
210
169
  const { candidates, ownerUserId, source } = await resolveCandidates(session, accountId);
211
- return { rules: deconflictBySubject(candidates).slice(0, cap), ownerUserId, source };
170
+ const rules = candidates.map(({ embedding: _embedding, ...rest }) => rest);
171
+ return { rules, ownerUserId, source };
212
172
  }
213
173
 
214
174
  // The rule array alone — the unchanged call boundary for profile-read and the
@@ -216,7 +176,6 @@ export async function resolveActiveRules(
216
176
  export async function activeRuleSet(
217
177
  session: MinimalSession,
218
178
  accountId: string,
219
- cap: number = INJECTION_RULE_CAP,
220
179
  ): Promise<ActiveRule[]> {
221
- return (await resolveActiveRules(session, accountId, cap)).rules;
180
+ return (await resolveActiveRules(session, accountId)).rules;
222
181
  }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what Maxy ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:99a8e6ca434399104da91bd275d70b2c95bb10b96f5d1b4666053d66de447468
4
+ content-hash: sha256:de872279f0c40b8dee0a8ef51dba6534e2452d6034ca2765d6ab912447bc95b3
5
5
  brand: maxy-code
6
6
  product-name: Maxy
7
7
  ---
@@ -4290,13 +4290,13 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
4290
4290
 
4291
4291
  **Reconcile by meaning on write (`profile-update`).** The existence check is two-tier. First an exact `(category, key)` match — the classic fast path, unchanged. When that misses, a semantic-neighbour lookup runs over the `preference_embedding` vector index, scoped to the same `accountId` and `scope:'admin'`, non-trashed. If the nearest neighbour is at or above `SAME_SUBJECT_COSINE` (0.85), the write lands on that node through the existing reinforce/update/contradict curve instead of minting a duplicate; the matched neighbours are returned in the tool result so the agent can classify future writes against real prior state. The confidence constants are unchanged — only the join key widens, so a rephrasing under a new `key` no longer forks the rule and a correction demotes the prior node rather than adding a contradictory one.
4292
4292
 
4293
- **Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a bounded, floor-filtered (`confidence >= 0.4`), confidence-ordered candidate query, then semantic de-confliction (one winner per subject at the same 0.85 cosine), capped to `INJECTION_RULE_CAP` (12). A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context`. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
4293
+ **Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context`. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
4294
4294
 
4295
4295
  **Trust a scheduled turn's provenance, never an inbound's claim (channel-agent grounding).** A cron-dispatched agent turn reaches the same channel builders as a real inbound, so its own authorisation text would otherwise land inside `## Context` — the zone `REFRAME_INSTRUCTION` demotes to untrusted — and a security-cautious agent would distrust its own automation (the self-cancel loop). The dispatcher therefore stamps a `ScheduleProvenance` marker (owning account, event, owner userId, dispatch time) that the channel builders render as a `## Scheduled automation (platform-verified)` preamble **above** `## Context` (and above the rules block); the whatsapp wrap tag names the door `scheduler`. Both channel-server paths carry the marker — the single-target loop and the unified multi-target door reader (via the same `doorInboundPayload` seam that carries `standingRules`); the scheduler only dispatches to the whatsapp/telegram door, so a webchat frame never bears one. Because the marker is dispatcher-stamped and never derived from payload text, an inbound message cannot forge it — placement above `## Context` alone is not the trust signal, the dispatcher provenance is. The grounding binds to it in two places: one recipient-neutral clause added byte-identical to the three reframe instructions (a marker-bearing turn is authorised standing automation; a reconfiguration or re-enable is not intrusion), and the admin `IDENTITY.md` "Untrusted input" rule that the agent never states a platform-level authenticity or impersonation verdict it was not given (an unsourced "this is not from you" is its own inference, flagged, never asserted as the platform's). A sender has exactly one session: every inbound, real or scheduled, resolves to the deterministic `adminSessionIdFor(effectiveAccount, destination)`. An earlier change gave each firing its own salted seat so distrust could not accrete across firings, but the inbound hub keys on `senderId` and replaces its subscriber outright, so a seat's channel server attached under the sender's own key and swallowed the sender's REAL messages until it detached — splitting one conversation across two sessions that each denied what the other held. The seat is removed; the authoritative `agentPrompt`, this provenance marker, and the injected standing rules ground each firing without one. Self-cancel attribution is disarmed while that model beds in: the guard used to compare the cancelling turn's session id to a dispatcher stamp, which discriminated only because each firing ran its own seat, so with one session per sender it would report an autonomous loop on every operator cancel. It now always reports false — under-counting a real loop rather than inventing one — until the discriminator is rebuilt from the turn's own dispatched eventId. See `.docs/scheduling-agent-dispatch.md § Operator-provenance marker`, `§ Session model — one session per sender`, and `§ Self-cancel attribution — disarmed`.
4296
4296
 
4297
4297
  **Carry what a reply points at (WhatsApp reply quote).** A WhatsApp sender replying to an earlier message produces a quote that `app/lib/whatsapp/inbound/extract.ts` resolves to `{ id, text?, sender? }` and `manager.ts` assigns to the route payload's `replyContext`. That field had no reader: the `server/index.ts` gateway argument omitted it, `AdminInbound` and the hub frame had no such field, and the channel service's `composePayload` emitted the caption and media notes only, so a message saying "prepare an email confirmation for this booking" reached the agent with nothing to resolve "this booking" against. It now rides both WhatsApp admin doors. On the persistent door it travels `server/index.ts` -> `AdminInbound` -> the hub frame the SSE route stringifies -> the channel service's `InboundPayload`, where `composeReplyNote` renders it as a bracketed note at the top of the payload, inside `## Context` and above the caption and any woven media notes: the quoted body is sender-controlled and belongs in the reframed untrusted zone, never in the platform preamble. Both channel-server inbound paths carry it, through the same `doorInboundPayload` seam that carries `standingRules` and the schedule provenance marker: the single-target SSE loop parses the whole frame, and the unified multi-target door reader rebuilds the payload field by field, so a field that seam omits is dropped on multi-target sessions alone. That seam is guarded by a test which reads the `InboundPayload` declaration and the rebuild function rather than a fixture, because the pre-existing byte-identity guard compared one hardcoded payload and so stayed green for every field added after it was written. On the passive account-manager door there is no channel server and no `## Context` frame, so the one-shot's whole initial prompt is read at face value; `composePassivePrompt` therefore puts the BARE sentence above the requirement and never the quoted body. The body arrives from the sender's own client and is never checked against the message store, so quoting it in a platform-voiced bracket on the door with no untrusted zone would let a modified client put words the agent never said into a `work-create` filing. The empty-text drop gate still tests the requirement alone, so a quote never rescues an inbound carrying no requirement of its own. The quoted body is capped at `QUOTED_TEXT_CAP` (500 characters, `…` appended when cut) because it is unbounded sender-controlled input, and the cut lands on whole characters so a body whose 500th boundary falls inside a surrogate pair cannot emit a lone surrogate at a position the sender chose. Only the channel service renders a body, so the cap lives there alone; platform/ui's `app/lib/whatsapp/reply-quote.ts` holds just the bare sentence for the passive door, and both packages hardcode that sentence in their own tests so the two doors cannot drift apart on the wording (the packages never cross-import, the same arrangement `REFRAME_INSTRUCTION` and `MediaRef` use). Telegram and webchat extract no quote at all, so neither carries one. `payloadBytes` measures the whole inner payload, so a reply carrying a quote produces a visibly larger `[reframe] op=applied … payloadBytes=<n>` than the same text without one, and a bare-text-length `payloadBytes` on an inbound whose store row has a non-null `quotedId` is the signature of this defect returning. Because the field is optional at every hop, dropping it from either `server/index.ts` argument would be silent (tsc stays green, and no unit test executes that wiring file), so the `check-reply-context-threaded` build gate asserts both argument sites.
4298
4298
 
4299
- **Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-audit] op=drift accountId=… storedActive=… injectedActive=… duplicateClusters=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose store holds more active rows than injection would surface, the no-event detector for drift.
4299
+ **Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-inject] op=reconcile accountId=… stored=… injected=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose stored active count diverges from what injection surfaces; injection now surfaces every stored active preference verbatim, so `stored == injected` is the healthy signal and any gap is a dropped-row regression (a reintroduced cap, filter, or truncation). A clean pass logs `op=reconcile drifted=0`, the no-event detector.
4300
4300
 
4301
4301
  **Consult-before-send gate.** Injection (above) puts the owner's active rules in front of every channel turn, but the growing layer-2 `Preference` set is read on demand with `profile-read`, a per-turn judgement the agent can skip, the miss that dropped a customer signature on a delivered document. `hooks/preference-consult-gate.sh` (admin plugin) is the deliverable-boundary enforcement: a PreToolUse gate on customer-facing document deliverables (`browser-pdf-save`/`SendUserFile` to `memory/users/<phone>/documents/`, and email/Outlook sends carrying attachments) that blocks (exit 2) when no `profile-read` ran after the last user message in the transcript. It fires on documents only, never on casual replies. **Observability:** `[preference-gate] op=allow tool=<name> consulted=true` on an allowed document deliverable, `op=bypass tool=<name> detail=no profile-read this turn` on a block; a stream of `op=bypass` is the un-consulted-send signature. The advisory companion `hooks/preference-consult-directive.sh` injects the two-layer architecture reminder every turn (`[pref-wrapper] op=inject`). The placement half, promoting finished deliverables out of `output/` scratch, is counted by the `data-manager` reconcile audit (`scratch-refs`, `stranded`); making that audit run on a standing periodic cadence is a filed follow-up.
4302
4302
 
@@ -400,13 +400,13 @@ Standing rules the operator gives over a channel are `Preference` nodes. Two mec
400
400
 
401
401
  **Reconcile by meaning on write (`profile-update`).** The existence check is two-tier. First an exact `(category, key)` match — the classic fast path, unchanged. When that misses, a semantic-neighbour lookup runs over the `preference_embedding` vector index, scoped to the same `accountId` and `scope:'admin'`, non-trashed. If the nearest neighbour is at or above `SAME_SUBJECT_COSINE` (0.85), the write lands on that node through the existing reinforce/update/contradict curve instead of minting a duplicate; the matched neighbours are returned in the tool result so the agent can classify future writes against real prior state. The confidence constants are unchanged — only the join key widens, so a rephrasing under a new `key` no longer forks the rule and a correction demotes the prior node rather than adding a contradictory one.
402
402
 
403
- **Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a bounded, floor-filtered (`confidence >= 0.4`), confidence-ordered candidate query, then semantic de-confliction (one winner per subject at the same 0.85 cosine), capped to `INJECTION_RULE_CAP` (12). A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context`. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
403
+ **Inject the active rule-set every turn (channel gateways).** The one definition of "this account's active rules" is `@maxy/active-rules`: the account owner's admin-scope preferences (resolve the seeded `AdminUser {role:'owner'}`, then select through that owner's `UserProfile` via `HAS_PREFERENCE`), a floor-filtered (`confidence >= 0.4`), confidence-ordered query that returns every such preference verbatim — no cap, no de-confliction, no candidate limit. Concision is enforced at write time by the `conversational-memory` skill (each stored value is a single minimal imperative) and by the write-time semantic reconcile (a same-subject write at or above `SAME_SUBJECT_COSINE` folds into the existing node rather than minting a duplicate), so the injected block stays small because each stored preference is short and non-redundant, not because injection truncates. A preference owned by a non-owner profile (an operator's, stranded on the operator's own profile while managing the account) is never injected; an account with no seeded owner injects nothing. `profile-read` exposes this same owner-scoped set as `activeRules`, and the three inbound gateways (`wa-gateway`, `telegram-gateway`, `webchat-gateway`) fetch it for the effective session account and attach it as `standingRules` on the inbound frame; the channel builders prepend it as a `## Standing rules` block above `## Context`. Both channel-server inbound paths carry the block: the single-target SSE loop parses the whole frame, and the unified multi-target door reader (`runDoorStream`,) reconstructs it through the shared `doorInboundPayload` seam whose `parseFrame` closures read `standingRules` off the frame. The read is by code, not agent discretion, so a rule set last week is applied this week regardless of how many times the session compacted. Telegram and webchat inject for `role:'admin'` only — a public visitor never receives operator rules.
404
404
 
405
405
  **Trust a scheduled turn's provenance, never an inbound's claim (channel-agent grounding).** A cron-dispatched agent turn reaches the same channel builders as a real inbound, so its own authorisation text would otherwise land inside `## Context` — the zone `REFRAME_INSTRUCTION` demotes to untrusted — and a security-cautious agent would distrust its own automation (the self-cancel loop). The dispatcher therefore stamps a `ScheduleProvenance` marker (owning account, event, owner userId, dispatch time) that the channel builders render as a `## Scheduled automation (platform-verified)` preamble **above** `## Context` (and above the rules block); the whatsapp wrap tag names the door `scheduler`. Both channel-server paths carry the marker — the single-target loop and the unified multi-target door reader (via the same `doorInboundPayload` seam that carries `standingRules`); the scheduler only dispatches to the whatsapp/telegram door, so a webchat frame never bears one. Because the marker is dispatcher-stamped and never derived from payload text, an inbound message cannot forge it — placement above `## Context` alone is not the trust signal, the dispatcher provenance is. The grounding binds to it in two places: one recipient-neutral clause added byte-identical to the three reframe instructions (a marker-bearing turn is authorised standing automation; a reconfiguration or re-enable is not intrusion), and the admin `IDENTITY.md` "Untrusted input" rule that the agent never states a platform-level authenticity or impersonation verdict it was not given (an unsourced "this is not from you" is its own inference, flagged, never asserted as the platform's). A sender has exactly one session: every inbound, real or scheduled, resolves to the deterministic `adminSessionIdFor(effectiveAccount, destination)`. An earlier change gave each firing its own salted seat so distrust could not accrete across firings, but the inbound hub keys on `senderId` and replaces its subscriber outright, so a seat's channel server attached under the sender's own key and swallowed the sender's REAL messages until it detached — splitting one conversation across two sessions that each denied what the other held. The seat is removed; the authoritative `agentPrompt`, this provenance marker, and the injected standing rules ground each firing without one. Self-cancel attribution is disarmed while that model beds in: the guard used to compare the cancelling turn's session id to a dispatcher stamp, which discriminated only because each firing ran its own seat, so with one session per sender it would report an autonomous loop on every operator cancel. It now always reports false — under-counting a real loop rather than inventing one — until the discriminator is rebuilt from the turn's own dispatched eventId. See `.docs/scheduling-agent-dispatch.md § Operator-provenance marker`, `§ Session model — one session per sender`, and `§ Self-cancel attribution — disarmed`.
406
406
 
407
407
  **Carry what a reply points at (WhatsApp reply quote).** A WhatsApp sender replying to an earlier message produces a quote that `app/lib/whatsapp/inbound/extract.ts` resolves to `{ id, text?, sender? }` and `manager.ts` assigns to the route payload's `replyContext`. That field had no reader: the `server/index.ts` gateway argument omitted it, `AdminInbound` and the hub frame had no such field, and the channel service's `composePayload` emitted the caption and media notes only, so a message saying "prepare an email confirmation for this booking" reached the agent with nothing to resolve "this booking" against. It now rides both WhatsApp admin doors. On the persistent door it travels `server/index.ts` -> `AdminInbound` -> the hub frame the SSE route stringifies -> the channel service's `InboundPayload`, where `composeReplyNote` renders it as a bracketed note at the top of the payload, inside `## Context` and above the caption and any woven media notes: the quoted body is sender-controlled and belongs in the reframed untrusted zone, never in the platform preamble. Both channel-server inbound paths carry it, through the same `doorInboundPayload` seam that carries `standingRules` and the schedule provenance marker: the single-target SSE loop parses the whole frame, and the unified multi-target door reader rebuilds the payload field by field, so a field that seam omits is dropped on multi-target sessions alone. That seam is guarded by a test which reads the `InboundPayload` declaration and the rebuild function rather than a fixture, because the pre-existing byte-identity guard compared one hardcoded payload and so stayed green for every field added after it was written. On the passive account-manager door there is no channel server and no `## Context` frame, so the one-shot's whole initial prompt is read at face value; `composePassivePrompt` therefore puts the BARE sentence above the requirement and never the quoted body. The body arrives from the sender's own client and is never checked against the message store, so quoting it in a platform-voiced bracket on the door with no untrusted zone would let a modified client put words the agent never said into a `work-create` filing. The empty-text drop gate still tests the requirement alone, so a quote never rescues an inbound carrying no requirement of its own. The quoted body is capped at `QUOTED_TEXT_CAP` (500 characters, `…` appended when cut) because it is unbounded sender-controlled input, and the cut lands on whole characters so a body whose 500th boundary falls inside a surrogate pair cannot emit a lone surrogate at a position the sender chose. Only the channel service renders a body, so the cap lives there alone; platform/ui's `app/lib/whatsapp/reply-quote.ts` holds just the bare sentence for the passive door, and both packages hardcode that sentence in their own tests so the two doors cannot drift apart on the wording (the packages never cross-import, the same arrangement `REFRAME_INSTRUCTION` and `MediaRef` use). Telegram and webchat extract no quote at all, so neither carries one. `payloadBytes` measures the whole inner payload, so a reply carrying a quote produces a visibly larger `[reframe] op=applied … payloadBytes=<n>` than the same text without one, and a bare-text-length `payloadBytes` on an inbound whose store row has a non-null `quotedId` is the signature of this defect returning. Because the field is optional at every hop, dropping it from either `server/index.ts` argument would be silent (tsc stays green, and no unit test executes that wiring file), so the `check-reply-context-threaded` build gate asserts both argument sites.
408
408
 
409
- **Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-audit] op=drift accountId=… storedActive=… injectedActive=… duplicateClusters=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose store holds more active rows than injection would surface, the no-event detector for drift.
409
+ **Observability.** `[preference-write] op=reconcile … mode=<reinforce|update|contradict|new> similarity=…` — a stream of `mode=new` for subjects that already exist is the duplicate-minting signature. `[preference-inject] op=inject channel=<wa|tg|web> injected=<N> owner=<8> source=<owner|no-owner|owner-error|fetch-error>` — injection selects only the account owner's admin preferences, so `source=owner injected=0` is a genuinely empty owner, `source=no-owner` is an account with no seeded owner (injects nothing, a missed owner seeding), and `injected=0 source=owner` on an account whose owner holds rules is the write-only regression re-appearing. `[preference-inject] op=reconcile accountId=… stored=… injected=…` — the standing reconciliation audit (claude-session-manager, 5-minute tick) flags any account whose stored active count diverges from what injection surfaces; injection now surfaces every stored active preference verbatim, so `stored == injected` is the healthy signal and any gap is a dropped-row regression (a reintroduced cap, filter, or truncation). A clean pass logs `op=reconcile drifted=0`, the no-event detector.
410
410
 
411
411
  **Consult-before-send gate.** Injection (above) puts the owner's active rules in front of every channel turn, but the growing layer-2 `Preference` set is read on demand with `profile-read`, a per-turn judgement the agent can skip, the miss that dropped a customer signature on a delivered document. `hooks/preference-consult-gate.sh` (admin plugin) is the deliverable-boundary enforcement: a PreToolUse gate on customer-facing document deliverables (`browser-pdf-save`/`SendUserFile` to `memory/users/<phone>/documents/`, and email/Outlook sends carrying attachments) that blocks (exit 2) when no `profile-read` ran after the last user message in the transcript. It fires on documents only, never on casual replies. **Observability:** `[preference-gate] op=allow tool=<name> consulted=true` on an allowed document deliverable, `op=bypass tool=<name> detail=no profile-read this turn` on a block; a stream of `op=bypass` is the un-consulted-send signature. The advisory companion `hooks/preference-consult-directive.sh` injects the two-layer architecture reminder every turn (`[pref-wrapper] op=inject`). The placement half, promoting finished deliverables out of `output/` scratch, is counted by the `data-manager` reconcile audit (`scratch-refs`, `stranded`); making that audit run on a standing periodic cadence is a filed follow-up.
412
412
 
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=compose-signature.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compose-signature.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/compose-signature.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { composeDraft } from '../lib/compose.js';
6
+ const config = { email: 'agent@gls.test', agentAddress: 'agent@gls.test' };
7
+ describe('Task 1952 — composeDraft applies signature', () => {
8
+ let root = '';
9
+ let accountDir = '';
10
+ const accountId = 'gls12345-aaaa';
11
+ const saved = { ...process.env };
12
+ beforeEach(() => {
13
+ root = realpathSync(mkdtempSync(join(tmpdir(), 'cmp-')));
14
+ const platformRoot = join(root, 'platform');
15
+ mkdirSync(platformRoot, { recursive: true });
16
+ accountDir = join(root, 'data', 'accounts', accountId);
17
+ mkdirSync(accountDir, { recursive: true });
18
+ process.env.PLATFORM_ROOT = platformRoot;
19
+ delete process.env.ACCOUNT_DIR;
20
+ });
21
+ afterEach(() => {
22
+ process.env = { ...saved };
23
+ if (root)
24
+ rmSync(root, { recursive: true, force: true });
25
+ });
26
+ it('appends the text signature to the composed body', async () => {
27
+ writeFileSync(join(accountDir, 'email-signature.txt'), 'Regards,\nDale');
28
+ const { raw } = await composeDraft(config, 'pw', accountId, {
29
+ to: 'x@y.test', subject: 'Hi', body: 'Quote attached.',
30
+ });
31
+ const text = raw.toString('utf8');
32
+ expect(text).toContain('Quote attached.');
33
+ expect(text).toMatch(/Regards,\r?\nDale/);
34
+ });
35
+ it('logs applied=true with the account id prefix and asset path', async () => {
36
+ writeFileSync(join(accountDir, 'email-signature.txt'), 'Regards,\nDale');
37
+ const err = vi.spyOn(console, 'error').mockImplementation(() => { });
38
+ await composeDraft(config, 'pw', accountId, { to: 'x@y.test', subject: 'Hi', body: 'Body.' });
39
+ const line = err.mock.calls.map((c) => String(c[0])).find((s) => s.includes('op=signature'));
40
+ expect(line).toContain('accountId=gls12345');
41
+ expect(line).toContain('applied=true');
42
+ expect(line).toContain(join(accountDir, 'email-signature'));
43
+ err.mockRestore();
44
+ });
45
+ it('logs applied=false when no signature file is present', async () => {
46
+ const err = vi.spyOn(console, 'error').mockImplementation(() => { });
47
+ await composeDraft(config, 'pw', accountId, { to: 'x@y.test', subject: 'Hi', body: 'Body.' });
48
+ const line = err.mock.calls.map((c) => String(c[0])).find((s) => s.includes('op=signature'));
49
+ expect(line).toContain('applied=false');
50
+ err.mockRestore();
51
+ });
52
+ });
53
+ //# sourceMappingURL=compose-signature.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compose-signature.test.js","sourceRoot":"","sources":["../../src/__tests__/compose-signature.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=signature.test.d.ts.map