@lensagent/core 0.1.0 → 0.1.2

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 (99) hide show
  1. package/README.md +63 -4
  2. package/config/lens.example.jsonc +36 -13
  3. package/dist/adapters/executor-router.d.ts +20 -0
  4. package/dist/adapters/executor-router.js +86 -0
  5. package/dist/adapters/executor.d.ts +9 -3
  6. package/dist/adapters/executor.js +196 -61
  7. package/dist/adapters/lsp.d.ts +2 -1
  8. package/dist/adapters/lsp.js +98 -41
  9. package/dist/adapters/mcp.js +52 -17
  10. package/dist/adapters/revert.d.ts +5 -4
  11. package/dist/adapters/revert.js +6 -64
  12. package/dist/adapters/web.d.ts +9 -0
  13. package/dist/adapters/web.js +244 -0
  14. package/dist/config/files.d.ts +14 -0
  15. package/dist/config/files.js +57 -0
  16. package/dist/config/runtime.d.ts +3 -4
  17. package/dist/config/runtime.js +116 -97
  18. package/dist/config/schema.d.ts +56 -10
  19. package/dist/config/schema.js +48 -8
  20. package/dist/contracts/capability.d.ts +27 -1
  21. package/dist/contracts/domain.d.ts +1 -0
  22. package/dist/contracts/model.d.ts +33 -6
  23. package/dist/contracts/review.d.ts +17 -0
  24. package/dist/contracts/review.js +1 -0
  25. package/dist/core/access-policy.d.ts +40 -0
  26. package/dist/core/access-policy.js +68 -0
  27. package/dist/core/access.d.ts +30 -2
  28. package/dist/core/access.js +159 -26
  29. package/dist/core/context-boundaries.d.ts +25 -0
  30. package/dist/core/context-boundaries.js +106 -0
  31. package/dist/core/context.d.ts +3 -0
  32. package/dist/core/context.js +11 -0
  33. package/dist/core/host.js +261 -173
  34. package/dist/entry/pair.d.ts +1 -0
  35. package/dist/entry/pair.js +14 -0
  36. package/dist/entry/run.js +1 -1
  37. package/dist/execution/lsp-process.d.ts +3 -2
  38. package/dist/execution/lsp-process.js +46 -28
  39. package/dist/http/server.d.ts +5 -0
  40. package/dist/http/server.js +262 -80
  41. package/dist/runtime/agent.d.ts +9 -4
  42. package/dist/runtime/agent.js +361 -286
  43. package/dist/runtime/artifacts.d.ts +13 -1
  44. package/dist/runtime/artifacts.js +137 -10
  45. package/dist/runtime/cache.d.ts +59 -16
  46. package/dist/runtime/cache.js +124 -43
  47. package/dist/runtime/calls.d.ts +1 -1
  48. package/dist/runtime/calls.js +164 -48
  49. package/dist/runtime/capabilities.d.ts +2 -2
  50. package/dist/runtime/capabilities.js +4 -5
  51. package/dist/runtime/cleanup.d.ts +5 -2
  52. package/dist/runtime/cleanup.js +48 -17
  53. package/dist/runtime/compaction.d.ts +9 -0
  54. package/dist/runtime/compaction.js +52 -0
  55. package/dist/runtime/context.d.ts +8 -4
  56. package/dist/runtime/context.js +115 -128
  57. package/dist/runtime/events.d.ts +7 -0
  58. package/dist/runtime/events.js +83 -0
  59. package/dist/runtime/images.d.ts +14 -0
  60. package/dist/runtime/images.js +82 -0
  61. package/dist/runtime/kernel-capabilities.d.ts +7 -1
  62. package/dist/runtime/kernel-capabilities.js +146 -68
  63. package/dist/runtime/model-audit.d.ts +9 -0
  64. package/dist/runtime/model-audit.js +39 -0
  65. package/dist/runtime/model.d.ts +109 -4
  66. package/dist/runtime/model.js +368 -143
  67. package/dist/runtime/observability.d.ts +1 -0
  68. package/dist/runtime/observability.js +60 -15
  69. package/dist/runtime/policy.d.ts +1 -1
  70. package/dist/runtime/policy.js +2 -2
  71. package/dist/runtime/profile.d.ts +2 -1
  72. package/dist/runtime/profile.js +12 -15
  73. package/dist/runtime/projects.d.ts +3 -1
  74. package/dist/runtime/projects.js +38 -17
  75. package/dist/runtime/ptc-worker.d.ts +1 -0
  76. package/dist/runtime/ptc-worker.js +97 -0
  77. package/dist/runtime/ptc.d.ts +16 -0
  78. package/dist/runtime/ptc.js +128 -0
  79. package/dist/runtime/results.d.ts +5 -4
  80. package/dist/runtime/results.js +29 -51
  81. package/dist/runtime/review.d.ts +4 -1
  82. package/dist/runtime/review.js +101 -22
  83. package/dist/runtime/scheduler.js +1 -0
  84. package/dist/runtime/skills.js +34 -5
  85. package/dist/runtime/task-audit.d.ts +21 -0
  86. package/dist/runtime/task-audit.js +122 -0
  87. package/dist/runtime/time.d.ts +9 -0
  88. package/dist/runtime/time.js +18 -0
  89. package/dist/runtime/tokenizer.d.ts +2 -0
  90. package/dist/runtime/tokenizer.js +26 -1
  91. package/dist/store/database.js +26 -1
  92. package/dist/store/event-log.d.ts +1 -0
  93. package/dist/store/event-log.js +22 -5
  94. package/dist/store/schema.d.ts +1 -1
  95. package/dist/store/schema.js +50 -1
  96. package/dist/store/store.d.ts +65 -19
  97. package/dist/store/store.js +986 -188
  98. package/package.json +18 -8
  99. package/proto/executor.proto +69 -55
