@goondan/openharness-base 0.1.2 → 0.1.4

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.
package/dist/index.d.ts CHANGED
@@ -1,12 +1,15 @@
1
- import { Extension, ToolDefinition } from '@goondan/openharness-types';
1
+ import { Extension, LlmChatOptions, Message, ToolDefinition } from '@goondan/openharness-types';
2
2
 
3
3
  /**
4
- * ContextMessage extension — prepends a system message to the conversation
4
+ * BasicSystemPrompt extension — prepends a system message to the conversation
5
5
  * at the start of every turn.
6
6
  *
7
+ * Uses a fixed message ID so the system prompt is only appended once;
8
+ * subsequent turns detect the existing message and skip the append.
9
+ *
7
10
  * Priority 10 (HIGH) ensures it runs before other turn middleware.
8
11
  */
9
- declare function ContextMessage(text: string): Extension;
12
+ declare function BasicSystemPrompt(text: string): Extension;
10
13
 
11
14
  /**
12
15
  * MessageWindow extension — truncates conversation history to keep only
@@ -18,13 +21,22 @@ declare function MessageWindow(config: {
18
21
 
19
22
  /**
20
23
  * CompactionSummarize extension — when message count exceeds `threshold`,
21
- * removes the oldest messages and prepends a summary system message.
24
+ * removes the oldest messages and replaces them with an LLM-generated summary.
25
+ *
26
+ * By default, uses the agent's own LLM (`ctx.llm`) to produce the summary.
27
+ * A custom `summarizer` callback can override this for advanced use cases
28
+ * (e.g. using a cheaper model, external API, or deterministic logic).
22
29
  *
23
- * For MVP the "summary" is just the concatenation of removed message text.
30
+ * @param config.threshold - Trigger compaction when messages exceed this count.
31
+ * @param config.summaryPrompt - Custom system prompt for the LLM summarizer.
32
+ * @param config.summarizer - Optional override: produce summary text from messages.
24
33
  */
25
34
  declare function CompactionSummarize(config: {
26
35
  threshold: number;
27
36
  summaryPrompt?: string;
37
+ /** LLM options for the summarization call (e.g. model override for cheaper summarization). */
38
+ llmOptions?: LlmChatOptions;
39
+ summarizer?: (messages: Message[]) => Promise<string>;
28
40
  }): Extension;
29
41
 
