@goondan/openharness-base 0.1.3 → 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,9 +1,12 @@
1
- import { Extension, ToolDefinition } from '@goondan/openharness-types';
1
+ import { Extension, LlmChatOptions, Message, ToolDefinition } from '@goondan/openharness-types';
2
2
 
3
3
  /**
4
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
12
  declare function BasicSystemPrompt(text: string): Extension;
@@ -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
  /**
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/extensions/basic-system-prompt.ts
2
- import { randomUUID } from "crypto";
2
+ var SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
3
3
  function BasicSystemPrompt(text) {
4
4
  return {
5
5
  name: "basic-system-prompt",
@@ -7,19 +7,24 @@ function BasicSystemPrompt(text) {
7
7
  api.pipeline.register(
8
8
  "turn",
9
9
  async (ctx, next) => {
10
- ctx.conversation.emit({
11
- type: "append",
12
- message: {
13
- id: `sys-${randomUUID()}`,
14
- data: {
15
- role: "system",
16
- content: text
17
- },
18
- metadata: {
19
- __createdBy: "basic-system-prompt"
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}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goondan/openharness-base",
3
- "version": "0.1.3",
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.3"
12
+ "@goondan/openharness-types": "0.1.4"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^25.5.0",
@@ -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
 
@@ -147,9 +148,39 @@ describe("BasicSystemPrompt", () => {
147
148
  const emittedEvent = conversation.emitted[0];
148
149
  expect(emittedEvent.type).toBe("append");
149
150
  if (emittedEvent.type === "append") {
151
+ expect(emittedEvent.message.id).toBe("sys-basic-system-prompt");
150
152
  expect(emittedEvent.message.data.role).toBe("system");
151
153
  expect(emittedEvent.message.data.content).toBe("You are helpful.");
152
154
  expect(emittedEvent.message.metadata?.__createdBy).toBe("basic-system-prompt");
153
155
  }
154
156
  });
157
+
158
+ it("does not duplicate the system message on subsequent turns", async () => {
159
+ const conversation = makeMockConversationState();
160
+ const { api, registeredMiddleware } = makeMockApi(conversation);
161
+
162
+ const ext = BasicSystemPrompt("You are helpful.");
163
+ ext.register(api);
164
+
165
+ const middleware = registeredMiddleware[0].handler;
166
+ const next = vi.fn(async () => stubTurnResult);
167
+
168
+ // First turn — should append
169
+ const ctx1 = makeTurnContext(conversation);
170
+ await middleware(ctx1, next);
171
+ expect(conversation.emitted).toHaveLength(1);
172
+
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
177
+
178
+ // Third turn — still no duplication
179
+ const ctx3 = makeTurnContext(conversation);
180
+ await middleware(ctx3, next);
181
+ expect(conversation.emitted).toHaveLength(1);
182
+
183
+ // next() should have been called every turn
184
+ expect(next).toHaveBeenCalledTimes(3);
185
+ });
155
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
 
@@ -1,10 +1,14 @@
1
1
  import type { Extension, ExtensionApi } from "@goondan/openharness-types";
2
- import { randomUUID } from "node:crypto";
2
+
3
+ const SYSTEM_MESSAGE_ID = "sys-basic-system-prompt";
3
4
 
4
5
  /**
5
6
  * BasicSystemPrompt extension — prepends a system message to the conversation
6
7
  * at the start of every turn.
7
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
+ *
8
12
  * Priority 10 (HIGH) ensures it runs before other turn middleware.
9
13
  */
10
14
  export function BasicSystemPrompt(text: string): Extension {
@@ -15,19 +19,26 @@ export function BasicSystemPrompt(text: string): Extension {
15
19
  api.pipeline.register(
16
20
  "turn",
17
21
  async (ctx, next) => {
18
- ctx.conversation.emit({
19
- type: "append",
20
- message: {
21
- id: `sys-${randomUUID()}`,
22
- data: {
23
- role: "system",
24
- content: text,
25
- },
26
- metadata: {
27
- __createdBy: "basic-system-prompt",
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
+ },
28
38
  },
29
- },
30
- });
39
+ });
40
+ }
41
+
31
42
  return next();
32
43
  },
33
44
  { priority: 10 },
@@ -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: {