@@ -1,6 +1,10 @@
1
+ import { assertContextBoundaries, contextToolCalls } from '../core/context-boundaries.js';
2
+ import { canonicalize } from 'json-canonicalize';
1
3
  import { digest, toolArguments } from '../core/codec.js';
4
+ import { activeContextItems } from '../core/context.js';
2
5
  import { fail } from '../core/domain.js';
3
- import { estimateRequest, estimateTokens } from './tokenizer.js';
6
+ import { estimateRequest, estimateTokens, tokenSlice } from './tokenizer.js';
7
+ import { resultImages } from './images.js';
4
8
  export class ContextEngine {
5
9
  store;
6
10
  registry;
@@ -9,161 +13,144 @@ export class ContextEngine {
9
13
  this.registry = registry;
10
14
  }
11
15
  capture(projectId, agentId) {
16
+ this.store.syncTodoReminder(projectId, agentId);
12
17
  return this.store.contextSource(projectId, agentId);
13
18
  }
14
19
  prepare(projectId, agentId, config, contextWindowTokens, profile, requestFrame) {
15
- return this.prepareSource(this.capture(projectId, agentId), config, contextWindowTokens, profile, requestFrame);
20
+ return this.prepareSource(this.capture(projectId, agentId), config, contextWindowTokens, profile, requestFrame, projectId);
16
21
  }
17
- prepareSource(source, config, contextWindowTokens, profile, requestFrame) {
18
- const agentId = string(source.agent.id, 'agent_id');
19
- const sourceRevision = number(source.agent.context_revision, 'context_revision');
22
+ prepareSource(source, config, contextWindowTokens, profile, requestFrame, projectId) {
23
+ const agentId = string(source.agent.id);
24
+ const sourceRevision = Number(source.agent.context_revision);
25
+ const fullItems = activeContextItems(source.items);
26
+ let workItems = activeContextItems(source.workingItems ?? source.items);
27
+ const fullMessages = this.#messages(source, fullItems, profile);
28
+ let messages = this.#messages(source, workItems, profile);
29
+ assertContextBoundaries(fullMessages, { allowOpenTail: true, unsettledItemIds: source.unsettledItemIds ?? [] });
30
+ assertContextBoundaries(messages, { allowOpenTail: true, unsettledItemIds: source.unsettledItemIds ?? [] });
31
+ let estimate = estimateMessages(messages, requestFrame);
32
+ const decisions = [];
33
+ const edits = [];
34
+ if (estimate.expected >= contextWindowTokens * 0.6) {
35
+ const sourceTokens = estimate.expected;
36
+ const results = workItems.filter((item) => item.kind === 'result');
37
+ const olderIds = new Set(results.slice(0, Math.max(0, results.length - 15)).map((item) => item.id));
38
+ workItems = workItems.map((item) => {
39
+ if (!olderIds.has(item.id) || item.snipped === true || item.tool_name === 'programmatic_tool_run')
40
+ return item;
41
+ const content = snipResult(string(item.content));
42
+ if (content === item.content && resultImages(item, projectId ?? (typeof source.agent.project_id === 'string' ? source.agent.project_id : undefined), this.store).length === 0)
43
+ return item;
44
+ edits.push({ id: string(item.id), content });
45
+ // Everything except the payload is retained, including source identity and metadata.
46
+ return { ...item, content, snipped: true };
47
+ });
48
+ messages = this.#messages(source, workItems, profile);
49
+ estimate = estimateMessages(messages, requestFrame);
50
+ if (edits.length > 0)
51
+ decisions.push({ stage: 'snip', sourceTokens, projectedTokens: estimate.expected });
52
+ }
53
+ if (projectId !== undefined) {
54
+ this.store.saveWorkingContext(projectId, agentId, sourceRevision, edits, messages, messages.filter((message) => message.contextItemId === undefined), requestFrame ?? null);
55
+ }
56
+ return { source: { ...source, workingItems: workItems }, fullMessages, messages, estimate, sourceRevision, digest: digest(messages), decisions };
57
+ }
58
+ #messages(source, items, profile) {
20
59
  const overrides = new Map(source.overrides.map((entry) => [`${string(entry.target_kind)}:${string(entry.target_id)}`, string(entry.instructions)]));
21
- const visibleItems = source.items.filter((item) => item.visible === true);
22
- const latestCompaction = visibleItems.findLast((item) => item.kind === 'compaction');
23
- const workItems = latestCompaction === undefined
24
- ? visibleItems
25
- : [latestCompaction, ...visibleItems.filter((item) => item.id !== latestCompaction.id && item.locked !== true)];
26
- let messages = workItems.map((item) => {
60
+ let messages = items.map((item) => {
27
61
  const role = roleOf(item.role);
28
62
  const content = string(item.content);
63
+ const metadata = { ...object(item.metadata) };
64
+ const response = metadata.model_response;
65
+ delete metadata.model_response;
66
+ const annotations = { contextItemId: string(item.id), metadata: { ...metadata, ...(item.snipped === true ? { snipped: true } : {}) } };
29
67
  if (item.kind === 'tool_calls') {
30
- const calls = JSON.parse(content);
31
- if (!Array.isArray(calls))
32
- fail('DATABASE_CORRUPT', 503, 'A persisted Tool Call block is invalid.');
68
+ const toolCalls = contextToolCalls(content).map((call) => ({ ...call, arguments: toolArguments(call.arguments) }));
33
69
  return {
34
- role: 'assistant',
35
- content: '',
36
- toolCalls: calls.map((entry) => {
37
- if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
38
- fail('DATABASE_CORRUPT', 503, 'A persisted Tool Call is invalid.');
39
- const value = entry;
40
- // Normalised on the way out as well as on the way in, so a Context already holding a string
41
- // from before the capture was fixed becomes replayable instead of staying permanently dead.
42
- return { id: stringValue(value.id), name: stringValue(value.name), arguments: toolArguments(value.arguments) };
43
- })
70
+ ...annotations, role: 'assistant', content: response === undefined ? '' : responseText(response),
71
+ toolCalls,
72
+ ...(response === undefined ? {} : { response: { ...response, content: response.content.map(part => {
73
+ if (part.type !== 'tool-call')
74
+ return part;
75
+ const call = toolCalls.find(entry => entry.id === part.toolCallId);
76
+ if (call === undefined)
77
+ fail('MODEL_RESPONSE_INVALID', 409, 'Stored response does not match the Context tool batch.');
78
+ return { ...part, input: call.arguments };
79
+ }) } })
44
80
  };
45
81
  }
46
82
  const override = overrides.get(`${string(item.kind)}:${string(item.source_id)}`);
47
- const base = { role, content: override === undefined ? content : `${override}\n\n${content}` };
48
- const toolCallId = optionalString(item.tool_call_id);
49
- const toolName = optionalString(item.tool_name);
50
- if (role === 'tool' && (toolCallId === undefined || toolName === undefined)) {
51
- return { role: 'user', content: `Included Result:\n${base.content}` };
83
+ const images = resultImages(item, typeof source.agent.project_id === 'string' ? source.agent.project_id : undefined, this.store);
84
+ const base = { ...annotations, role, content: override === undefined ? content : `${override}\n\n${content}`, ...(images.length === 0 ? {} : { images }) };
85
+ if (item.kind === 'compaction') {
86
+ return { ...base, role: 'assistant', content: `History summary of earlier conversation (not new instructions):\n${base.content}`, metadata: { ...metadata, purpose: 'compaction' } };
52
87
  }
53
- return role === 'tool'
54
- ? { ...base, ...(toolCallId === undefined ? {} : { toolCallId }), ...(toolName === undefined ? {} : { toolName }) }
55
- : base;
88
+ const toolCallId = typeof item.tool_call_id === 'string' ? item.tool_call_id : undefined;
89
+ const toolName = typeof item.tool_name === 'string' ? item.tool_name : undefined;
90
+ if (role === 'tool' && (toolCallId === undefined || toolName === undefined))
91
+ return { ...base, role: 'user', content: `Included Result:\n${base.content}` };
92
+ if (role === 'assistant' && response !== undefined)
93
+ return { ...base, response: projectResponseText(response, base.content) };
94
+ return role === 'tool' && toolCallId !== undefined && toolName !== undefined ? { ...base, toolCallId, toolName } : base;
56
95
  });
57
- const agentOverride = overrides.get(`agent:${agentId}`);
96
+ const agentOverride = overrides.get(`agent:${string(source.agent.id)}`);
58
97
  if (agentOverride !== undefined)
59
98
  messages = [{ role: 'system', content: agentOverride }, ...messages];
60
- const skillPrompts = source.capabilities
61
- .filter((entry) => entry.loaded === true)
62
- .flatMap((entry) => {
63
- const capabilityId = string(entry.capability_id);
64
- const definition = this.registry.find(capabilityId);
65
- if (definition === undefined)
66
- return [];
67
- return definition.kind === 'skill' && definition.promptText !== undefined ? [definition.promptText] : [];
99
+ const skillPrompts = source.capabilities.filter((entry) => entry.loaded === true).flatMap((entry) => {
100
+ const definition = this.registry.find(string(entry.capability_id));
101
+ return definition?.kind === 'skill' && definition.promptText !== undefined ? [definition.promptText] : [];
68
102
  });
69
103
  if (skillPrompts.length > 0)
70
104
  messages = [{ role: 'system', content: skillPrompts.join('\n\n') }, ...messages];
71
- messages = [{ role: 'system', content: profile.value.systemPrompt.content }, ...messages];
72
- const reserve = config.contextBudget?.outputReserveTokens ?? 4096;
73
- const margin = config.contextBudget?.safetyMarginTokens ?? 1024;
74
- const maximum = contextWindowTokens - reserve - margin;
75
- if (maximum < 1)
76
- fail('CONTEXT_BUDGET_INVALID', 503, 'The configured context budget leaves no model input capacity.');
77
- const decisions = [];
78
- let estimate = estimateMessages(messages, requestFrame);
79
- const projection = config.contextProjection;
80
- if (estimate.safe > maximum && projection?.toolResultMaxTokens !== undefined) {
81
- const sourceTokens = estimate.safe;
82
- messages = toolResultBudget(messages, projection.toolResultMaxTokens);
83
- estimate = estimateMessages(messages, requestFrame);
84
- decisions.push({ stage: 'tool_result_budget', sourceTokens, projectedTokens: estimate.safe });
85
- }
86
- if (estimate.safe > maximum && projection?.snip !== undefined && estimate.safe > projection.snip.triggerTokens && messages.length > 4) {
87
- const sourceTokens = estimate.safe;
88
- const retainedBytes = Math.max(1, Math.floor(projection.snip.retainedTokens * 3 / Math.max(1, messages.length - 3)));
89
- messages = messages.map((entry, index) => index > 0 && index < messages.length - 2 ? { ...entry, content: snip(entry.content, retainedBytes) } : entry);
90
- estimate = estimateMessages(messages, requestFrame);
91
- decisions.push({ stage: 'snip', sourceTokens, projectedTokens: estimate.safe });
92
- }
93
- if (estimate.safe > maximum && projection?.microcompact !== undefined && estimate.safe > projection.microcompact.triggerTokens && messages.length > 6) {
94
- const sourceTokens = estimate.safe;
95
- const head = first(messages);
96
- const tail = messages.slice(-4);
97
- messages = [head, { role: 'system', content: snip(localSummary(messages.slice(1, -4)), projection.microcompact.projectedTokens * 3) }, ...tail];
98
- estimate = estimateMessages(messages, requestFrame);
99
- decisions.push({ stage: 'microcompact', sourceTokens, projectedTokens: estimate.safe });
100
- }
101
- if (estimate.safe > maximum && projection?.contextCollapse !== undefined && estimate.safe > projection.contextCollapse.triggerTokens && messages.length > 2) {
102
- const sourceTokens = estimate.safe;
103
- const target = Math.min(maximum, projection.contextCollapse.projectedTokens);
104
- while (messages.length > 2 && estimate.safe > target) {
105
- messages = [first(messages), ...messages.slice(2)];
106
- estimate = estimateMessages(messages, requestFrame);
107
- }
108
- decisions.push({ stage: 'context_collapse', sourceTokens, projectedTokens: estimate.safe });
109
- }
110
- return {
111
- source,
112
- messages,
113
- estimate,
114
- sourceRevision,
115
- digest: digest(messages),
116
- decisions
117
- };
105
+ return [{ role: 'system', content: profile.value.systemPrompt.content }, ...messages];
118
106
  }
119
107
  }
120
- function estimateMessages(messages, requestFrame) {
121
- return requestFrame === undefined
122
- ? estimateTokens(messages.map((entry) => `${entry.role}\n${entry.content}`).join('\n'))
123
- : estimateRequest({ messages, ...requestFrame });
124
- }
125
- function toolResultBudget(messages, maximumTokens) {
126
- const maximumBytes = maximumTokens * 3;
127
- return messages.map((entry) => entry.role === 'tool' && Buffer.byteLength(entry.content) > maximumBytes
128
- ? { ...entry, content: `${Buffer.from(entry.content).subarray(0, maximumBytes).toString()}\n[tool result truncated]` }
129
- : entry);
108
+ function responseText(response) {
109
+ return response.content.filter(part => part.type === 'text').map(part => part.text).join('');
130
110
  }
131
- function snip(value, maximumBytes) {
132
- if (Buffer.byteLength(value) <= maximumBytes)
133
- return value;
134
- const half = Math.floor(maximumBytes / 2);
135
- const bytes = Buffer.from(value);
136
- return `${bytes.subarray(0, half).toString()}\n[snip]\n${bytes.subarray(bytes.length - half).toString()}`;
111
+ /** Context text edits do not rewrite signatures or lose the original block ordering. */
112
+ function projectResponseText(response, text) {
113
+ if (responseText(response) === text)
114
+ return response;
115
+ const firstText = response.content.findIndex(part => part.type === 'text');
116
+ const content = response.content.flatMap((part, index) => {
117
+ if (part.type !== 'text')
118
+ return [part];
119
+ return index === firstText ? [{ ...part, text }] : [];
120
+ });
121
+ if (firstText === -1 && text.length > 0)
122
+ content.push({ type: 'text', text });
123
+ return { ...response, content };
137
124
  }
138
- function localSummary(messages) {
139
- return `Earlier context (${messages.length} messages):\n${messages.map((entry) => `- ${entry.role}: ${snip(entry.content.replaceAll('\n', ' '), 256)}`).join('\n')}`;
140
- }
141
- function roleOf(value) {
142
- if (value === 'system' || value === 'user' || value === 'assistant' || value === 'tool')
143
- return value;
144
- return 'user';
125
+ function estimateMessages(messages, requestFrame) {
126
+ return estimateRequest({ messages, ...requestFrame });
145
127
  }
146
- function string(value, field = 'value') {
147
- if (typeof value !== 'string')
148
- fail('DATABASE_CORRUPT', 503, `${field} is not a string.`);
149
- return value;
128
+ function snipResult(content) {
129
+ let envelope;
130
+ try {
131
+ envelope = object(JSON.parse(content));
132
+ }
133
+ catch {
134
+ return estimateTokens(content).expected > 1500 ? snippedText(content) : content;
135
+ }
136
+ if (envelope === null || envelope.content === undefined)
137
+ return content;
138
+ const payload = typeof envelope.content === 'string' ? envelope.content : canonicalize(envelope.content);
139
+ if (estimateTokens(payload).expected <= 1500)
140
+ return content;
141
+ return canonicalize({ ...envelope, content: snippedText(payload), truncated: true, payload: { ...object(envelope.payload), capture: object(envelope.payload)?.capture ?? 'unknown', stored: object(envelope.payload)?.stored ?? 'unknown', view: 'preview' } });
150
142
  }
151
- function number(value, field) {
152
- if (typeof value !== 'number')
153
- fail('DATABASE_CORRUPT', 503, `${field} is not a number.`);
154
- return value;
143
+ function snippedText(payload) {
144
+ return `${tokenSlice(payload, 800, false)}\n\n[... snipped; original retained in Full Context ...]\n\n${tokenSlice(payload, 400, true)}`;
155
145
  }
156
- function optionalString(value) {
157
- return typeof value === 'string' ? value : undefined;
146
+ function object(value) {
147
+ return value !== undefined && value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
158
148
  }
159
- function first(messages) {
160
- const message = messages[0];
161
- if (message === undefined)
162
- fail('CONTEXT_EMPTY', 503, 'Prepared Context is empty.');
163
- return message;
149
+ function roleOf(value) {
150
+ return value === 'system' || value === 'assistant' || value === 'tool' ? value : 'user';
164
151
  }
165
- function stringValue(value) {
152
+ function string(value) {
166
153
  if (typeof value !== 'string')
167
- fail('DATABASE_CORRUPT', 503, 'A persisted Tool Call field is invalid.');
154
+ fail('DATABASE_CORRUPT', 503, 'A Context field is not a string.');
168
155
  return value;
169
156
  }
@@ -18,5 +18,12 @@ export declare class EventBus {
18
18
  emit(projectId: string, stateRevision: number, type: RuntimeEvent['type'], payload: Json): void;
19
19
  subscribe(clientId: string, projectId: string): Subscription;
20
20
  revoke(clientId: string): void;
21
+ /** Internal committed-state notifications, independent of SSE buffers and client revocation. */
22
+ onProjectChange(projectId: string, changed: () => void, closed?: () => void): () => void;
23
+ /** Subscribe before reading committed state; undefined means the condition is still pending. */
24
+ waitFor<T>(projectId: string, read: () => T | undefined, signal: AbortSignal, timeout?: {
25
+ readonly ms: number;
26
+ readonly error: Error;
27
+ }): Promise<T>;
21
28
  close(): void;
22
29
  }
@@ -5,6 +5,8 @@ export class EventBus {
5
5
  metadataRevision;
6
6
  #subscribers = new Map();
7
7
  #lifecycle = new EventEmitter();
8
+ #waiters = new Map();
9
+ #closed = false;
8
10
  #limits;
9
11
  constructor(instanceEpoch, limits, metadataRevision) {
10
12
  this.instanceEpoch = instanceEpoch;
@@ -15,6 +17,8 @@ export class EventBus {
15
17
  this.#limits = limits;
16
18
  }
17
19
  publish(change) {
20
+ for (const waiter of this.#waiters.get(change.projectId) ?? [])
21
+ waiter.wake();
18
22
  // Drops the two internal aliases and forwards the rest of `ChangedEntityRef` whole. Listing the
19
23
  // fields to keep is what broke this twice: first it reprojected each entry down to `{ type, id }`
20
24
  // and discarded the `read_route` a subscriber needs, then the replacement enumerated six fields
@@ -65,7 +69,86 @@ export class EventBus {
65
69
  this.#close(subscriber);
66
70
  }
67
71
  }
72
+ /** Internal committed-state notifications, independent of SSE buffers and client revocation. */
73
+ onProjectChange(projectId, changed, closed = () => { }) {
74
+ if (this.#closed)
75
+ throw new Error('Event bus closed.');
76
+ const waiters = this.#waiters.get(projectId) ?? new Set();
77
+ this.#waiters.set(projectId, waiters);
78
+ let active = true;
79
+ let queued = false;
80
+ const unsubscribe = () => {
81
+ active = false;
82
+ waiters.delete(waiter);
83
+ if (waiters.size === 0)
84
+ this.#waiters.delete(projectId);
85
+ };
86
+ const waiter = {
87
+ wake: () => {
88
+ if (!active || queued)
89
+ return;
90
+ queued = true;
91
+ // Coalesce commits and never run database reads inside the publisher.
92
+ queueMicrotask(() => {
93
+ queued = false;
94
+ if (active)
95
+ changed();
96
+ });
97
+ },
98
+ close: () => { unsubscribe(); closed(); }
99
+ };
100
+ waiters.add(waiter);
101
+ return unsubscribe;
102
+ }
103
+ /** Subscribe before reading committed state; undefined means the condition is still pending. */
104
+ waitFor(projectId, read, signal, timeout) {
105
+ return new Promise((resolve, reject) => {
106
+ signal.throwIfAborted();
107
+ let settled = false;
108
+ let timer;
109
+ const cleanup = () => {
110
+ settled = true;
111
+ clearTimeout(timer);
112
+ signal.removeEventListener('abort', abort);
113
+ unsubscribe();
114
+ };
115
+ const fail = (error) => {
116
+ if (settled)
117
+ return;
118
+ cleanup();
119
+ // AbortSignal.reason is caller-owned and need not be an Error.
120
+ // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
121
+ reject(error);
122
+ };
123
+ const abort = () => fail(signal.reason);
124
+ const check = () => {
125
+ if (settled)
126
+ return;
127
+ try {
128
+ signal.throwIfAborted();
129
+ const value = read();
130
+ if (value !== undefined) {
131
+ cleanup();
132
+ resolve(value);
133
+ }
134
+ }
135
+ catch (error) {
136
+ fail(error);
137
+ }
138
+ };
139
+ const unsubscribe = this.onProjectChange(projectId, check, () => fail(new Error('Event bus closed.')));
140
+ signal.addEventListener('abort', abort, { once: true });
141
+ if (timeout !== undefined)
142
+ timer = setTimeout(() => fail(timeout.error), timeout.ms);
143
+ check();
144
+ });
145
+ }
68
146
  close() {
147
+ this.#closed = true;
148
+ for (const waiters of this.#waiters.values()) {
149
+ for (const waiter of waiters)
150
+ waiter.close();
151
+ }
69
152
  for (const subscriber of this.#subscribers.values())
70
153
  this.#close(subscriber);
71
154
  this.#lifecycle.removeAllListeners();
@@ -0,0 +1,14 @@
1
+ import type { ModelMessage as AiMessage } from 'ai';
2
+ import { type ModelImage, type ModelMessage } from '../core/domain.js';
3
+ import type { Document, Store } from '../store/store.js';
4
+ import type { ArtifactStore } from './artifacts.js';
5
+ export declare function isImageMediaType(value: unknown): value is string;
6
+ export declare const imageTokenEstimate = 4096;
7
+ export declare const imageByteLimit: number;
8
+ /** Only Result attachments are interpreted as images; ordinary message text is never parsed. */
9
+ export declare function resultImages(item: Document, projectId: string | undefined, store: Store): readonly ModelImage[];
10
+ export declare function imageReference(image: ModelImage): string;
11
+ /** Chat-compatible providers need images in user content, after the complete tool-result group. */
12
+ export declare function imageMessages(messages: readonly ModelMessage[]): ModelMessage[];
13
+ /** Keep binary data out of stored Context and attempt diagnostics; load verified bytes only for sending. */
14
+ export declare function materializeImages(messages: readonly AiMessage[], source: readonly ModelMessage[], artifacts?: ArtifactStore): Promise<AiMessage[]>;
@@ -0,0 +1,82 @@
1
+ import { fail } from '../core/domain.js';
2
+ const mediaTypes = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
3
+ export function isImageMediaType(value) {
4
+ return typeof value === 'string' && mediaTypes.has(value);
5
+ }
6
+ export const imageTokenEstimate = 4096;
7
+ export const imageByteLimit = 5 * 1024 * 1024;
8
+ /** Only Result attachments are interpreted as images; ordinary message text is never parsed. */
9
+ export function resultImages(item, projectId, store) {
10
+ if (item.kind !== 'result' || item.snipped === true)
11
+ return [];
12
+ let envelope;
13
+ try {
14
+ envelope = JSON.parse(item.content);
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ if (!Array.isArray(envelope?.artifacts))
20
+ return [];
21
+ return envelope.artifacts.flatMap((id) => {
22
+ if (typeof id !== 'string')
23
+ return [];
24
+ if (projectId === undefined)
25
+ fail('DATABASE_CORRUPT', 503, 'Image Result is missing its Project.');
26
+ const artifact = store.getArtifact(projectId, id);
27
+ const mediaType = artifact.media_type;
28
+ if (!mediaTypes.has(mediaType))
29
+ return [];
30
+ if (Number(artifact.size_bytes) > imageByteLimit)
31
+ fail('IMAGE_TOO_LARGE', 413, 'Images must not exceed 5 MiB.');
32
+ return [{ projectId, artifactId: id }];
33
+ });
34
+ }
35
+ export function imageReference(image) {
36
+ return `lens-artifact:${image.artifactId}`;
37
+ }
38
+ /** Chat-compatible providers need images in user content, after the complete tool-result group. */
39
+ export function imageMessages(messages) {
40
+ const result = [];
41
+ let pending = [];
42
+ let labels = [];
43
+ const flush = () => {
44
+ if (pending.length === 0)
45
+ return;
46
+ result.push({ role: 'user', content: `Images attached to tool results: ${labels.join(', ')}`, images: pending });
47
+ pending = [];
48
+ labels = [];
49
+ };
50
+ for (const message of messages) {
51
+ if (message.role !== 'tool')
52
+ flush();
53
+ result.push(message);
54
+ if (message.role === 'tool' && message.images?.length) {
55
+ pending.push(...message.images);
56
+ labels.push(`${message.toolName} (${message.toolCallId})`);
57
+ }
58
+ }
59
+ flush();
60
+ return result;
61
+ }
62
+ /** Keep binary data out of stored Context and attempt diagnostics; load verified bytes only for sending. */
63
+ export async function materializeImages(messages, source, artifacts) {
64
+ const references = new Map(source.flatMap(message => (message.images ?? []).map(image => [imageReference(image), image])));
65
+ if (references.size === 0)
66
+ return [...messages];
67
+ if (artifacts === undefined)
68
+ fail('ARTIFACT_STORE_UNAVAILABLE', 503, 'Image input requires an Artifact store.');
69
+ const data = new Map();
70
+ for (const [reference, image] of references) {
71
+ const artifact = artifacts.store.getArtifact(image.projectId, image.artifactId);
72
+ const mediaType = artifact.media_type;
73
+ if (!isImageMediaType(mediaType))
74
+ fail('IMAGE_FORMAT_UNSUPPORTED', 422, 'Unsupported image format.');
75
+ if (Number(artifact.size_bytes) > imageByteLimit)
76
+ fail('IMAGE_TOO_LARGE', 413, 'Images must not exceed 5 MiB.');
77
+ data.set(reference, `data:${mediaType};base64,${(await artifacts.read(artifact)).toString('base64')}`);
78
+ }
79
+ return messages.map(message => message.role !== 'user' || typeof message.content === 'string' ? message : {
80
+ ...message, content: message.content.map(part => part.type !== 'image' || typeof part.image !== 'string' ? part : { ...part, image: data.get(part.image) ?? part.image })
81
+ });
82
+ }
@@ -1,3 +1,4 @@
1
+ import { type ArtifactStore } from './artifacts.js';
1
2
  import { type CapabilityAdapter, type CapabilityInvocation, type CapabilityOutput } from '../core/domain.js';
2
3
  import type { ConfigRuntime } from '../config/runtime.js';
3
4
  import type { Document, Store } from '../store/store.js';
@@ -16,7 +17,11 @@ export declare class KernelAdapter implements CapabilityAdapter {
16
17
  readonly modelReasoningEffort?: string;
17
18
  readonly initialPrompt?: string;
18
19
  readonly initialMessage?: string;
20
+ readonly compactFocus?: string;
19
21
  }, signal: AbortSignal) => Promise<Document>;
22
+ readonly compactAgent: (projectId: string, agentId: string, focus: string | undefined, signal: AbortSignal) => Promise<Document>;
23
+ readonly runProgram?: ((invocation: CapabilityInvocation) => Promise<CapabilityOutput>) | undefined;
24
+ readonly artifacts?: ArtifactStore | undefined;
20
25
  readonly id = "kernel";
21
26
  constructor(store: Store, registry: CapabilityRegistry, config: ConfigRuntime, wakeAgent: (projectId: string, agentId: string) => void, createSubagent: (projectId: string, input: {
22
27
  readonly parentAgentId: string;
@@ -26,7 +31,8 @@ export declare class KernelAdapter implements CapabilityAdapter {
26
31
  readonly modelReasoningEffort?: string;
27
32
  readonly initialPrompt?: string;
28
33
  readonly initialMessage?: string;
29
- }, signal: AbortSignal) => Promise<Document>);
34
+ readonly compactFocus?: string;
35
+ }, signal: AbortSignal) => Promise<Document>, compactAgent: (projectId: string, agentId: string, focus: string | undefined, signal: AbortSignal) => Promise<Document>, runProgram?: ((invocation: CapabilityInvocation) => Promise<CapabilityOutput>) | undefined, artifacts?: ArtifactStore | undefined);
30
36
  execute(invocation: CapabilityInvocation): Promise<CapabilityOutput>;
31
37
  }
32
38
  export declare function kernelCapabilities(adapter: KernelAdapter): readonly RegisteredCapability[];