30
42
  /**
@@ -69,4 +81,4 @@ interface WaitToolConfig {
69
81
  }
70
82
  declare function WaitTool(config?: WaitToolConfig): ToolDefinition;
71
83
 
72
- export { BashTool, type BashToolConfig, CompactionSummarize, ContextMessage, FileListTool, FileReadTool, FileWriteTool, HttpFetchTool, JsonQueryTool, Logging, MessageWindow, RequiredToolsGuard, TextTransformTool, ToolSearch, WaitTool, type WaitToolConfig };
84
+ export { BashTool, type BashToolConfig, BasicSystemPrompt, CompactionSummarize, FileListTool, FileReadTool, FileWriteTool, HttpFetchTool, JsonQueryTool, Logging, MessageWindow, RequiredToolsGuard, TextTransformTool, ToolSearch, WaitTool, type WaitToolConfig };
package/dist/index.js CHANGED
@@ -1,25 +1,30 @@
1
- // src/extensions/context-message.ts
2
- import { randomUUID } from "crypto";
3
- function ContextMessage(text) {
1
+ // src/extensions/basic-system-prompt.ts
2
+ var SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
3
+ function BasicSystemPrompt(text) {
4
4
  return {
5
- name: "context-message",
5
+ name: "basic-system-prompt",
6
6
  register(api) {
7
7
  api.pipeline.register(
8
8
  "turn",
9
9
  async (ctx, next) => {
10
- ctx.conversation.emit({
11
- type: "append",
12
- message: {
13
- id: `ctx-msg-${randomUUID()}`,
14
- data: {
15
- role: "system",
16
- content: text
17
- },
18
- metadata: {
19
- __createdBy: "context-message"
10
+ const alreadyExists = ctx.conversation.messages.some(
11
+ (m) => m.id === SYSTEM_MESSAGE_ID
12
+ );
13
+ if (!alreadyExists) {
14
+ ctx.conversation.emit({
15
+ type: "append",
16
+ message: {
17
+ id: SYSTEM_MESSAGE_ID,
18
+ data: {
19
+ role: "system",
20
+ content: text
21
+ },
22
+ metadata: {
23
+ __createdBy: "basic-system-prompt"
24
+ }
20
25
  }
21
- }
22
- });
26
+ });
27
+ }
23
28
  return next();
24
29
  },
25
30
  { priority: 10 }
@@ -47,7 +52,13 @@ function MessageWindow(config) {
47
52
  }
48
53
 
49
54
  // src/extensions/compaction-summarize.ts
50
- import { randomUUID as randomUUID2 } from "crypto";
55
+ import { randomUUID } from "crypto";
56
+ var DEFAULT_SUMMARY_PROMPT = "You are a conversation compactor. Summarize the following messages into a concise summary that preserves all important context, decisions, facts, and action items. Be thorough but brief. Output only the summary text, nothing else.";
57
+ function messageToText(m) {
58
+ const role = m.data.role;
59
+ const content = typeof m.data.content === "string" ? m.data.content : JSON.stringify(m.data.content);
60
+ return `[${role}]: ${content}`;
61
+ }
51
62
  function CompactionSummarize(config) {
52
63
  return {
53
64
  name: "compaction-summarize",
@@ -58,16 +69,36 @@ function CompactionSummarize(config) {
58
69
  const keepCount = Math.floor(config.threshold / 2);
59
70
  const removeCount = messages.length - keepCount;
60
71
  const toRemove = messages.slice(0, removeCount);
61
- const summaryText = toRemove.map(
62
- (m) => typeof m.data.content === "string" ? m.data.content : JSON.stringify(m.data.content)
63
- ).join(" ");
64
- for (const msg of toRemove) {
72
+ let summaryText;
73
+ if (config.summarizer) {
74
+ summaryText = await config.summarizer([...toRemove]);
75
+ } else {
76
+ const transcript = toRemove.map(messageToText).join("\n");
77
+ const prompt = config.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
78
+ const llmResponse = await ctx.llm.chat(
79
+ [
80
+ { id: `compaction-sys-${randomUUID()}`, data: { role: "system", content: prompt }, metadata: {} },
81
+ { id: `compaction-usr-${randomUUID()}`, data: { role: "user", content: transcript }, metadata: {} }
82
+ ],
83
+ [],
84
+ // no tools needed for summarization
85
+ ctx.abortSignal,
86
+ config.llmOptions
87
+ );
88
+ summaryText = llmResponse.text ?? transcript;
89
+ }
90
+ const [firstToRemove, ...restToRemove] = toRemove;
91
+ ctx.conversation.emit({
92
+ type: "remove",
93
+ messageId: firstToRemove.id
94
+ });
95
+ for (const msg of restToRemove) {
65
96
  ctx.conversation.emit({ type: "remove", messageId: msg.id });
66
97
  }
67
98
  ctx.conversation.emit({
68
99
  type: "append",
69
100
  message: {
70
- id: `summary-${randomUUID2()}`,
101
+ id: `summary-${randomUUID()}`,
71
102
  data: {
72
103
  role: "system",
73
104
  content: `[Summary of earlier conversation]: ${summaryText}`
@@ -490,8 +521,8 @@ function WaitTool(config = {}) {
490
521
  }
491
522
  export {
492
523
  BashTool,
524
+ BasicSystemPrompt,
493
525
  CompactionSummarize,
494
- ContextMessage,
495
526
  FileListTool,
496
527
  FileReadTool,
497
528
  FileWriteTool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goondan/openharness-base",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -9,7 +9,7 @@
9
9
  }
10
10
  },
11
11
  "dependencies": {
12
- "@goondan/openharness-types": "0.1.2"
12
+ "@goondan/openharness-types": "0.1.4"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^25.5.0",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { ContextMessage } from "../extensions/context-message.js";
2
+ import { BasicSystemPrompt } from "../extensions/basic-system-prompt.js";
3
3
  import type {
4
4
  ExtensionApi,
5
5
  TurnMiddleware,
@@ -82,6 +82,7 @@ function makeTurnContext(conversation: ConversationState): TurnContext {
82
82
  receivedAt: new Date().toISOString(),
83
83
  },
84
84
  },
85
+ llm: { chat: vi.fn().mockResolvedValue({ text: "mock" }) },
85
86
  };
86
87
  }
87
88
 
@@ -97,19 +98,17 @@ const stubTurnResult: TurnResult = {
97
98
  // Tests
98
99
  // ---------------------------------------------------------------------------
99
100
 
100
- describe("ContextMessage", () => {
101
- // Test 1: creates Extension with correct name
102
- it("creates an Extension with name 'context-message'", () => {
103
- const ext = ContextMessage("You are a helpful assistant.");
104
- expect(ext.name).toBe("context-message");
101
+ describe("BasicSystemPrompt", () => {
102
+ it("creates an Extension with name 'basic-system-prompt'", () => {
103
+ const ext = BasicSystemPrompt("You are helpful.");
104
+ expect(ext.name).toBe("basic-system-prompt");
105
105
  });
106
106
 
107
- // Test 2: register() adds turn middleware via api.pipeline.register
108
107
  it("register() calls api.pipeline.register with level 'turn'", () => {
109
108
  const conversation = makeMockConversationState();
110
109
  const { api, registeredMiddleware } = makeMockApi(conversation);
111
110
 
112
- const ext = ContextMessage("Hello");
111
+ const ext = BasicSystemPrompt("You are helpful.");
113
112
  ext.register(api);
114
113
 
115
114
  expect(api.pipeline.register).toHaveBeenCalledOnce();
@@ -118,86 +117,70 @@ describe("ContextMessage", () => {
118
117
  expect(typeof registeredMiddleware[0].handler).toBe("function");
119
118
  });
120
119
 
121
- // Test 3: middleware is registered with HIGH priority (low number)
122
120
  it("registers turn middleware with priority 10 (high priority)", () => {
123
121
  const conversation = makeMockConversationState();
124
122
  const { api, registeredMiddleware } = makeMockApi(conversation);
125
123
 
126
- const ext = ContextMessage("Hello");
124
+ const ext = BasicSystemPrompt("You are helpful.");
127
125
  ext.register(api);
128
126
 
129
127
  expect(registeredMiddleware[0].options?.priority).toBe(10);
130
128
  });
131
129
 
132
- // Test 4: middleware prepends system message to conversation and text matches
133
- it("middleware emits append event with system message matching the text argument", async () => {
130
+ it("middleware appends a system message and calls next()", async () => {
134
131
  const conversation = makeMockConversationState();
135
132
  const { api, registeredMiddleware } = makeMockApi(conversation);
136
- const systemText = "You are a helpful assistant.";
137
133
 
138
- const ext = ContextMessage(systemText);
134
+ const ext = BasicSystemPrompt("You are helpful.");
139
135
  ext.register(api);
140
136
 
141
137
  const middleware = registeredMiddleware[0].handler;
142
138
  const ctx = makeTurnContext(conversation);
143
139
  const next = vi.fn(async () => stubTurnResult);
144
140
 
145
- await middleware(ctx, next);
141
+ const result = await middleware(ctx, next);
146
142
 
143
+ expect(next).toHaveBeenCalledOnce();
144
+ expect(result).toBe(stubTurnResult);
145
+
146
+ // Verify system message was emitted
147
147
  expect(conversation.emit).toHaveBeenCalledOnce();
148
- const emitted = conversation.emitted[0];
149
- expect(emitted.type).toBe("append");
150
- if (emitted.type === "append") {
151
- expect(emitted.message.data.role).toBe("system");
152
- expect(emitted.message.data.content).toBe(systemText);
153
- expect(emitted.message.metadata?.__createdBy).toBe("context-message");
154
- expect(typeof emitted.message.id).toBe("string");
148
+ const emittedEvent = conversation.emitted[0];
149
+ expect(emittedEvent.type).toBe("append");
150
+ if (emittedEvent.type === "append") {
151
+ expect(emittedEvent.message.id).toBe("sys-basic-system-prompt");
152
+ expect(emittedEvent.message.data.role).toBe("system");
153
+ expect(emittedEvent.message.data.content).toBe("You are helpful.");
154
+ expect(emittedEvent.message.metadata?.__createdBy).toBe("basic-system-prompt");
155
155
  }
156
156
  });
157
157
 
158
- // Test 5: middleware calls next() after emitting
159
- it("middleware calls next() after emitting the system message", async () => {
158
+ it("does not duplicate the system message on subsequent turns", async () => {
160
159
  const conversation = makeMockConversationState();
161
160
  const { api, registeredMiddleware } = makeMockApi(conversation);
162
161
 
163
- const ext = ContextMessage("Some context");
162
+ const ext = BasicSystemPrompt("You are helpful.");
164
163
  ext.register(api);
165
164
 
166
165
  const middleware = registeredMiddleware[0].handler;
167
- const ctx = makeTurnContext(conversation);
168
- const next = vi.fn(async () => stubTurnResult);
169
-
170
- const result = await middleware(ctx, next);
171
-
172
- expect(next).toHaveBeenCalledOnce();
173
- expect(result).toBe(stubTurnResult);
174
- });
175
-
176
- // Test 6: multiple ContextMessage extensions → multiple system messages
177
- it("multiple ContextMessage extensions each prepend their own system message", async () => {
178
- const conversation = makeMockConversationState();
179
- const { api: api1, registeredMiddleware: rm1 } = makeMockApi(conversation);
180
- const { api: api2, registeredMiddleware: rm2 } = makeMockApi(conversation);
181
-
182
- const ext1 = ContextMessage("First context");
183
- const ext2 = ContextMessage("Second context");
184
-
185
- ext1.register(api1);
186
- ext2.register(api2);
187
-
188
- const ctx = makeTurnContext(conversation);
189
166
  const next = vi.fn(async () => stubTurnResult);
190
167
 
191
- await rm1[0].handler(ctx, next);
192
- await rm2[0].handler(ctx, next);
168
+ // First turn — should append
169
+ const ctx1 = makeTurnContext(conversation);
170
+ await middleware(ctx1, next);
171
+ expect(conversation.emitted).toHaveLength(1);
193
172
 
194
- expect(conversation.emitted).toHaveLength(2);
173
+ // Second turn — system message already in conversation.messages, should skip
174
+ const ctx2 = makeTurnContext(conversation);
175
+ await middleware(ctx2, next);
176
+ expect(conversation.emitted).toHaveLength(1); // still 1, no new append
195
177
 
196
- const texts = conversation.emitted
197
- .filter((e): e is Extract<typeof e, { type: "append" }> => e.type === "append")
198
- .map((e) => e.message.data.content);
178
+ // Third turn — still no duplication
179
+ const ctx3 = makeTurnContext(conversation);
180
+ await middleware(ctx3, next);
181
+ expect(conversation.emitted).toHaveLength(1);
199
182
 
200
- expect(texts).toContain("First context");
201
- expect(texts).toContain("Second context");
183
+ // next() should have been called every turn
184
+ expect(next).toHaveBeenCalledTimes(3);
202
185
  });
203
186
  });
@@ -83,6 +83,12 @@ function makeMockApi(conversation: ConversationState): {
83
83
  return { api, registeredMiddleware };
84
84
  }
85
85
 
86
+ function makeMockLlmClient() {
87
+ return {
88
+ chat: vi.fn().mockResolvedValue({ text: "LLM-generated summary of the conversation." }),
89
+ };
90
+ }
91
+
86
92
  function makeStepContext(conversation: ConversationState): StepContext {
87
93
  return {
88
94
  turnId: "turn-1",
@@ -101,6 +107,7 @@ function makeStepContext(conversation: ConversationState): StepContext {
101
107
  receivedAt: new Date().toISOString(),
102
108
  },
103
109
  },
110
+ llm: makeMockLlmClient(),
104
111
  };
105
112
  }
106
113
 
@@ -223,12 +230,53 @@ describe("CompactionSummarize", () => {
223
230
 
224
231
  await middleware(ctx, next);
225
232
 
233
+ // Verify LLM was called for summarization
234
+ expect(ctx.llm.chat).toHaveBeenCalledOnce();
235
+
226
236
  const emitted = (conversation as ReturnType<typeof makeMockConversationState>).emitted;
227
237
  const appendEvent = emitted.find((e) => e.type === "append");
228
238
  if (appendEvent && appendEvent.type === "append") {
229
239
  const content = appendEvent.message.data.content as string;
230
- // Should include text from the removed messages
231
- expect(content).toContain("Message 0");
240
+ // Should include the LLM-generated summary
241
+ expect(content).toContain("LLM-generated summary of the conversation.");
232
242
  }
233
243
  });
244
+
245
+ it("uses custom summarizer when provided", async () => {
246
+ const messages = makeMessages(12);
247
+ const conversation = makeMockConversationState(messages);
248
+ const { api, registeredMiddleware } = makeMockApi(conversation);
249
+
250
+ const summarizer = vi.fn(async (msgs: Message[]) => {
251
+ return `Custom summary of ${msgs.length} messages`;
252
+ });
253
+
254
+ const ext = CompactionSummarize({ threshold: 10, summarizer });
255
+ ext.register(api);
256
+
257
+ const middleware = registeredMiddleware[0].handler;
258
+ const ctx = makeStepContext(conversation);
259
+ const next = vi.fn(async () => stubStepResult);
260
+
261
+ await middleware(ctx, next);
262
+
263
+ // summarizer should have been called with the 7 removed messages
264
+ expect(summarizer).toHaveBeenCalledOnce();
265
+ expect(summarizer).toHaveBeenCalledWith(
266
+ expect.arrayContaining([
267
+ expect.objectContaining({ id: "msg-0" }),
268
+ expect.objectContaining({ id: "msg-6" }),
269
+ ]),
270
+ );
271
+ expect(summarizer.mock.calls[0][0]).toHaveLength(7);
272
+
273
+ const emitted = (conversation as ReturnType<typeof makeMockConversationState>).emitted;
274
+ const appendEvent = emitted.find((e) => e.type === "append");
275
+ if (appendEvent && appendEvent.type === "append") {
276
+ const content = appendEvent.message.data.content as string;
277
+ expect(content).toContain("Custom summary of 7 messages");
278
+ }
279
+
280
+ expect(next).toHaveBeenCalledOnce();
281
+ });
234
282
  });
@@ -101,6 +101,7 @@ function makeStepContext(conversation: ConversationState): StepContext {
101
101
  receivedAt: new Date().toISOString(),
102
102
  },
103
103
  },
104
+ llm: { chat: vi.fn().mockResolvedValue({ text: "mock" }) },
104
105
  };
105
106
  }
106
107
 
@@ -86,6 +86,7 @@ function makeTurnContext(conversation: ConversationState): TurnContext {
86
86
  receivedAt: new Date().toISOString(),
87
87
  },
88
88
  },
89
+ llm: { chat: vi.fn().mockResolvedValue({ text: "mock" }) },
89
90
  };
90
91
  }
91
92
 
@@ -0,0 +1,48 @@
1
+ import type { Extension, ExtensionApi } from "@goondan/openharness-types";
2
+
3
+ const SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
4
+
5
+ /**
6
+ * BasicSystemPrompt extension — prepends a system message to the conversation
7
+ * at the start of every turn.
8
+ *
9
+ * Uses a fixed message ID so the system prompt is only appended once;
10
+ * subsequent turns detect the existing message and skip the append.
11
+ *
12
+ * Priority 10 (HIGH) ensures it runs before other turn middleware.
13
+ */
14
+ export function BasicSystemPrompt(text: string): Extension {
15
+ return {
16
+ name: "basic-system-prompt",
17
+
18
+ register(api: ExtensionApi): void {
19
+ api.pipeline.register(
20
+ "turn",
21
+ async (ctx, next) => {
22
+ const alreadyExists = ctx.conversation.messages.some(
23
+ (m) => m.id === SYSTEM_MESSAGE_ID,
24
+ );
25
+
26
+ if (!alreadyExists) {
27
+ ctx.conversation.emit({
28
+ type: "append",
29
+ message: {
30
+ id: SYSTEM_MESSAGE_ID,
31
+ data: {
32
+ role: "system",
33
+ content: text,
34
+ },
35
+ metadata: {
36
+ __createdBy: "basic-system-prompt",
37
+ },
38
+ },
39
+ });
40
+ }
41
+
42
+ return next();
43
+ },
44
+ { priority: 10 },
45
+ );
46
+ },
47
+ };
48
+ }
@@ -1,15 +1,41 @@
1
- import type { Extension, ExtensionApi } from "@goondan/openharness-types";
1
+ import type { Extension, ExtensionApi, Message, LlmChatOptions } from "@goondan/openharness-types";
2
2
  import { randomUUID } from "node:crypto";
