@diyor28/context 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. package/README.md +270 -0
  2. package/dist/__tests__/attachment-selector.test.d.ts +11 -0
  3. package/dist/__tests__/attachment-selector.test.d.ts.map +1 -0
  4. package/dist/__tests__/attachment-selector.test.js +449 -0
  5. package/dist/__tests__/attachment-selector.test.js.map +1 -0
  6. package/dist/__tests__/cache-breakpoints.test.d.ts +11 -0
  7. package/dist/__tests__/cache-breakpoints.test.d.ts.map +1 -0
  8. package/dist/__tests__/cache-breakpoints.test.js +398 -0
  9. package/dist/__tests__/cache-breakpoints.test.js.map +1 -0
  10. package/dist/__tests__/codecs.test.d.ts +7 -0
  11. package/dist/__tests__/codecs.test.d.ts.map +1 -0
  12. package/dist/__tests__/codecs.test.js +331 -0
  13. package/dist/__tests__/codecs.test.js.map +1 -0
  14. package/dist/__tests__/compactor.test.d.ts +11 -0
  15. package/dist/__tests__/compactor.test.d.ts.map +1 -0
  16. package/dist/__tests__/compactor.test.js +519 -0
  17. package/dist/__tests__/compactor.test.js.map +1 -0
  18. package/dist/__tests__/context-graph.test.d.ts +7 -0
  19. package/dist/__tests__/context-graph.test.d.ts.map +1 -0
  20. package/dist/__tests__/context-graph.test.js +262 -0
  21. package/dist/__tests__/context-graph.test.js.map +1 -0
  22. package/dist/__tests__/hash.test.d.ts +7 -0
  23. package/dist/__tests__/hash.test.d.ts.map +1 -0
  24. package/dist/__tests__/hash.test.js +228 -0
  25. package/dist/__tests__/hash.test.js.map +1 -0
  26. package/dist/__tests__/integration.test.d.ts +15 -0
  27. package/dist/__tests__/integration.test.d.ts.map +1 -0
  28. package/dist/__tests__/integration.test.js +728 -0
  29. package/dist/__tests__/integration.test.js.map +1 -0
  30. package/dist/__tests__/kind-order.test.d.ts +7 -0
  31. package/dist/__tests__/kind-order.test.d.ts.map +1 -0
  32. package/dist/__tests__/kind-order.test.js +243 -0
  33. package/dist/__tests__/kind-order.test.js.map +1 -0
  34. package/dist/__tests__/phase2-integration.test.d.ts +5 -0
  35. package/dist/__tests__/phase2-integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/phase2-integration.test.js +222 -0
  37. package/dist/__tests__/phase2-integration.test.js.map +1 -0
  38. package/dist/__tests__/queries.test.d.ts +7 -0
  39. package/dist/__tests__/queries.test.d.ts.map +1 -0
  40. package/dist/__tests__/queries.test.js +254 -0
  41. package/dist/__tests__/queries.test.js.map +1 -0
  42. package/dist/__tests__/token-estimator.test.d.ts +7 -0
  43. package/dist/__tests__/token-estimator.test.d.ts.map +1 -0
  44. package/dist/__tests__/token-estimator.test.js +267 -0
  45. package/dist/__tests__/token-estimator.test.js.map +1 -0
  46. package/dist/adapters/anthropic-estimator.d.ts +38 -0
  47. package/dist/adapters/anthropic-estimator.d.ts.map +1 -0
  48. package/dist/adapters/anthropic-estimator.js +108 -0
  49. package/dist/adapters/anthropic-estimator.js.map +1 -0
  50. package/dist/adapters/attachment-resolver.d.ts +96 -0
  51. package/dist/adapters/attachment-resolver.d.ts.map +1 -0
  52. package/dist/adapters/attachment-resolver.js +176 -0
  53. package/dist/adapters/attachment-resolver.js.map +1 -0
  54. package/dist/adapters/attachment-selector.d.ts +59 -0
  55. package/dist/adapters/attachment-selector.d.ts.map +1 -0
  56. package/dist/adapters/attachment-selector.js +163 -0
  57. package/dist/adapters/attachment-selector.js.map +1 -0
  58. package/dist/adapters/gemini-estimator.d.ts +27 -0
  59. package/dist/adapters/gemini-estimator.d.ts.map +1 -0
  60. package/dist/adapters/gemini-estimator.js +80 -0
  61. package/dist/adapters/gemini-estimator.js.map +1 -0
  62. package/dist/adapters/index.d.ts +12 -0
  63. package/dist/adapters/index.d.ts.map +1 -0
  64. package/dist/adapters/index.js +28 -0
  65. package/dist/adapters/index.js.map +1 -0
  66. package/dist/adapters/memory-store.d.ts +139 -0
  67. package/dist/adapters/memory-store.d.ts.map +1 -0
  68. package/dist/adapters/memory-store.js +187 -0
  69. package/dist/adapters/memory-store.js.map +1 -0
  70. package/dist/adapters/openai-estimator.d.ts +35 -0
  71. package/dist/adapters/openai-estimator.d.ts.map +1 -0
  72. package/dist/adapters/openai-estimator.js +89 -0
  73. package/dist/adapters/openai-estimator.js.map +1 -0
  74. package/dist/adapters/summarizer.d.ts +121 -0
  75. package/dist/adapters/summarizer.d.ts.map +1 -0
  76. package/dist/adapters/summarizer.js +121 -0
  77. package/dist/adapters/summarizer.js.map +1 -0
  78. package/dist/adapters/token-estimator.d.ts +63 -0
  79. package/dist/adapters/token-estimator.d.ts.map +1 -0
  80. package/dist/adapters/token-estimator.js +37 -0
  81. package/dist/adapters/token-estimator.js.map +1 -0
  82. package/dist/builder/context-builder.d.ts +186 -0
  83. package/dist/builder/context-builder.d.ts.map +1 -0
  84. package/dist/builder/context-builder.js +305 -0
  85. package/dist/builder/context-builder.js.map +1 -0
  86. package/dist/builder/context-fork.d.ts +166 -0
  87. package/dist/builder/context-fork.d.ts.map +1 -0
  88. package/dist/builder/context-fork.js +282 -0
  89. package/dist/builder/context-fork.js.map +1 -0
  90. package/dist/builder/index.d.ts +6 -0
  91. package/dist/builder/index.d.ts.map +1 -0
  92. package/dist/builder/index.js +22 -0
  93. package/dist/builder/index.js.map +1 -0
  94. package/dist/codecs/base.d.ts +18 -0
  95. package/dist/codecs/base.d.ts.map +1 -0
  96. package/dist/codecs/base.js +39 -0
  97. package/dist/codecs/base.js.map +1 -0
  98. package/dist/codecs/conversation-history.codec.d.ts +81 -0
  99. package/dist/codecs/conversation-history.codec.d.ts.map +1 -0
  100. package/dist/codecs/conversation-history.codec.js +89 -0
  101. package/dist/codecs/conversation-history.codec.js.map +1 -0
  102. package/dist/codecs/index.d.ts +31 -0
  103. package/dist/codecs/index.d.ts.map +1 -0
  104. package/dist/codecs/index.js +71 -0
  105. package/dist/codecs/index.js.map +1 -0
  106. package/dist/codecs/redacted-stub.codec.d.ts +32 -0
  107. package/dist/codecs/redacted-stub.codec.d.ts.map +1 -0
  108. package/dist/codecs/redacted-stub.codec.js +64 -0
  109. package/dist/codecs/redacted-stub.codec.js.map +1 -0
  110. package/dist/codecs/structured-reference.codec.d.ts +40 -0
  111. package/dist/codecs/structured-reference.codec.d.ts.map +1 -0
  112. package/dist/codecs/structured-reference.codec.js +81 -0
  113. package/dist/codecs/structured-reference.codec.js.map +1 -0
  114. package/dist/codecs/system-rules.codec.d.ts +32 -0
  115. package/dist/codecs/system-rules.codec.d.ts.map +1 -0
  116. package/dist/codecs/system-rules.codec.js +62 -0
  117. package/dist/codecs/system-rules.codec.js.map +1 -0
  118. package/dist/codecs/tool-output.codec.d.ts +66 -0
  119. package/dist/codecs/tool-output.codec.d.ts.map +1 -0
  120. package/dist/codecs/tool-output.codec.js +95 -0
  121. package/dist/codecs/tool-output.codec.js.map +1 -0
  122. package/dist/codecs/tool-schema.codec.d.ts +36 -0
  123. package/dist/codecs/tool-schema.codec.d.ts.map +1 -0
  124. package/dist/codecs/tool-schema.codec.js +74 -0
  125. package/dist/codecs/tool-schema.codec.js.map +1 -0
  126. package/dist/codecs/unsafe-text.codec.d.ts +28 -0
  127. package/dist/codecs/unsafe-text.codec.d.ts.map +1 -0
  128. package/dist/codecs/unsafe-text.codec.js +63 -0
  129. package/dist/codecs/unsafe-text.codec.js.map +1 -0
  130. package/dist/graph/context-graph.d.ts +121 -0
  131. package/dist/graph/context-graph.d.ts.map +1 -0
  132. package/dist/graph/context-graph.js +166 -0
  133. package/dist/graph/context-graph.js.map +1 -0
  134. package/dist/graph/index.d.ts +8 -0
  135. package/dist/graph/index.d.ts.map +1 -0
  136. package/dist/graph/index.js +24 -0
  137. package/dist/graph/index.js.map +1 -0
  138. package/dist/graph/kind-order.d.ts +60 -0
  139. package/dist/graph/kind-order.d.ts.map +1 -0
  140. package/dist/graph/kind-order.js +113 -0
  141. package/dist/graph/kind-order.js.map +1 -0
  142. package/dist/graph/queries.d.ts +68 -0
  143. package/dist/graph/queries.d.ts.map +1 -0
  144. package/dist/graph/queries.js +240 -0
  145. package/dist/graph/queries.js.map +1 -0
  146. package/dist/graph/views.d.ts +90 -0
  147. package/dist/graph/views.d.ts.map +1 -0
  148. package/dist/graph/views.js +173 -0
  149. package/dist/graph/views.js.map +1 -0
  150. package/dist/index.d.ts +16 -0
  151. package/dist/index.d.ts.map +1 -0
  152. package/dist/index.js +40 -0
  153. package/dist/index.js.map +1 -0
  154. package/dist/pipeline/compactor.d.ts +128 -0
  155. package/dist/pipeline/compactor.d.ts.map +1 -0
  156. package/dist/pipeline/compactor.js +346 -0
  157. package/dist/pipeline/compactor.js.map +1 -0
  158. package/dist/pipeline/index.d.ts +6 -0
  159. package/dist/pipeline/index.d.ts.map +1 -0
  160. package/dist/pipeline/index.js +22 -0
  161. package/dist/pipeline/index.js.map +1 -0
  162. package/dist/pipeline/summarizer.d.ts +18 -0
  163. package/dist/pipeline/summarizer.d.ts.map +1 -0
  164. package/dist/pipeline/summarizer.js +68 -0
  165. package/dist/pipeline/summarizer.js.map +1 -0
  166. package/dist/policies/default-policy.d.ts +29 -0
  167. package/dist/policies/default-policy.d.ts.map +1 -0
  168. package/dist/policies/default-policy.js +58 -0
  169. package/dist/policies/default-policy.js.map +1 -0
  170. package/dist/policies/index.d.ts +5 -0
  171. package/dist/policies/index.d.ts.map +1 -0
  172. package/dist/policies/index.js +21 -0
  173. package/dist/policies/index.js.map +1 -0
  174. package/dist/providers/anthropic-compiler.d.ts +58 -0
  175. package/dist/providers/anthropic-compiler.d.ts.map +1 -0
  176. package/dist/providers/anthropic-compiler.js +182 -0
  177. package/dist/providers/anthropic-compiler.js.map +1 -0
  178. package/dist/providers/capabilities.d.ts +54 -0
  179. package/dist/providers/capabilities.d.ts.map +1 -0
  180. package/dist/providers/capabilities.js +87 -0
  181. package/dist/providers/capabilities.js.map +1 -0
  182. package/dist/providers/gemini-compiler.d.ts +51 -0
  183. package/dist/providers/gemini-compiler.d.ts.map +1 -0
  184. package/dist/providers/gemini-compiler.js +206 -0
  185. package/dist/providers/gemini-compiler.js.map +1 -0
  186. package/dist/providers/index.d.ts +8 -0
  187. package/dist/providers/index.d.ts.map +1 -0
  188. package/dist/providers/index.js +24 -0
  189. package/dist/providers/index.js.map +1 -0
  190. package/dist/providers/openai-compiler.d.ts +46 -0
  191. package/dist/providers/openai-compiler.d.ts.map +1 -0
  192. package/dist/providers/openai-compiler.js +149 -0
  193. package/dist/providers/openai-compiler.js.map +1 -0
  194. package/dist/types/attachment.d.ts +62 -0
  195. package/dist/types/attachment.d.ts.map +1 -0
  196. package/dist/types/attachment.js +6 -0
  197. package/dist/types/attachment.js.map +1 -0
  198. package/dist/types/block.d.ts +61 -0
  199. package/dist/types/block.d.ts.map +1 -0
  200. package/dist/types/block.js +8 -0
  201. package/dist/types/block.js.map +1 -0
  202. package/dist/types/codec.d.ts +58 -0
  203. package/dist/types/codec.d.ts.map +1 -0
  204. package/dist/types/codec.js +6 -0
  205. package/dist/types/codec.js.map +1 -0
  206. package/dist/types/compiled.d.ts +91 -0
  207. package/dist/types/compiled.d.ts.map +1 -0
  208. package/dist/types/compiled.js +6 -0
  209. package/dist/types/compiled.js.map +1 -0
  210. package/dist/types/hash.d.ts +24 -0
  211. package/dist/types/hash.d.ts.map +1 -0
  212. package/dist/types/hash.js +49 -0
  213. package/dist/types/hash.js.map +1 -0
  214. package/dist/types/index.d.ts +10 -0
  215. package/dist/types/index.d.ts.map +1 -0
  216. package/dist/types/index.js +26 -0
  217. package/dist/types/index.js.map +1 -0
  218. package/dist/types/policy.d.ts +128 -0
  219. package/dist/types/policy.d.ts.map +1 -0
  220. package/dist/types/policy.js +55 -0
  221. package/dist/types/policy.js.map +1 -0
  222. package/package.json +55 -0
  223. package/postcss.config.js +4 -0
  224. package/src/__tests__/attachment-selector.test.ts +559 -0
  225. package/src/__tests__/cache-breakpoints.test.ts +566 -0
  226. package/src/__tests__/codecs.test.ts +417 -0
  227. package/src/__tests__/compactor.test.ts +608 -0
  228. package/src/__tests__/context-graph.test.ts +383 -0
  229. package/src/__tests__/hash.test.ts +274 -0
  230. package/src/__tests__/integration.test.ts +866 -0
  231. package/src/__tests__/kind-order.test.ts +312 -0
  232. package/src/__tests__/phase2-integration.test.ts +253 -0
  233. package/src/__tests__/queries.test.ts +387 -0
  234. package/src/__tests__/token-estimator.test.ts +326 -0
  235. package/src/adapters/anthropic-estimator.ts +125 -0
  236. package/src/adapters/attachment-resolver.ts +295 -0
  237. package/src/adapters/attachment-selector.ts +218 -0
  238. package/src/adapters/gemini-estimator.ts +93 -0
  239. package/src/adapters/index.ts +12 -0
  240. package/src/adapters/memory-store.ts +299 -0
  241. package/src/adapters/openai-estimator.ts +105 -0
  242. package/src/adapters/summarizer.ts +250 -0
  243. package/src/adapters/token-estimator.ts +74 -0
  244. package/src/builder/context-builder.ts +467 -0
  245. package/src/builder/context-fork.ts +471 -0
  246. package/src/builder/index.ts +6 -0
  247. package/src/codecs/base.ts +36 -0
  248. package/src/codecs/conversation-history.codec.ts +108 -0
  249. package/src/codecs/index.ts +57 -0
  250. package/src/codecs/redacted-stub.codec.ts +76 -0
  251. package/src/codecs/structured-reference.codec.ts +96 -0
  252. package/src/codecs/system-rules.codec.ts +74 -0
  253. package/src/codecs/tool-output.codec.ts +109 -0
  254. package/src/codecs/tool-schema.codec.ts +87 -0
  255. package/src/codecs/unsafe-text.codec.ts +74 -0
  256. package/src/graph/context-graph.ts +205 -0
  257. package/src/graph/index.ts +8 -0
  258. package/src/graph/kind-order.ts +125 -0
  259. package/src/graph/queries.ts +306 -0
  260. package/src/graph/views.ts +255 -0
  261. package/src/index.ts +31 -0
  262. package/src/pipeline/compactor.ts +563 -0
  263. package/src/pipeline/index.ts +6 -0
  264. package/src/pipeline/summarizer.ts +76 -0
  265. package/src/policies/default-policy.ts +69 -0
  266. package/src/policies/index.ts +5 -0
  267. package/src/providers/anthropic-compiler.ts +294 -0
  268. package/src/providers/capabilities.ts +144 -0
  269. package/src/providers/gemini-compiler.ts +272 -0
  270. package/src/providers/index.ts +8 -0
  271. package/src/providers/openai-compiler.ts +191 -0
  272. package/src/types/attachment.ts +86 -0
  273. package/src/types/block.ts +84 -0
  274. package/src/types/codec.ts +68 -0
  275. package/src/types/compiled.ts +109 -0
  276. package/src/types/hash.ts +58 -0
  277. package/src/types/index.ts +10 -0
  278. package/src/types/policy.ts +194 -0
  279. package/tsconfig.json +21 -0
  280. package/vitest.config.ts +21 -0
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Unit tests for AttachmentSelector: budget enforcement and ranking.
3
+ *
4
+ * Tests:
5
+ * - Budget enforcement (token limit)
6
+ * - Ranking by purpose, user mention, recency
7
+ * - Deterministic ordering (same input → same selection)
8
+ * - Token estimation for different attachment types
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { AttachmentSelector } from '../adapters/attachment-selector.js';
13
+ import type { RankedAttachment } from '../adapters/attachment-selector.js';
14
+ import type { AttachmentPolicy } from '../types/policy.js';
15
+ import type { ResolvedAttachment } from '../types/attachment.js';
16
+
17
+ // Mock TokenEstimator
18
+ const mockEstimator = {
19
+ estimateTokens: async () => ({ tokens: 100 }),
20
+ } as any;
21
+
22
+ describe('AttachmentSelector', () => {
23
+ describe('Budget Enforcement', () => {
24
+ it('should select attachments within budget', async () => {
25
+ const policy: AttachmentPolicy = {
26
+ maxTokensTotal: 1000, // Budget for ~10 text attachments (100 tokens each)
27
+ selectionStrategy: {
28
+ rankBy: ['purpose'],
29
+ },
30
+ };
31
+
32
+ const selector = new AttachmentSelector(policy, mockEstimator);
33
+
34
+ const attachments: RankedAttachment[] = [
35
+ createRankedAttachment('att1', 'evidence', false, 'Short text', 400), // ~100 tokens
36
+ createRankedAttachment('att2', 'evidence', false, 'Short text', 400), // ~100 tokens
37
+ createRankedAttachment('att3', 'evidence', false, 'Short text', 400), // ~100 tokens
38
+ ];
39
+
40
+ const result = await selector.selectAttachments(attachments);
41
+
42
+ expect(result.selected).toHaveLength(3);
43
+ expect(result.tokensUsed).toBeLessThanOrEqual(1000);
44
+ });
45
+
46
+ it('should exclude attachments over budget', async () => {
47
+ const policy: AttachmentPolicy = {
48
+ maxTokensTotal: 500, // Small budget
49
+ selectionStrategy: {
50
+ rankBy: ['purpose'],
51
+ },
52
+ };
53
+
54
+ const selector = new AttachmentSelector(policy, mockEstimator);
55
+
56
+ const largeText = 'x'.repeat(2000); // ~500 tokens
57
+ const attachments: RankedAttachment[] = [
58
+ createRankedAttachment('att1', 'evidence', false, largeText, 500), // ~500 tokens
59
+ createRankedAttachment('att2', 'evidence', false, largeText, 500), // ~500 tokens (over budget)
60
+ ];
61
+
62
+ const result = await selector.selectAttachments(attachments);
63
+
64
+ expect(result.selected).toHaveLength(1);
65
+ expect(result.excluded).toHaveLength(1);
66
+ expect(result.excluded[0].attachmentId).toBe('att2');
67
+ });
68
+
69
+ it('should track total tokens used', async () => {
70
+ const policy: AttachmentPolicy = {
71
+ maxTokensTotal: 2000,
72
+ selectionStrategy: {
73
+ rankBy: ['purpose'],
74
+ },
75
+ };
76
+
77
+ const selector = new AttachmentSelector(policy, mockEstimator);
78
+
79
+ const attachments: RankedAttachment[] = [
80
+ createRankedAttachment('att1', 'evidence', false, 'Short text', 400), // ~100 tokens
81
+ createRankedAttachment('att2', 'evidence', false, 'Short text', 400), // ~100 tokens
82
+ ];
83
+
84
+ const result = await selector.selectAttachments(attachments);
85
+
86
+ expect(result.tokensUsed).toBeGreaterThan(0);
87
+ expect(result.tokensUsed).toBeLessThanOrEqual(2000);
88
+ });
89
+
90
+ it('should handle zero budget gracefully', async () => {
91
+ const policy: AttachmentPolicy = {
92
+ maxTokensTotal: 0,
93
+ selectionStrategy: {
94
+ rankBy: ['purpose'],
95
+ },
96
+ };
97
+
98
+ const selector = new AttachmentSelector(policy, mockEstimator);
99
+
100
+ const attachments: RankedAttachment[] = [
101
+ createRankedAttachment('att1', 'evidence', false, 'text', 100),
102
+ ];
103
+
104
+ const result = await selector.selectAttachments(attachments);
105
+
106
+ expect(result.selected).toHaveLength(0);
107
+ expect(result.excluded).toHaveLength(1);
108
+ expect(result.tokensUsed).toBe(0);
109
+ });
110
+ });
111
+
112
+ describe('Purpose Ranking', () => {
113
+ it('should prioritize evidence > input > context > artifact', async () => {
114
+ const policy: AttachmentPolicy = {
115
+ maxTokensTotal: 200, // Only 2 attachments fit (100 tokens each)
116
+ selectionStrategy: {
117
+ rankBy: ['purpose'],
118
+ },
119
+ };
120
+
121
+ const selector = new AttachmentSelector(policy, mockEstimator);
122
+
123
+ // Each attachment: 400 chars = ~100 tokens (text.length / 4)
124
+ const largeText = 'x'.repeat(400);
125
+ const attachments: RankedAttachment[] = [
126
+ createRankedAttachment('att1', 'artifact', false, largeText, 100), // Lowest priority
127
+ createRankedAttachment('att2', 'evidence', false, largeText, 100), // Highest priority
128
+ createRankedAttachment('att3', 'context', false, largeText, 100), // Medium priority
129
+ createRankedAttachment('att4', 'input', false, largeText, 100), // High priority
130
+ ];
131
+
132
+ const result = await selector.selectAttachments(attachments);
133
+
134
+ // Should select evidence and input (highest priority)
135
+ expect(result.selected).toHaveLength(2);
136
+ const selectedIds = result.selected.map((a) => a.attachmentId);
137
+ expect(selectedIds).toContain('att2'); // evidence
138
+ expect(selectedIds).toContain('att4'); // input
139
+ });
140
+
141
+ it('should use custom purpose priority if provided', async () => {
142
+ const policy: AttachmentPolicy = {
143
+ maxTokensTotal: 200,
144
+ selectionStrategy: {
145
+ rankBy: ['purpose'],
146
+ purposePriority: {
147
+ artifact: 1, // Highest
148
+ context: 2,
149
+ input: 3,
150
+ evidence: 4, // Lowest
151
+ },
152
+ },
153
+ };
154
+
155
+ const selector = new AttachmentSelector(policy, mockEstimator);
156
+
157
+ const attachments: RankedAttachment[] = [
158
+ createRankedAttachment('att1', 'artifact', false, 'text', 100),
159
+ createRankedAttachment('att2', 'evidence', false, 'text', 100),
160
+ ];
161
+
162
+ const result = await selector.selectAttachments(attachments);
163
+
164
+ // Should prioritize artifact over evidence
165
+ expect(result.selected).toHaveLength(2);
166
+ expect(result.selected[0].attachmentId).toBe('att1'); // artifact first
167
+ });
168
+ });
169
+
170
+ describe('User Mention Ranking', () => {
171
+ it('should prioritize user-mentioned attachments', async () => {
172
+ const policy: AttachmentPolicy = {
173
+ maxTokensTotal: 200,
174
+ selectionStrategy: {
175
+ rankBy: ['user_mention'],
176
+ },
177
+ };
178
+
179
+ const selector = new AttachmentSelector(policy, mockEstimator);
180
+
181
+ // Each attachment: 400 chars = ~100 tokens
182
+ const largeText = 'x'.repeat(400);
183
+ const attachments: RankedAttachment[] = [
184
+ createRankedAttachment('att1', 'context', false, largeText, 100), // Not mentioned
185
+ createRankedAttachment('att2', 'context', true, largeText, 100), // User mentioned
186
+ createRankedAttachment('att3', 'context', true, largeText, 100), // User mentioned
187
+ ];
188
+
189
+ const result = await selector.selectAttachments(attachments);
190
+
191
+ // Should select mentioned attachments first
192
+ expect(result.selected).toHaveLength(2);
193
+ const selectedIds = result.selected.map((a) => a.attachmentId);
194
+ expect(selectedIds).toContain('att2');
195
+ expect(selectedIds).toContain('att3');
196
+ });
197
+ });
198
+
199
+ describe('Recency Ranking', () => {
200
+ it('should prioritize recent attachments', async () => {
201
+ const now = Date.now() / 1000;
202
+
203
+ const policy: AttachmentPolicy = {
204
+ maxTokensTotal: 200,
205
+ selectionStrategy: {
206
+ rankBy: ['recency'],
207
+ },
208
+ };
209
+
210
+ const selector = new AttachmentSelector(policy, mockEstimator);
211
+
212
+ // Each attachment: 400 chars = ~100 tokens
213
+ const largeText = 'x'.repeat(400);
214
+ const attachments: RankedAttachment[] = [
215
+ createRankedAttachment('att1', 'context', false, largeText, now - 1000), // Older
216
+ createRankedAttachment('att2', 'context', false, largeText, now - 100), // Recent
217
+ createRankedAttachment('att3', 'context', false, largeText, now - 10), // Most recent
218
+ ];
219
+
220
+ const result = await selector.selectAttachments(attachments);
221
+
222
+ // Should select most recent
223
+ expect(result.selected).toHaveLength(2);
224
+ const selectedIds = result.selected.map((a) => a.attachmentId);
225
+ expect(selectedIds).toContain('att3'); // Most recent
226
+ expect(selectedIds).toContain('att2'); // Second most recent
227
+ });
228
+ });
229
+
230
+ describe('Multi-Criterion Ranking', () => {
231
+ it('should rank by multiple criteria in priority order', async () => {
232
+ const policy: AttachmentPolicy = {
233
+ maxTokensTotal: 200,
234
+ selectionStrategy: {
235
+ rankBy: ['user_mention', 'purpose'], // Mention first, then purpose
236
+ },
237
+ };
238
+
239
+ const selector = new AttachmentSelector(policy, mockEstimator);
240
+
241
+ // Each attachment: 400 chars = ~100 tokens
242
+ const largeText = 'x'.repeat(400);
243
+ const attachments: RankedAttachment[] = [
244
+ createRankedAttachment('att1', 'evidence', false, largeText, 100), // Not mentioned, high purpose
245
+ createRankedAttachment('att2', 'artifact', true, largeText, 100), // Mentioned, low purpose
246
+ createRankedAttachment('att3', 'input', true, largeText, 100), // Mentioned, medium purpose
247
+ ];
248
+
249
+ const result = await selector.selectAttachments(attachments);
250
+
251
+ // Should select mentioned attachments (att2, att3), prioritize input over artifact
252
+ expect(result.selected).toHaveLength(2);
253
+ const selectedIds = result.selected.map((a) => a.attachmentId);
254
+ expect(selectedIds).toContain('att2');
255
+ expect(selectedIds).toContain('att3');
256
+ });
257
+ });
258
+
259
+ describe('Deterministic Ordering', () => {
260
+ it('should produce same selection for same input', async () => {
261
+ const policy: AttachmentPolicy = {
262
+ maxTokensTotal: 500,
263
+ selectionStrategy: {
264
+ rankBy: ['purpose', 'recency'],
265
+ },
266
+ };
267
+
268
+ const selector = new AttachmentSelector(policy, mockEstimator);
269
+
270
+ const attachments: RankedAttachment[] = [
271
+ createRankedAttachment('att1', 'evidence', false, 'text', 100),
272
+ createRankedAttachment('att2', 'context', false, 'text', 200),
273
+ createRankedAttachment('att3', 'input', false, 'text', 150),
274
+ ];
275
+
276
+ const result1 = await selector.selectAttachments([...attachments]);
277
+ const result2 = await selector.selectAttachments([...attachments]);
278
+
279
+ expect(result1.selected.map((a) => a.attachmentId)).toEqual(
280
+ result2.selected.map((a) => a.attachmentId)
281
+ );
282
+ });
283
+
284
+ it('should use attachmentId as tiebreaker', async () => {
285
+ const policy: AttachmentPolicy = {
286
+ maxTokensTotal: 200,
287
+ selectionStrategy: {
288
+ rankBy: ['purpose'], // Same purpose = tie
289
+ },
290
+ };
291
+
292
+ const selector = new AttachmentSelector(policy, mockEstimator);
293
+
294
+ // Each attachment: 400 chars = ~100 tokens
295
+ const largeText = 'x'.repeat(400);
296
+ const attachments: RankedAttachment[] = [
297
+ createRankedAttachment('bbb', 'evidence', false, largeText, 100),
298
+ createRankedAttachment('aaa', 'evidence', false, largeText, 100),
299
+ createRankedAttachment('ccc', 'evidence', false, largeText, 100),
300
+ ];
301
+
302
+ const result = await selector.selectAttachments(attachments);
303
+
304
+ // Should select in alphabetical order (tiebreaker)
305
+ expect(result.selected).toHaveLength(2);
306
+ expect(result.selected[0].attachmentId).toBe('aaa');
307
+ expect(result.selected[1].attachmentId).toBe('bbb');
308
+ });
309
+ });
310
+
311
+ describe('Token Estimation', () => {
312
+ it('should estimate tokens for text attachments', async () => {
313
+ const policy: AttachmentPolicy = {
314
+ maxTokensTotal: 200,
315
+ selectionStrategy: {
316
+ rankBy: ['purpose'],
317
+ },
318
+ };
319
+
320
+ const selector = new AttachmentSelector(policy, mockEstimator);
321
+
322
+ // ~400 chars = ~100 tokens
323
+ const text = 'x'.repeat(400);
324
+ const attachments: RankedAttachment[] = [
325
+ createRankedAttachment('att1', 'evidence', false, text, 100),
326
+ ];
327
+
328
+ const result = await selector.selectAttachments(attachments);
329
+
330
+ expect(result.tokensUsed).toBeGreaterThan(0);
331
+ expect(result.tokensUsed).toBeLessThanOrEqual(200);
332
+ });
333
+
334
+ it('should estimate tokens for image attachments', async () => {
335
+ const policy: AttachmentPolicy = {
336
+ maxTokensTotal: 1000,
337
+ selectionStrategy: {
338
+ rankBy: ['purpose'],
339
+ },
340
+ };
341
+
342
+ const selector = new AttachmentSelector(policy, mockEstimator);
343
+
344
+ const imageAttachment: RankedAttachment = {
345
+ attachmentId: 'img1',
346
+ purpose: 'evidence',
347
+ userMention: false,
348
+ rankScore: 0,
349
+ filename: 'test.png',
350
+ mimeType: 'image/png',
351
+ sizeBytes: 1024,
352
+ storage: 'local',
353
+ storagePath: '/test/test.png',
354
+ createdAt: Date.now() / 1000,
355
+ content: 'base64data',
356
+ };
357
+
358
+ const result = await selector.selectAttachments([imageAttachment]);
359
+
360
+ // Image should have conservative token estimate (~340 tokens)
361
+ expect(result.tokensUsed).toBeGreaterThan(0);
362
+ });
363
+
364
+ it('should estimate tokens for PDF attachments', async () => {
365
+ const policy: AttachmentPolicy = {
366
+ maxTokensTotal: 1000,
367
+ selectionStrategy: {
368
+ rankBy: ['purpose'],
369
+ },
370
+ };
371
+
372
+ const selector = new AttachmentSelector(policy, mockEstimator);
373
+
374
+ const pdfAttachment: RankedAttachment = {
375
+ attachmentId: 'pdf1',
376
+ purpose: 'evidence',
377
+ userMention: false,
378
+ rankScore: 0,
379
+ filename: 'doc.pdf',
380
+ mimeType: 'application/pdf',
381
+ sizeBytes: 2048,
382
+ storage: 'local',
383
+ storagePath: '/test/doc.pdf',
384
+ createdAt: Date.now() / 1000,
385
+ content: 'base64pdfdata',
386
+ };
387
+
388
+ const result = await selector.selectAttachments([pdfAttachment]);
389
+
390
+ expect(result.tokensUsed).toBeGreaterThan(0);
391
+ });
392
+ });
393
+
394
+ describe('selectFromList convenience method', () => {
395
+ it('should build ranked attachments from list and metadata', async () => {
396
+ const policy: AttachmentPolicy = {
397
+ maxTokensTotal: 500,
398
+ selectionStrategy: {
399
+ rankBy: ['purpose'],
400
+ },
401
+ };
402
+
403
+ const selector = new AttachmentSelector(policy, mockEstimator);
404
+
405
+ const attachments: ResolvedAttachment[] = [
406
+ {
407
+ attachmentId: 'att1',
408
+ filename: 'test.txt',
409
+ mimeType: 'text/plain',
410
+ sizeBytes: 12,
411
+ storage: 'local',
412
+ storagePath: '/test/test.txt',
413
+ createdAt: Date.now() / 1000,
414
+ text: 'Test content',
415
+ },
416
+ {
417
+ attachmentId: 'att2',
418
+ filename: 'test2.txt',
419
+ mimeType: 'text/plain',
420
+ sizeBytes: 14,
421
+ storage: 'local',
422
+ storagePath: '/test/test2.txt',
423
+ createdAt: Date.now() / 1000,
424
+ text: 'Test content 2',
425
+ },
426
+ ];
427
+
428
+ const metadata = new Map([
429
+ ['att1', { purpose: 'evidence' as const, userMention: true }],
430
+ ['att2', { purpose: 'context' as const, userMention: false }],
431
+ ]);
432
+
433
+ const result = await selector.selectFromList(attachments, metadata);
434
+
435
+ expect(result.selected).toHaveLength(2);
436
+ });
437
+
438
+ it('should use default metadata when not provided', async () => {
439
+ const policy: AttachmentPolicy = {
440
+ maxTokensTotal: 500,
441
+ selectionStrategy: {
442
+ rankBy: ['purpose'],
443
+ },
444
+ };
445
+
446
+ const selector = new AttachmentSelector(policy, mockEstimator);
447
+
448
+ const attachments: ResolvedAttachment[] = [
449
+ {
450
+ attachmentId: 'att1',
451
+ filename: 'test.txt',
452
+ mimeType: 'text/plain',
453
+ sizeBytes: 12,
454
+ storage: 'local',
455
+ storagePath: '/test/test.txt',
456
+ createdAt: Date.now() / 1000,
457
+ text: 'Test content',
458
+ },
459
+ ];
460
+
461
+ const metadata = new Map(); // Empty metadata
462
+
463
+ const result = await selector.selectFromList(attachments, metadata);
464
+
465
+ // Should use default: purpose = 'context', userMention = false
466
+ expect(result.selected).toHaveLength(1);
467
+ });
468
+ });
469
+
470
+ describe('Edge Cases', () => {
471
+ it('should handle empty attachment list', async () => {
472
+ const policy: AttachmentPolicy = {
473
+ maxTokensTotal: 1000,
474
+ selectionStrategy: {
475
+ rankBy: ['purpose'],
476
+ },
477
+ };
478
+
479
+ const selector = new AttachmentSelector(policy, mockEstimator);
480
+
481
+ const result = await selector.selectAttachments([]);
482
+
483
+ expect(result.selected).toHaveLength(0);
484
+ expect(result.excluded).toHaveLength(0);
485
+ expect(result.tokensUsed).toBe(0);
486
+ });
487
+
488
+ it('should handle single attachment', async () => {
489
+ const policy: AttachmentPolicy = {
490
+ maxTokensTotal: 1000,
491
+ selectionStrategy: {
492
+ rankBy: ['purpose'],
493
+ },
494
+ };
495
+
496
+ const selector = new AttachmentSelector(policy, mockEstimator);
497
+
498
+ const attachments: RankedAttachment[] = [
499
+ createRankedAttachment('att1', 'evidence', false, 'text', 100),
500
+ ];
501
+
502
+ const result = await selector.selectAttachments(attachments);
503
+
504
+ expect(result.selected).toHaveLength(1);
505
+ expect(result.excluded).toHaveLength(0);
506
+ });
507
+
508
+ it('should handle all attachments over budget', async () => {
509
+ const policy: AttachmentPolicy = {
510
+ maxTokensTotal: 50, // Very small budget
511
+ selectionStrategy: {
512
+ rankBy: ['purpose'],
513
+ },
514
+ };
515
+
516
+ const selector = new AttachmentSelector(policy, mockEstimator);
517
+
518
+ const largeText = 'x'.repeat(1000);
519
+ const attachments: RankedAttachment[] = [
520
+ createRankedAttachment('att1', 'evidence', false, largeText, 100),
521
+ createRankedAttachment('att2', 'evidence', false, largeText, 100),
522
+ ];
523
+
524
+ const result = await selector.selectAttachments(attachments);
525
+
526
+ expect(result.selected).toHaveLength(0);
527
+ expect(result.excluded).toHaveLength(2);
528
+ expect(result.tokensUsed).toBe(0);
529
+ });
530
+ });
531
+ });
532
+
533
+ // Test helpers
534
+
535
+ function createRankedAttachment(
536
+ id: string,
537
+ purpose: 'evidence' | 'input' | 'context' | 'artifact',
538
+ userMention: boolean,
539
+ text: string,
540
+ createdAt: number
541
+ ): RankedAttachment {
542
+ // If createdAt looks like a character count (small number), assume it's meant as a timestamp offset
543
+ // Otherwise, treat it as Unix timestamp in seconds
544
+ const normalizedCreatedAt = createdAt < 10000 ? Date.now() / 1000 - createdAt : createdAt;
545
+
546
+ return {
547
+ attachmentId: id,
548
+ purpose,
549
+ userMention,
550
+ rankScore: 0, // Will be computed
551
+ filename: `${id}.txt`,
552
+ mimeType: 'text/plain',
553
+ sizeBytes: text.length,
554
+ storage: 'local',
555
+ storagePath: `/test/${id}.txt`,
556
+ createdAt: normalizedCreatedAt,
557
+ text,
558
+ };
559
+ }