@pi-unipi/unipi 2.0.5 → 2.0.7
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/package.json +18 -34
- package/packages/ask-user/package.json +57 -0
- package/packages/autocomplete/package.json +44 -0
- package/packages/btw/package.json +49 -0
- package/packages/cocoindex/package.json +42 -0
- package/packages/compactor/package.json +59 -0
- package/packages/core/package.json +34 -0
- package/packages/footer/package.json +52 -0
- package/packages/info-screen/package.json +50 -0
- package/packages/input-shortcuts/package.json +50 -0
- package/packages/kanboard/package.json +54 -0
- package/packages/mcp/package.json +47 -0
- package/packages/memory/package.json +62 -0
- package/packages/milestone/package.json +50 -0
- package/packages/notify/ask-user-prompt-message.ts +75 -0
- package/packages/notify/events.ts +54 -6
- package/packages/notify/package.json +60 -0
- package/packages/notify/src/__tests__/ask-user-prompt-message.test.ts +159 -0
- package/packages/notify/src/__tests__/event-bus.test.ts +118 -0
- package/packages/ralph/SKILL.md +86 -0
- package/packages/ralph/package.json +46 -0
- package/packages/subagents/package.json +32 -0
- package/packages/updater/package.json +53 -0
- package/packages/updater/src/checker.ts +23 -16
- package/packages/updater/src/version.ts +36 -0
- package/packages/utility/package.json +55 -0
- package/packages/web-api/package.json +57 -0
- package/packages/workflow/package.json +44 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-unipi/memory",
|
|
3
|
+
"version": "2.0.7",
|
|
4
|
+
"description": "Persistent cross-session memory with vector search for Pi coding agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Neuron Mr White",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Neuron-Mr-White/unipi.git",
|
|
11
|
+
"directory": "packages/memory"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/Neuron-Mr-White/unipi#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Neuron-Mr-White/unipi/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"pi-coding-agent",
|
|
21
|
+
"unipi",
|
|
22
|
+
"memory",
|
|
23
|
+
"vector-search",
|
|
24
|
+
"sqlite-vec"
|
|
25
|
+
],
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"index.ts"
|
|
29
|
+
],
|
|
30
|
+
"skills": [
|
|
31
|
+
"skills"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"index.ts",
|
|
36
|
+
"storage.ts",
|
|
37
|
+
"search.ts",
|
|
38
|
+
"embedding.ts",
|
|
39
|
+
"settings.ts",
|
|
40
|
+
"tools.ts",
|
|
41
|
+
"commands.ts",
|
|
42
|
+
"tui/**/*",
|
|
43
|
+
"skills/**/*",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"better-sqlite3": "^12.9.0",
|
|
48
|
+
"sqlite-vec": "^0.1.9",
|
|
49
|
+
"js-yaml": "^4.1.0",
|
|
50
|
+
"@pi-unipi/core": "*",
|
|
51
|
+
"@pi-unipi/info-screen": "*"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
55
|
+
"@sinclair/typebox": "*"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
59
|
+
"@types/js-yaml": "^4.0.0",
|
|
60
|
+
"@types/node": "^25.6.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-unipi/milestone",
|
|
3
|
+
"version": "2.0.7",
|
|
4
|
+
"description": "Lifecycle layer for project-level goals — MILESTONES.md tracking, session hooks, auto-sync",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Neuron Mr White",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Neuron-Mr-White/unipi.git",
|
|
11
|
+
"directory": "packages/milestone"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi-extension",
|
|
16
|
+
"pi-coding-agent",
|
|
17
|
+
"unipi",
|
|
18
|
+
"milestone",
|
|
19
|
+
"goals",
|
|
20
|
+
"progress"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
"*.ts",
|
|
24
|
+
"skills/**/*",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"index.ts"
|
|
30
|
+
],
|
|
31
|
+
"skills": [
|
|
32
|
+
"skills"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@pi-unipi/core": "*"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
43
|
+
"@mariozechner/pi-tui": "*",
|
|
44
|
+
"@sinclair/typebox": "*"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^25.6.0",
|
|
48
|
+
"typescript": "^6.0.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Internal helper: build notification message from
|
|
3
|
+
* ask-user prompt event payloads.
|
|
4
|
+
*
|
|
5
|
+
* Supports both UniPi's flat `unipi:ask-user:prompt` payload and the
|
|
6
|
+
* lossless `rpiv:ask-user:prompt` questionnaire projection.
|
|
7
|
+
*
|
|
8
|
+
* @internal — not part of the public API. Shared by the event listener and tests.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface AskUserPromptEventPayload {
|
|
12
|
+
questions: ReadonlyArray<AskUserPromptQuestion>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AskUserPromptQuestion {
|
|
16
|
+
question: string;
|
|
17
|
+
header: string;
|
|
18
|
+
multiSelect: boolean;
|
|
19
|
+
options: ReadonlyArray<AskUserPromptOption>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AskUserPromptOption {
|
|
23
|
+
label: string;
|
|
24
|
+
description: string;
|
|
25
|
+
hasPreview: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return value !== null && typeof value === "object";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nonEmptyString(value: unknown, fallback: string): string {
|
|
33
|
+
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildFlatPromptMessage(payload: Record<string, unknown>): string {
|
|
37
|
+
const question = nonEmptyString(payload.question, "A question");
|
|
38
|
+
const context = nonEmptyString(payload.context, "");
|
|
39
|
+
return context ? `Agent asks: ${question} — ${context}` : `Agent asks: ${question}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build a human-readable notification message from an ask-user prompt payload. */
|
|
43
|
+
export function buildAskUserPromptMessage(payload: unknown): string {
|
|
44
|
+
const p = isRecord(payload) ? payload : {};
|
|
45
|
+
|
|
46
|
+
const questions = Array.isArray(p.questions)
|
|
47
|
+
? p.questions.filter(isRecord)
|
|
48
|
+
: [];
|
|
49
|
+
|
|
50
|
+
if (questions.length === 0 && ("question" in p || "context" in p)) {
|
|
51
|
+
return buildFlatPromptMessage(p);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const firstQ = questions[0];
|
|
55
|
+
|
|
56
|
+
const baseQuestion = firstQ
|
|
57
|
+
? nonEmptyString(firstQ.question, "A question")
|
|
58
|
+
: "A question";
|
|
59
|
+
|
|
60
|
+
const suffix = questions.length > 1 ? ` (+${questions.length - 1} more)` : "";
|
|
61
|
+
|
|
62
|
+
const optionLabels =
|
|
63
|
+
firstQ && Array.isArray(firstQ.options)
|
|
64
|
+
? firstQ.options
|
|
65
|
+
.filter(isRecord)
|
|
66
|
+
.map((o) => nonEmptyString(o.label, ""))
|
|
67
|
+
.filter((label) => label.length > 0)
|
|
68
|
+
: [];
|
|
69
|
+
|
|
70
|
+
const options = optionLabels.join(", ");
|
|
71
|
+
|
|
72
|
+
return options
|
|
73
|
+
? `Agent asks: ${baseQuestion}${suffix} — ${options}`
|
|
74
|
+
: `Agent asks: ${baseQuestion}${suffix}`;
|
|
75
|
+
}
|
|
@@ -13,11 +13,28 @@ import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
|
|
|
13
13
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
14
14
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
15
15
|
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
16
|
+
import { buildAskUserPromptMessage } from "./ask-user-prompt-message.js";
|
|
16
17
|
import { summarizeLastMessage } from "./summarize.js";
|
|
17
18
|
|
|
19
|
+
// Event emitted by @juicesharp/rpiv-ask-user-question before showing its UI.
|
|
20
|
+
// Keep this as a local string until that package publishes an importable
|
|
21
|
+
// `./events` contract in npm.
|
|
22
|
+
const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt" as const;
|
|
23
|
+
|
|
18
24
|
/** Stored session context for modelRegistry access */
|
|
19
25
|
let sessionCtx: ExtensionContext | null = null;
|
|
20
26
|
|
|
27
|
+
/** Unsubscribe functions for pi.events.on() listeners. Cleared before each registration to avoid accumulation across reloads. */
|
|
28
|
+
const unsubs: Array<() => void> = [];
|
|
29
|
+
|
|
30
|
+
/** Unregister all previously registered pi.events.on() listeners. */
|
|
31
|
+
function unregisterAll(): void {
|
|
32
|
+
for (const unsub of unsubs) {
|
|
33
|
+
try { unsub(); } catch { /* ignore */ }
|
|
34
|
+
}
|
|
35
|
+
unsubs.length = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
21
38
|
/** Store session context (called from index.ts on session_start) */
|
|
22
39
|
export function setSessionContext(ctx: ExtensionContext): void {
|
|
23
40
|
sessionCtx = ctx;
|
|
@@ -42,6 +59,12 @@ export const BUILTIN_EVENTS: Record<
|
|
|
42
59
|
ask_user_prompt: { hook: UNIPI_EVENTS.ASK_USER_PROMPT, label: "Question Asked" },
|
|
43
60
|
};
|
|
44
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Pi lifecycle event types (dispatched by ExtensionRunner).
|
|
64
|
+
* These must use pi.on() — not pi.events.on() — to receive events.
|
|
65
|
+
*/
|
|
66
|
+
const LIFECYCLE_EVENTS = new Set(["agent_end", "session_shutdown"]);
|
|
67
|
+
|
|
45
68
|
/**
|
|
46
69
|
* Register event listeners for all enabled notification events.
|
|
47
70
|
* Attaches listeners to pi hooks and routes notifications to platforms.
|
|
@@ -51,6 +74,9 @@ export function registerEventListeners(
|
|
|
51
74
|
config: NotifyConfig,
|
|
52
75
|
cwd: string
|
|
53
76
|
): void {
|
|
77
|
+
// Remove all previously registered EventBus listeners to prevent accumulation
|
|
78
|
+
// across reloads (EventBus persists but module instances are replaced).
|
|
79
|
+
unregisterAll();
|
|
54
80
|
// Register built-in events (except agent_end which has custom logic)
|
|
55
81
|
for (const [eventKey, def] of Object.entries(BUILTIN_EVENTS)) {
|
|
56
82
|
if (eventKey === "agent_end") continue; // handled separately below
|
|
@@ -69,7 +95,29 @@ export function registerEventListeners(
|
|
|
69
95
|
);
|
|
70
96
|
};
|
|
71
97
|
|
|
72
|
-
|
|
98
|
+
// pi lifecycle events (agent_end, session_shutdown) are dispatched via
|
|
99
|
+
// ExtensionRunner — must use pi.on(). These are stored in
|
|
100
|
+
// extension.handlers and automatically replaced on reload, so they
|
|
101
|
+
// do NOT accumulate like EventBus listeners.
|
|
102
|
+
if (LIFECYCLE_EVENTS.has(eventKey)) {
|
|
103
|
+
(pi as any).on(def.hook, handler);
|
|
104
|
+
} else {
|
|
105
|
+
unsubs.push(pi.events.on(def.hook, handler));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Listen for rpiv:ask-user:prompt from @juicesharp/rpiv-ask-user-question
|
|
110
|
+
const askUserConfig = config.events["ask_user_prompt"];
|
|
111
|
+
if (askUserConfig?.enabled) {
|
|
112
|
+
unsubs.push(pi.events.on(ASK_USER_PROMPT_EVENT, (payload: unknown) => {
|
|
113
|
+
const title = `Pi — ${BUILTIN_EVENTS.ask_user_prompt.label}`;
|
|
114
|
+
const message = buildAskUserPromptMessage(payload);
|
|
115
|
+
dispatchNotification(pi, title, message, askUserConfig.platforms, "ask_user_prompt", config, cwd).catch(
|
|
116
|
+
() => {
|
|
117
|
+
// Silently ignore — background notification failure is non-blocking.
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
}));
|
|
73
121
|
}
|
|
74
122
|
|
|
75
123
|
// agent_end — custom handler with session name and recap support
|
|
@@ -130,7 +178,7 @@ export function registerEventListeners(
|
|
|
130
178
|
// For now, modules register their own events through MODULE_READY
|
|
131
179
|
}
|
|
132
180
|
};
|
|
133
|
-
(pi
|
|
181
|
+
unsubs.push(pi.events.on(UNIPI_EVENTS.MODULE_READY, moduleHandler));
|
|
134
182
|
}
|
|
135
183
|
|
|
136
184
|
/** Get all platforms that are currently enabled in config */
|
|
@@ -144,7 +192,9 @@ function getEnabledPlatforms(config: NotifyConfig, ntfyEnabled: boolean): Notify
|
|
|
144
192
|
}
|
|
145
193
|
|
|
146
194
|
/** No-op — cleanup handled by session teardown */
|
|
147
|
-
export function unregisterEventListeners(): void {
|
|
195
|
+
export function unregisterEventListeners(): void {
|
|
196
|
+
unregisterAll();
|
|
197
|
+
}
|
|
148
198
|
|
|
149
199
|
/**
|
|
150
200
|
* Dispatch a notification to the configured platforms.
|
|
@@ -291,9 +341,7 @@ function buildEventMessage(eventKey: string, payload: unknown): string {
|
|
|
291
341
|
case "session_shutdown":
|
|
292
342
|
return "Session ending";
|
|
293
343
|
case "ask_user_prompt":
|
|
294
|
-
return
|
|
295
|
-
? `Agent asks: ${String(p.question || "")} — ${String(p.context)}`
|
|
296
|
-
: `Agent asks: ${String(p.question || "A question")}`;
|
|
344
|
+
return buildAskUserPromptMessage(payload);
|
|
297
345
|
default:
|
|
298
346
|
return p.message ? String(p.message) : "Event occurred";
|
|
299
347
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-unipi/notify",
|
|
3
|
+
"version": "2.0.7",
|
|
4
|
+
"description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Neuron Mr White",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Neuron-Mr-White/unipi.git",
|
|
11
|
+
"directory": "packages/notify"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi-extension",
|
|
16
|
+
"pi-coding-agent",
|
|
17
|
+
"unipi",
|
|
18
|
+
"notify",
|
|
19
|
+
"notifications"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "tsc --noEmit && node --experimental-strip-types --test src/__tests__/*.test.ts",
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"index.ts",
|
|
27
|
+
"tools.ts",
|
|
28
|
+
"commands.ts",
|
|
29
|
+
"settings.ts",
|
|
30
|
+
"events.ts",
|
|
31
|
+
"ask-user-prompt-message.ts",
|
|
32
|
+
"ntfy-config.ts",
|
|
33
|
+
"summarize.ts",
|
|
34
|
+
"types.ts",
|
|
35
|
+
"platforms/*",
|
|
36
|
+
"tui/*",
|
|
37
|
+
"skills/**/*",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"index.ts"
|
|
43
|
+
],
|
|
44
|
+
"skills": [
|
|
45
|
+
"skills"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@pi-unipi/core": "*",
|
|
53
|
+
"node-notifier": "^10.0.1"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
57
|
+
"@mariozechner/pi-tui": "*",
|
|
58
|
+
"@sinclair/typebox": "*"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Notify — buildAskUserPromptMessage
|
|
3
|
+
*
|
|
4
|
+
* Tests the internal helper directly by importing from its module.
|
|
5
|
+
* The test fixtures use `satisfies AskUserPromptEventPayload` to
|
|
6
|
+
* enforce compile-time alignment with the upstream event contract.
|
|
7
|
+
*
|
|
8
|
+
* Run: node --experimental-strip-types --test
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import {
|
|
14
|
+
buildAskUserPromptMessage,
|
|
15
|
+
type AskUserPromptEventPayload,
|
|
16
|
+
} from "../../ask-user-prompt-message.ts";
|
|
17
|
+
|
|
18
|
+
describe("buildAskUserPromptMessage", () => {
|
|
19
|
+
it("standard lossless payload", () => {
|
|
20
|
+
const payload = {
|
|
21
|
+
questions: [
|
|
22
|
+
{
|
|
23
|
+
question: "Which library?",
|
|
24
|
+
header: "Lib",
|
|
25
|
+
multiSelect: false,
|
|
26
|
+
options: [
|
|
27
|
+
{ label: "React", description: "UI library", hasPreview: false },
|
|
28
|
+
{ label: "Vue", description: "Another UI library", hasPreview: false },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
} satisfies AskUserPromptEventPayload;
|
|
33
|
+
|
|
34
|
+
assert.equal(buildAskUserPromptMessage(payload), "Agent asks: Which library? — React, Vue");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("multiple questions appends (+N more) suffix", () => {
|
|
38
|
+
const payload = {
|
|
39
|
+
questions: [
|
|
40
|
+
{
|
|
41
|
+
question: "First question",
|
|
42
|
+
header: "Q1",
|
|
43
|
+
multiSelect: false,
|
|
44
|
+
options: [{ label: "A", description: "Option A", hasPreview: false }],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
question: "Second question",
|
|
48
|
+
header: "Q2",
|
|
49
|
+
multiSelect: false,
|
|
50
|
+
options: [{ label: "B", description: "Option B", hasPreview: false }],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
} satisfies AskUserPromptEventPayload;
|
|
54
|
+
|
|
55
|
+
assert.equal(buildAskUserPromptMessage(payload), "Agent asks: First question (+1 more) — A");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("malformed payload (empty question object) falls back to A question", () => {
|
|
59
|
+
// 故意构造非法 payload,验证运行时降级路径
|
|
60
|
+
const payload = { questions: [{}] } as unknown as AskUserPromptEventPayload;
|
|
61
|
+
|
|
62
|
+
assert.equal(buildAskUserPromptMessage(payload), "Agent asks: A question");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("empty payload falls back to A question", () => {
|
|
66
|
+
assert.equal(buildAskUserPromptMessage({}), "Agent asks: A question");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("null payload falls back to A question", () => {
|
|
70
|
+
assert.equal(buildAskUserPromptMessage(null), "Agent asks: A question");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("missing question string in first question falls back", () => {
|
|
74
|
+
const payload = {
|
|
75
|
+
questions: [
|
|
76
|
+
{
|
|
77
|
+
question: "",
|
|
78
|
+
header: "H",
|
|
79
|
+
multiSelect: false,
|
|
80
|
+
options: [{ label: "Opt", description: "D", hasPreview: false }],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
} satisfies AskUserPromptEventPayload;
|
|
84
|
+
|
|
85
|
+
assert.equal(buildAskUserPromptMessage(payload), "Agent asks: A question — Opt");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("whitespace-only question is treated as missing", () => {
|
|
89
|
+
const payload = {
|
|
90
|
+
questions: [
|
|
91
|
+
{
|
|
92
|
+
question: " ",
|
|
93
|
+
header: "H",
|
|
94
|
+
multiSelect: false,
|
|
95
|
+
options: [{ label: "Opt", description: "D", hasPreview: false }],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
} satisfies AskUserPromptEventPayload;
|
|
99
|
+
|
|
100
|
+
// nonEmptyString uses trim().length check, so whitespace-only falls back
|
|
101
|
+
assert.equal(buildAskUserPromptMessage(payload), "Agent asks: A question — Opt");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("empty option labels do not produce stray commas", () => {
|
|
105
|
+
const payload = {
|
|
106
|
+
questions: [
|
|
107
|
+
{
|
|
108
|
+
question: "Pick one?",
|
|
109
|
+
header: "Pick",
|
|
110
|
+
multiSelect: false,
|
|
111
|
+
options: [
|
|
112
|
+
{ label: "React", description: "UI lib", hasPreview: false },
|
|
113
|
+
{ label: "", description: "Empty label", hasPreview: false },
|
|
114
|
+
{ label: "Vue", description: "Another UI lib", hasPreview: false },
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
} satisfies AskUserPromptEventPayload;
|
|
119
|
+
|
|
120
|
+
assert.equal(buildAskUserPromptMessage(payload), "Agent asks: Pick one? — React, Vue");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("no options produces bare question", () => {
|
|
124
|
+
const payload = {
|
|
125
|
+
questions: [
|
|
126
|
+
{
|
|
127
|
+
question: "Just type?",
|
|
128
|
+
header: "Free",
|
|
129
|
+
multiSelect: false,
|
|
130
|
+
options: [],
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
} satisfies AskUserPromptEventPayload;
|
|
134
|
+
|
|
135
|
+
assert.equal(buildAskUserPromptMessage(payload), "Agent asks: Just type?");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("legacy flat UniPi payload preserves question and context", () => {
|
|
139
|
+
const payload = {
|
|
140
|
+
question: "Proceed with deploy?",
|
|
141
|
+
context: "Production, with smoke tests",
|
|
142
|
+
optionCount: 2,
|
|
143
|
+
allowMultiple: false,
|
|
144
|
+
allowFreeform: true,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
assert.equal(
|
|
148
|
+
buildAskUserPromptMessage(payload),
|
|
149
|
+
"Agent asks: Proceed with deploy? — Production, with smoke tests",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("legacy flat UniPi payload works without context", () => {
|
|
154
|
+
assert.equal(
|
|
155
|
+
buildAskUserPromptMessage({ question: "Proceed?", context: "" }),
|
|
156
|
+
"Agent asks: Proceed?",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Notify — event bus registration
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the notify plugin correctly uses pi.events.on() for custom
|
|
5
|
+
* unipi events and pi.on() for pi lifecycle events — same pattern enforced
|
|
6
|
+
* by subagents/badge-generation.test.ts.
|
|
7
|
+
*
|
|
8
|
+
* BUG 2 (Wrong event bus):
|
|
9
|
+
* Cross-module events emitted via pi.events.emit() must be listened via
|
|
10
|
+
* pi.events.on(), NOT pi.on() (which only dispatches lifecycle events).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const ROOT = join(import.meta.dirname, "../../../../");
|
|
19
|
+
|
|
20
|
+
function readSource(relativePath: string): string {
|
|
21
|
+
const fullPath = join(ROOT, relativePath);
|
|
22
|
+
if (!existsSync(fullPath)) throw new Error(`File not found: ${fullPath}`);
|
|
23
|
+
return readFileSync(fullPath, "utf-8");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── Known lifecycle events (mirrors LIFECYCLE_EVENTS in events.ts) ──
|
|
27
|
+
|
|
28
|
+
const LIFECYCLE_EVENTS = new Set([
|
|
29
|
+
"agent_end",
|
|
30
|
+
"session_shutdown",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// ─── Test: events.ts correctly uses pi.events.on() for custom events ──
|
|
34
|
+
|
|
35
|
+
describe("notify — event bus registration", () => {
|
|
36
|
+
it("events.ts defines LIFECYCLE_EVENTS with correct lifecycle events", () => {
|
|
37
|
+
const src = readSource("packages/notify/events.ts");
|
|
38
|
+
|
|
39
|
+
const lifecycleMatch = src.match(
|
|
40
|
+
/const LIFECYCLE_EVENTS\s*=\s*new Set\((\[.*?\])\)/s,
|
|
41
|
+
);
|
|
42
|
+
assert.ok(lifecycleMatch, "LIFECYCLE_EVENTS should be defined");
|
|
43
|
+
|
|
44
|
+
const parsed = [...lifecycleMatch[1].matchAll(/"([^"]+)"/g)].map(
|
|
45
|
+
(match) => match[1],
|
|
46
|
+
);
|
|
47
|
+
assert.deepStrictEqual(
|
|
48
|
+
parsed.sort(),
|
|
49
|
+
[...LIFECYCLE_EVENTS].sort(),
|
|
50
|
+
"LIFECYCLE_EVENTS should contain exactly agent_end and session_shutdown",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("unipi:* events use pi.events.on(), NOT pi.on()", () => {
|
|
55
|
+
const src = readSource("packages/notify/events.ts");
|
|
56
|
+
|
|
57
|
+
assert.match(
|
|
58
|
+
src,
|
|
59
|
+
/pi\.events\.on\(def\.hook,\s*handler\)/,
|
|
60
|
+
"Custom unipi events should use pi.events.on(def.hook, handler)",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
assert.doesNotMatch(
|
|
64
|
+
src,
|
|
65
|
+
/(?:\(pi\s+as\s+any\)|pi)\.on\s*\(\s*UNIPI_EVENTS\./,
|
|
66
|
+
"Should NOT use pi.on() for custom unipi events",
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("lifecycle events (agent_end, session_shutdown) still use pi.on()", () => {
|
|
71
|
+
const src = readSource("packages/notify/events.ts");
|
|
72
|
+
|
|
73
|
+
assert.ok(
|
|
74
|
+
src.includes("LIFECYCLE_EVENTS.has(eventKey)") &&
|
|
75
|
+
src.includes("(pi as any).on(def.hook, handler)"),
|
|
76
|
+
"Lifecycle events should be routed through pi.on() in the registration loop",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("MODULE_READY listener uses pi.events.on()", () => {
|
|
81
|
+
const src = readSource("packages/notify/events.ts");
|
|
82
|
+
|
|
83
|
+
assert.match(
|
|
84
|
+
src,
|
|
85
|
+
/pi\.events\s*\.\s*on\s*\(\s*UNIPI_EVENTS\.MODULE_READY/,
|
|
86
|
+
"MODULE_READY should use pi.events.on()",
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
assert.doesNotMatch(
|
|
90
|
+
src,
|
|
91
|
+
/(?:\(pi\s+as\s+any\)|pi)\.on\s*\(\s*UNIPI_EVENTS\.MODULE_READY/,
|
|
92
|
+
"MODULE_READY should NOT use pi.on()",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ─── Test: index.ts only uses pi.on() for lifecycle events ──────────
|
|
98
|
+
|
|
99
|
+
describe("notify — index.ts event registration", () => {
|
|
100
|
+
it("all pi.on() calls in index.ts use lifecycle events only", () => {
|
|
101
|
+
const src = readSource("packages/notify/index.ts");
|
|
102
|
+
|
|
103
|
+
const validLifecycleEvents = [
|
|
104
|
+
"resources_discover",
|
|
105
|
+
"session_start",
|
|
106
|
+
"session_shutdown",
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
const piOnPattern = /pi\.on\("([^"]+)"/g;
|
|
110
|
+
let match: RegExpExecArray | null;
|
|
111
|
+
while ((match = piOnPattern.exec(src)) !== null) {
|
|
112
|
+
assert.ok(
|
|
113
|
+
validLifecycleEvents.includes(match[1]),
|
|
114
|
+
`index.ts: pi.on("${match[1]}") should be a lifecycle event`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|