3
3
 
4
+ const DEFAULT_SUMMARY_PROMPT =
5
+ "You are a conversation compactor. Summarize the following messages into a concise summary " +
6
+ "that preserves all important context, decisions, facts, and action items. " +
7
+ "Be thorough but brief. Output only the summary text, nothing else.";
8
+
9
+ /**
10
+ * Extract plain-text representation of a Message for summarization.
11
+ */
12
+ function messageToText(m: Message): string {
13
+ const role = m.data.role;
14
+ const content =
15
+ typeof m.data.content === "string"
16
+ ? m.data.content
17
+ : JSON.stringify(m.data.content);
18
+ return `[${role}]: ${content}`;
19
+ }
20
+
4
21
  /**
5
22
  * CompactionSummarize extension — when message count exceeds `threshold`,
6
- * removes the oldest messages and prepends a summary system message.
23
+ * removes the oldest messages and replaces them with an LLM-generated summary.
24
+ *
25
+ * By default, uses the agent's own LLM (`ctx.llm`) to produce the summary.
26
+ * A custom `summarizer` callback can override this for advanced use cases
27
+ * (e.g. using a cheaper model, external API, or deterministic logic).
7
28
  *
8
- * For MVP the "summary" is just the concatenation of removed message text.
29
+ * @param config.threshold - Trigger compaction when messages exceed this count.
30
+ * @param config.summaryPrompt - Custom system prompt for the LLM summarizer.
31
+ * @param config.summarizer - Optional override: produce summary text from messages.
9
32
  */
10
33
  export function CompactionSummarize(config: {
11
34
  threshold: number;
12
35
  summaryPrompt?: string;
36
+ /** LLM options for the summarization call (e.g. model override for cheaper summarization). */
37
+ llmOptions?: LlmChatOptions;
38
+ summarizer?: (messages: Message[]) => Promise<string>;
13
39
  }): Extension {
14
40
  return {
15
41
  name: "compaction-summarize",
@@ -22,21 +48,41 @@ export function CompactionSummarize(config: {
22
48
  const removeCount = messages.length - keepCount;
23
49
  const toRemove = messages.slice(0, removeCount);
24
50
 
25
- // Build a naive summary from removed messages
26
- const summaryText = toRemove
27
- .map((m) =>
28
- typeof m.data.content === "string"
29
- ? m.data.content
30
- : JSON.stringify(m.data.content),
31
- )
32
- .join(" ");
33
-
34
- // Remove old messages
35
- for (const msg of toRemove) {
51
+ let summaryText: string;
52
+
53
+ if (config.summarizer) {
54
+ // User-provided summarizer takes precedence
55
+ summaryText = await config.summarizer([...toRemove]);
56
+ } else {
57
+ // Default: LLM-based summarization via ctx.llm
58
+ const transcript = toRemove.map(messageToText).join("\n");
59
+ const prompt = config.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT;
60
+
61
+ const llmResponse = await ctx.llm.chat(
62
+ [
63
+ { id: `compaction-sys-${randomUUID()}`, data: { role: "system", content: prompt }, metadata: {} },
64
+ { id: `compaction-usr-${randomUUID()}`, data: { role: "user", content: transcript }, metadata: {} },
65
+ ],
66
+ [], // no tools needed for summarization
67
+ ctx.abortSignal,
68
+ config.llmOptions,
69
+ );
70
+
71
+ summaryText = llmResponse.text ?? transcript;
72
+ }
73
+
74
+ // Remove old messages, replace first with summary for stable ordering
75
+ const [firstToRemove, ...restToRemove] = toRemove;
76
+ // Replace the first message with the summary
77
+ ctx.conversation.emit({
78
+ type: "remove",
79
+ messageId: firstToRemove.id,
80
+ });
81
+ for (const msg of restToRemove) {
36
82
  ctx.conversation.emit({ type: "remove", messageId: msg.id });
37
83
  }
38
84
 
39
- // Prepend summary
85
+ // Prepend summary as a system message
40
86
  ctx.conversation.emit({
41
87
  type: "append",
42
88
  message: {
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { ContextMessage } from "./extensions/context-message.js";
1
+ export { BasicSystemPrompt } from "./extensions/basic-system-prompt.js";
2
2
  export { MessageWindow } from "./extensions/message-window.js";
3
3
  export { CompactionSummarize } from "./extensions/compaction-summarize.js";
4
4
  export { Logging } from "./extensions/logging.js";
@@ -1,8 +0,0 @@
1
- import type { Message } from '../types.js';
2
- /**
3
- * Removes messages that would leave invalid tool_result references after trimming.
4
- * Anthropic requires every tool_result block to map to a tool_use/tool-call block
5
- * in the immediately previous assistant message.
6
- */
7
- export declare function normalizeRemovalTargets(messages: Message[], initialRemovedIds: ReadonlySet<string>): Set<string>;
8
- //# sourceMappingURL=message-integrity.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"message-integrity.d.ts","sourceRoot":"","sources":["../../src/extensions/message-integrity.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAiF3C;;;;GAIG;AACH,wBAAgB,uBAAuB,CACrC,QAAQ,EAAE,OAAO,EAAE,EACnB,iBAAiB,EAAE,WAAW,CAAC,MAAM,CAAC,GACrC,GAAG,CAAC,MAAM,CAAC,CAwBb"}
@@ -1,88 +0,0 @@
1
- function readToolCallId(part) {
2
- const value = part.toolCallId;
3
- if (typeof value !== 'string' || value.length === 0) {
4
- return null;
5
- }
6
- return value;
7
- }
8
- function collectToolCallIds(message) {
9
- const ids = new Set();
10
- if (!message) {
11
- return ids;
12
- }
13
- const content = message.data.content;
14
- if (!Array.isArray(content)) {
15
- return ids;
16
- }
17
- for (const part of content) {
18
- if (part.type !== 'tool-call') {
19
- continue;
20
- }
21
- const toolCallId = readToolCallId(part);
22
- if (!toolCallId) {
23
- continue;
24
- }
25
- ids.add(toolCallId);
26
- }
27
- return ids;
28
- }
29
- function collectToolResultIds(message) {
30
- const content = message.data.content;
31
- if (!Array.isArray(content)) {
32
- return [];
33
- }
34
- const ids = [];
35
- for (const part of content) {
36
- if (part.type !== 'tool-result') {
37
- continue;
38
- }
39
- const toolCallId = readToolCallId(part);
40
- if (!toolCallId) {
41
- continue;
42
- }
43
- ids.push(toolCallId);
44
- }
45
- return ids;
46
- }
47
- function hasDanglingToolResult(message, previousMessage) {
48
- const toolResultIds = collectToolResultIds(message);
49
- if (toolResultIds.length === 0) {
50
- return false;
51
- }
52
- const previousToolCallIds = collectToolCallIds(previousMessage);
53
- if (previousToolCallIds.size === 0) {
54
- return true;
55
- }
56
- for (const toolResultId of toolResultIds) {
57
- if (!previousToolCallIds.has(toolResultId)) {
58
- return true;
59
- }
60
- }
61
- return false;
62
- }
63
- /**
64
- * Removes messages that would leave invalid tool_result references after trimming.
65
- * Anthropic requires every tool_result block to map to a tool_use/tool-call block
66
- * in the immediately previous assistant message.
67
- */
68
- export function normalizeRemovalTargets(messages, initialRemovedIds) {
69
- const removedIds = new Set(initialRemovedIds);
70
- let changed = true;
71
- while (changed) {
72
- changed = false;
73
- let previousRemainingMessage;
74
- for (const message of messages) {
75
- if (removedIds.has(message.id)) {
76
- continue;
77
- }
78
- if (hasDanglingToolResult(message, previousRemainingMessage)) {
79
- removedIds.add(message.id);
80
- changed = true;
81
- continue;
82
- }
83
- previousRemainingMessage = message;
84
- }
85
- }
86
- return removedIds;
87
- }
88
- //# sourceMappingURL=message-integrity.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"message-integrity.js","sourceRoot":"","sources":["../../src/extensions/message-integrity.ts"],"names":[],"mappings":"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"}
@@ -1,37 +0,0 @@
1
- import type { Extension, ExtensionApi } from "@goondan/openharness-types";
2
- import { randomUUID } from "node:crypto";
3
-
4
- /**
5
- * ContextMessage extension — prepends a system message to the conversation
6
- * at the start of every turn.
7
- *
8
- * Priority 10 (HIGH) ensures it runs before other turn middleware.
9
- */
10
- export function ContextMessage(text: string): Extension {
11
- return {
12
- name: "context-message",
13
-
14
- register(api: ExtensionApi): void {
15
- api.pipeline.register(
16
- "turn",
17
- async (ctx, next) => {
18
- ctx.conversation.emit({
19
- type: "append",
20
- message: {
21
- id: `ctx-msg-${randomUUID()}`,
22
- data: {
23
- role: "system",
24
- content: text,
25
- },
26
- metadata: {
27
- __createdBy: "context-message",
28
- },
29
- },
30
- });
31
- return next();
32
- },
33
- { priority: 10 },
34
- );
35
- },
36
- };
37
- }