pi-extension-utils 0.1.0 → 0.2.1
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/README.md +15 -0
- package/dist/index.js +2 -0
- package/dist/src/client.d.ts +22 -0
- package/dist/src/client.js +62 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -0
- package/dist/src/reminders/config.d.ts +6 -0
- package/dist/src/reminders/config.js +23 -0
- package/dist/src/reminders/debug.d.ts +37 -0
- package/dist/src/reminders/debug.js +101 -0
- package/dist/src/reminders/index.d.ts +5 -0
- package/dist/src/reminders/index.js +308 -0
- package/dist/src/reminders/manager.d.ts +32 -0
- package/dist/src/reminders/manager.js +200 -0
- package/dist/src/reminders/renderer.d.ts +7 -0
- package/dist/src/reminders/renderer.js +96 -0
- package/dist/src/reminders/types.d.ts +61 -0
- package/dist/src/reminders/types.js +5 -0
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -22,8 +22,23 @@ const client = connect(pi, { ctx, clientId: "my-extension" });
|
|
|
22
22
|
client.widgets.set("belowEditor", "status", (tui, theme) => component, { order: 10 });
|
|
23
23
|
client.widgets.remove("belowEditor", "status");
|
|
24
24
|
|
|
25
|
+
// Run a custom fullscreen UI: widgets are blanked before mount and
|
|
26
|
+
// restored afterwards, even if the component throws.
|
|
27
|
+
const result = await client.ui.fullscreen<string | null>(
|
|
28
|
+
(tui, theme, keybindings, done) => new MyOverlayComponent(tui, theme, done),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// Lower-level lease, for manual control over the blank/restore window.
|
|
25
32
|
const lease = client.fullscreen.acquire();
|
|
26
33
|
lease.release();
|
|
34
|
+
|
|
35
|
+
// Reminders (host absorbed from pi-reminders): fire-and-forget emits;
|
|
36
|
+
// list() resolves { reminders: [], count: 0 } when no host is present.
|
|
37
|
+
client.reminders.upsert({ id: "my-reminder", text: "...", source: "my-extension" });
|
|
38
|
+
client.reminders.remove("my-reminder");
|
|
39
|
+
client.reminders.clearSource("my-extension");
|
|
40
|
+
const { reminders } = await client.reminders.list();
|
|
41
|
+
|
|
27
42
|
client.dispose();
|
|
28
43
|
|
|
29
44
|
const log = createLogger("my-extension");
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { registerReminderHost } from "./src/reminders/index.js";
|
|
1
2
|
import { basePayload, EVENTS, PROTOCOL_VERSION, } from "./src/protocol.js";
|
|
2
3
|
const HOST_CLIENT_ID = "pi-extension-utils-host";
|
|
3
4
|
const HOST_WIDGET_PREFIX = "pi-extension-utils";
|
|
4
5
|
const PLACEMENTS = ["aboveEditor", "belowEditor"];
|
|
5
6
|
export default function (pi) {
|
|
7
|
+
registerReminderHost(pi);
|
|
6
8
|
const widgets = new Map();
|
|
7
9
|
const fullscreenStack = [];
|
|
8
10
|
let seq = 0;
|
package/dist/src/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { WidgetFactory, WidgetPlacement } from "./protocol.ts";
|
|
3
|
+
import { type ReminderAnnounceNowRequest, type ReminderIntent, type ReminderSnapshot } from "./reminders/types.ts";
|
|
3
4
|
export interface UtilsClientOptions {
|
|
4
5
|
ctx: ExtensionContext;
|
|
5
6
|
clientId?: string;
|
|
@@ -10,6 +11,18 @@ export interface WidgetSetOptions {
|
|
|
10
11
|
export interface FullscreenLease {
|
|
11
12
|
release(): void;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Factory signature accepted by `ctx.ui.custom`. Typed structurally so the
|
|
16
|
+
* client does not depend on pi-tui types directly.
|
|
17
|
+
*/
|
|
18
|
+
export type FullscreenComponentFactory<T> = (tui: unknown, theme: unknown, keybindings: unknown, done: (result: T) => void) => unknown;
|
|
19
|
+
export interface RemindersClient {
|
|
20
|
+
upsert(intent: ReminderIntent): void;
|
|
21
|
+
remove(source: string, key: string): void;
|
|
22
|
+
clearSource(source: string): void;
|
|
23
|
+
announceNow(payload?: ReminderAnnounceNowRequest): void;
|
|
24
|
+
list(source?: string): Promise<ReminderSnapshot>;
|
|
25
|
+
}
|
|
13
26
|
export interface UtilsClient {
|
|
14
27
|
readonly clientId: string;
|
|
15
28
|
readonly mode: "fallback" | "coordinated";
|
|
@@ -20,6 +33,15 @@ export interface UtilsClient {
|
|
|
20
33
|
fullscreen: {
|
|
21
34
|
acquire(): FullscreenLease;
|
|
22
35
|
};
|
|
36
|
+
ui: {
|
|
37
|
+
/**
|
|
38
|
+
* Run a full-screen custom UI: acquires a fullscreen lease (blanking
|
|
39
|
+
* coordinated widgets), shows the component via `ctx.ui.custom`, and
|
|
40
|
+
* releases the lease in a finally — even if the component throws.
|
|
41
|
+
*/
|
|
42
|
+
fullscreen<T>(factory: FullscreenComponentFactory<T>): Promise<T>;
|
|
43
|
+
};
|
|
44
|
+
reminders: RemindersClient;
|
|
23
45
|
dispose(): void;
|
|
24
46
|
}
|
|
25
47
|
export declare function connect(pi: ExtensionAPI, opts: UtilsClientOptions): UtilsClient;
|
package/dist/src/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { REMINDER_ANNOUNCE_NOW_EVENT, REMINDER_CLEAR_SOURCE_EVENT, REMINDER_LIST_EVENT, REMINDER_REMOVE_EVENT, REMINDER_UPSERT_EVENT, } from "./reminders/types.js";
|
|
1
2
|
import { basePayload, EVENTS, PROTOCOL_VERSION, } from "./protocol.js";
|
|
2
3
|
let nextClientId = 1;
|
|
3
4
|
let nextLeaseId = 1;
|
|
@@ -103,6 +104,64 @@ export function connect(pi, opts) {
|
|
|
103
104
|
};
|
|
104
105
|
},
|
|
105
106
|
},
|
|
107
|
+
ui: {
|
|
108
|
+
async fullscreen(factory) {
|
|
109
|
+
const ui = opts.ctx.ui;
|
|
110
|
+
if (typeof ui.custom !== "function") {
|
|
111
|
+
throw new Error("pi-extension-utils: ui.fullscreen requires an interactive UI (ctx.ui.custom is unavailable)");
|
|
112
|
+
}
|
|
113
|
+
const lease = client.fullscreen.acquire();
|
|
114
|
+
try {
|
|
115
|
+
return await ui.custom(factory);
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
lease.release();
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
reminders: {
|
|
123
|
+
upsert(intent) {
|
|
124
|
+
if (disposed)
|
|
125
|
+
return;
|
|
126
|
+
pi.events.emit(REMINDER_UPSERT_EVENT, intent);
|
|
127
|
+
},
|
|
128
|
+
remove(source, key) {
|
|
129
|
+
if (disposed)
|
|
130
|
+
return;
|
|
131
|
+
pi.events.emit(REMINDER_REMOVE_EVENT, { source, id: key });
|
|
132
|
+
},
|
|
133
|
+
clearSource(source) {
|
|
134
|
+
if (disposed)
|
|
135
|
+
return;
|
|
136
|
+
pi.events.emit(REMINDER_CLEAR_SOURCE_EVENT, { source });
|
|
137
|
+
},
|
|
138
|
+
announceNow(payload = {}) {
|
|
139
|
+
if (disposed)
|
|
140
|
+
return;
|
|
141
|
+
pi.events.emit(REMINDER_ANNOUNCE_NOW_EVENT, payload);
|
|
142
|
+
},
|
|
143
|
+
list(source) {
|
|
144
|
+
if (disposed)
|
|
145
|
+
return Promise.resolve(emptyReminderSnapshot());
|
|
146
|
+
let settled = false;
|
|
147
|
+
let resolveSnapshot;
|
|
148
|
+
let rejectSnapshot;
|
|
149
|
+
const promise = new Promise((resolve, reject) => {
|
|
150
|
+
resolveSnapshot = (snapshot) => {
|
|
151
|
+
settled = true;
|
|
152
|
+
resolve(snapshot);
|
|
153
|
+
};
|
|
154
|
+
rejectSnapshot = (error) => {
|
|
155
|
+
settled = true;
|
|
156
|
+
reject(error);
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
pi.events.emit(REMINDER_LIST_EVENT, { source, resolve: resolveSnapshot, reject: rejectSnapshot });
|
|
160
|
+
if (!settled)
|
|
161
|
+
resolveSnapshot(emptyReminderSnapshot());
|
|
162
|
+
return promise;
|
|
163
|
+
},
|
|
164
|
+
},
|
|
106
165
|
dispose() {
|
|
107
166
|
if (disposed)
|
|
108
167
|
return;
|
|
@@ -130,3 +189,6 @@ function isReadyPayload(data) {
|
|
|
130
189
|
const payload = data;
|
|
131
190
|
return typeof payload.protocolVersion === "number" && payload.protocolVersion <= PROTOCOL_VERSION && typeof payload.clientId === "string";
|
|
132
191
|
}
|
|
192
|
+
function emptyReminderSnapshot() {
|
|
193
|
+
return { reminders: [], count: 0 };
|
|
194
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { connect, type FullscreenLease, type UtilsClient, type UtilsClientOptions, type WidgetSetOptions } from "./client.ts";
|
|
1
|
+
export { connect, type FullscreenLease, type RemindersClient, type UtilsClient, type UtilsClientOptions, type WidgetSetOptions } from "./client.ts";
|
|
2
2
|
export { createLogger, type Logger, type LoggerOptions } from "./logger.ts";
|
|
3
3
|
export * from "./protocol.ts";
|
|
4
|
+
export * from "./reminders/types.ts";
|
package/dist/src/index.js
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export interface RemindersConfig {
|
|
2
|
+
debugShowAllInTui?: boolean;
|
|
3
|
+
}
|
|
4
|
+
export declare function configPath(cwd: string): string;
|
|
5
|
+
export declare function loadConfig(cwd: string): RemindersConfig;
|
|
6
|
+
export declare function saveConfig(config: RemindersConfig, cwd: string): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
export function configPath(cwd) {
|
|
4
|
+
return join(cwd, ".pi", "reminders", "reminders-config.json");
|
|
5
|
+
}
|
|
6
|
+
export function loadConfig(cwd) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(readFileSync(configPath(cwd), "utf8"));
|
|
9
|
+
if (!parsed || typeof parsed !== "object")
|
|
10
|
+
return {};
|
|
11
|
+
return {
|
|
12
|
+
debugShowAllInTui: typeof parsed.debugShowAllInTui === "boolean" ? parsed.debugShowAllInTui : undefined,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function saveConfig(config, cwd) {
|
|
20
|
+
const filePath = configPath(cwd);
|
|
21
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
22
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2));
|
|
23
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReminderSnapshot } from "./types.ts";
|
|
2
|
+
export interface ReminderCacheDiagnosticsInput {
|
|
3
|
+
payload?: unknown;
|
|
4
|
+
resultPayload?: unknown;
|
|
5
|
+
snapshot: ReminderSnapshot;
|
|
6
|
+
trailer: string | null;
|
|
7
|
+
inserted: boolean;
|
|
8
|
+
skippedReason?: string;
|
|
9
|
+
providerApi?: string;
|
|
10
|
+
strategy?: string;
|
|
11
|
+
adapterName?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface ReminderCacheDiagnostics {
|
|
14
|
+
event: "reminder_cache_diagnostics";
|
|
15
|
+
timestamp: string;
|
|
16
|
+
inserted: boolean;
|
|
17
|
+
skippedReason?: string;
|
|
18
|
+
providerApi?: string;
|
|
19
|
+
strategy?: string;
|
|
20
|
+
adapterName?: string;
|
|
21
|
+
payloadShape: "messages" | "input" | "unsupported";
|
|
22
|
+
messagesBefore: number | null;
|
|
23
|
+
messagesAfter: number | null;
|
|
24
|
+
activeReminderCount: number;
|
|
25
|
+
activeReminderSources: string[];
|
|
26
|
+
activeReminderLabels: string[];
|
|
27
|
+
trailerChars: number;
|
|
28
|
+
trailerHash: string | null;
|
|
29
|
+
stablePrefixHash: string | null;
|
|
30
|
+
cacheControlBeforeCount: number;
|
|
31
|
+
cacheControlBeforePaths: string[];
|
|
32
|
+
cacheControlInOrAfterTrailerCount: number;
|
|
33
|
+
cacheControlInOrAfterTrailerPaths: string[];
|
|
34
|
+
}
|
|
35
|
+
export declare function buildReminderCacheDiagnostics(input: ReminderCacheDiagnosticsInput): ReminderCacheDiagnostics;
|
|
36
|
+
export declare function writeReminderCacheDiagnostics(diagnostics: ReminderCacheDiagnostics): void;
|
|
37
|
+
export declare function writeReminderDiagnostic(record: Record<string, unknown>): void;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { deriveLabel } from "./renderer.js";
|
|
6
|
+
const DEBUG_LOG_PATH = join(homedir(), ".pi", "log", "reminders.jsonl");
|
|
7
|
+
export function buildReminderCacheDiagnostics(input) {
|
|
8
|
+
const before = getProviderMessages(input.payload);
|
|
9
|
+
const after = getProviderMessages(input.resultPayload);
|
|
10
|
+
const messagesBefore = before.messages;
|
|
11
|
+
const messagesAfter = after.messages;
|
|
12
|
+
const trailerMessage = input.inserted && messagesAfter ? messagesAfter[messagesAfter.length - 1] : undefined;
|
|
13
|
+
const trailerPath = after.shape === "input" ? "input[-1]" : "messages[-1]";
|
|
14
|
+
const cacheControlBeforePaths = findCacheControlPaths(messagesBefore ?? input.payload, before.path);
|
|
15
|
+
const cacheControlInOrAfterTrailerPaths = trailerMessage ? findCacheControlPaths(trailerMessage, trailerPath) : [];
|
|
16
|
+
const sources = uniqueSorted(input.snapshot.reminders.map((reminder) => reminder.source));
|
|
17
|
+
const labels = uniqueSorted(input.snapshot.reminders.map((reminder) => deriveLabel(reminder.source, reminder.label)));
|
|
18
|
+
return {
|
|
19
|
+
event: "reminder_cache_diagnostics",
|
|
20
|
+
timestamp: new Date().toISOString(),
|
|
21
|
+
inserted: input.inserted,
|
|
22
|
+
skippedReason: input.skippedReason,
|
|
23
|
+
providerApi: input.providerApi,
|
|
24
|
+
strategy: input.strategy,
|
|
25
|
+
adapterName: input.adapterName,
|
|
26
|
+
payloadShape: before.shape,
|
|
27
|
+
messagesBefore: messagesBefore?.length ?? null,
|
|
28
|
+
messagesAfter: messagesAfter?.length ?? null,
|
|
29
|
+
activeReminderCount: input.snapshot.count,
|
|
30
|
+
activeReminderSources: sources,
|
|
31
|
+
activeReminderLabels: labels,
|
|
32
|
+
trailerChars: input.trailer?.length ?? 0,
|
|
33
|
+
trailerHash: input.trailer ? hashString(input.trailer) : null,
|
|
34
|
+
stablePrefixHash: messagesBefore ? hashJson(messagesBefore) : null,
|
|
35
|
+
cacheControlBeforeCount: cacheControlBeforePaths.length,
|
|
36
|
+
cacheControlBeforePaths,
|
|
37
|
+
cacheControlInOrAfterTrailerCount: cacheControlInOrAfterTrailerPaths.length,
|
|
38
|
+
cacheControlInOrAfterTrailerPaths,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export function writeReminderCacheDiagnostics(diagnostics) {
|
|
42
|
+
writeReminderDiagnostic({ ...diagnostics });
|
|
43
|
+
}
|
|
44
|
+
export function writeReminderDiagnostic(record) {
|
|
45
|
+
try {
|
|
46
|
+
const path = process.env.PI_REMINDERS_DEBUG_LOG || DEBUG_LOG_PATH;
|
|
47
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
48
|
+
appendFileSync(path, `${JSON.stringify({ timestamp: new Date().toISOString(), ...record })}\n`, "utf8");
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Debug logging is best-effort and must not affect provider requests.
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function getProviderMessages(payload) {
|
|
55
|
+
if (!isRecord(payload))
|
|
56
|
+
return { shape: "unsupported", path: "messages", messages: null };
|
|
57
|
+
if (Array.isArray(payload.messages))
|
|
58
|
+
return { shape: "messages", path: "messages", messages: payload.messages };
|
|
59
|
+
if (Array.isArray(payload.input))
|
|
60
|
+
return { shape: "input", path: "input", messages: payload.input };
|
|
61
|
+
return { shape: "unsupported", path: "messages", messages: null };
|
|
62
|
+
}
|
|
63
|
+
function findCacheControlPaths(value, prefix = "messages") {
|
|
64
|
+
const paths = [];
|
|
65
|
+
visit(value, prefix, paths);
|
|
66
|
+
return paths;
|
|
67
|
+
}
|
|
68
|
+
function visit(value, path, paths) {
|
|
69
|
+
if (Array.isArray(value)) {
|
|
70
|
+
value.forEach((item, index) => visit(item, `${path}[${index}]`, paths));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!isRecord(value))
|
|
74
|
+
return;
|
|
75
|
+
for (const [key, child] of Object.entries(value)) {
|
|
76
|
+
const childPath = `${path}.${key}`;
|
|
77
|
+
if (key === "cache_control")
|
|
78
|
+
paths.push(childPath);
|
|
79
|
+
visit(child, childPath, paths);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function hashJson(value) {
|
|
83
|
+
return hashString(stableStringify(value));
|
|
84
|
+
}
|
|
85
|
+
function hashString(value) {
|
|
86
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
|
87
|
+
}
|
|
88
|
+
function stableStringify(value) {
|
|
89
|
+
if (Array.isArray(value))
|
|
90
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
91
|
+
if (isRecord(value)) {
|
|
92
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
93
|
+
}
|
|
94
|
+
return JSON.stringify(value);
|
|
95
|
+
}
|
|
96
|
+
function uniqueSorted(values) {
|
|
97
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
98
|
+
}
|
|
99
|
+
function isRecord(value) {
|
|
100
|
+
return typeof value === "object" && value !== null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { ReminderManager } from "./manager.ts";
|
|
3
|
+
/** Register the pi-reminders extension hooks and event listeners. */
|
|
4
|
+
export declare function registerReminderHost(pi: ExtensionAPI, manager?: ReminderManager): ReminderManager;
|
|
5
|
+
export default function reminderHost(pi: ExtensionAPI): void;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { loadConfig, saveConfig } from "./config.js";
|
|
3
|
+
import { writeReminderDiagnostic } from "./debug.js";
|
|
4
|
+
import { ReminderManager } from "./manager.js";
|
|
5
|
+
import { REMINDER_ANNOUNCE_NOW_EVENT, REMINDER_CLEAR_SOURCE_EVENT, REMINDER_LIST_EVENT, REMINDER_REMOVE_EVENT, REMINDER_UPSERT_EVENT, } from "./types.js";
|
|
6
|
+
/** Register the pi-reminders extension hooks and event listeners. */
|
|
7
|
+
export function registerReminderHost(pi, manager = new ReminderManager()) {
|
|
8
|
+
const eventBus = pi.events;
|
|
9
|
+
const cfg = {};
|
|
10
|
+
const refreshConfig = (cwd) => {
|
|
11
|
+
delete cfg.debugShowAllInTui;
|
|
12
|
+
Object.assign(cfg, loadConfig(cwd));
|
|
13
|
+
};
|
|
14
|
+
const debugShowAllInTui = (ctx) => {
|
|
15
|
+
refreshConfig(resolveCwd(ctx));
|
|
16
|
+
return isDebugModeEnabled(cfg);
|
|
17
|
+
};
|
|
18
|
+
writeReminderDiagnostic({
|
|
19
|
+
event: "reminder_extension_init",
|
|
20
|
+
});
|
|
21
|
+
writeReminderDiagnostic({
|
|
22
|
+
event: "reminder_extension_register_start",
|
|
23
|
+
});
|
|
24
|
+
eventBus.on(REMINDER_UPSERT_EVENT, (payload) => {
|
|
25
|
+
if (isRecord(payload))
|
|
26
|
+
manager.upsert(payload);
|
|
27
|
+
});
|
|
28
|
+
eventBus.on(REMINDER_REMOVE_EVENT, (payload) => {
|
|
29
|
+
if (isRecord(payload))
|
|
30
|
+
manager.remove(payload);
|
|
31
|
+
});
|
|
32
|
+
eventBus.on(REMINDER_CLEAR_SOURCE_EVENT, (payload) => {
|
|
33
|
+
if (isRecord(payload))
|
|
34
|
+
manager.clearSource(payload);
|
|
35
|
+
});
|
|
36
|
+
eventBus.on(REMINDER_ANNOUNCE_NOW_EVENT, (payload) => {
|
|
37
|
+
manager.forceAnnounce(isRecord(payload) ? payload : {});
|
|
38
|
+
});
|
|
39
|
+
eventBus.on(REMINDER_LIST_EVENT, (payload) => {
|
|
40
|
+
if (isRecord(payload) && typeof payload.resolve === "function") {
|
|
41
|
+
handleListRequest(manager, payload);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
if (typeof pi.registerMessageRenderer === "function") {
|
|
45
|
+
pi.registerMessageRenderer("pi-reminders", (message, _options, theme) => {
|
|
46
|
+
const details = isRecord(message.details) ? message.details : undefined;
|
|
47
|
+
const content = typeof details?.displayText === "string"
|
|
48
|
+
? details.displayText
|
|
49
|
+
: stripSystemReminderWrapper(extractTextContent(message.content));
|
|
50
|
+
return new SystemReminderComponent(content, details, theme);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
pi.on("session_start", (_event, ctx) => {
|
|
54
|
+
manager.clearSession();
|
|
55
|
+
refreshConfig(resolveCwd(ctx));
|
|
56
|
+
writeReminderDiagnostic({
|
|
57
|
+
event: "reminder_session_start",
|
|
58
|
+
sessionId: getSessionId(ctx),
|
|
59
|
+
cwd: getCwd(ctx),
|
|
60
|
+
providerApi: getProviderApi(ctx),
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
pi.on("session_shutdown", () => {
|
|
64
|
+
manager.clearSession();
|
|
65
|
+
});
|
|
66
|
+
pi.on("turn_start", () => {
|
|
67
|
+
manager.advanceTurn();
|
|
68
|
+
});
|
|
69
|
+
pi.on("before_agent_start", (_event, ctx) => {
|
|
70
|
+
const announcement = manager.collectDueAnnouncement({ showHiddenInDisplay: debugShowAllInTui(ctx) });
|
|
71
|
+
if (!announcement)
|
|
72
|
+
return;
|
|
73
|
+
const message = buildReminderMessage(announcement, getContextUsage(ctx));
|
|
74
|
+
writeAnnouncementDiagnostic("reminder_history_message", message);
|
|
75
|
+
return { message };
|
|
76
|
+
});
|
|
77
|
+
pi.on("context", (_event, ctx) => {
|
|
78
|
+
const announcement = manager.collectDueAnnouncement({ showHiddenInDisplay: debugShowAllInTui(ctx) });
|
|
79
|
+
if (!announcement)
|
|
80
|
+
return;
|
|
81
|
+
const message = buildReminderMessage(announcement, getContextUsage(ctx));
|
|
82
|
+
writeAnnouncementDiagnostic("reminder_context_message", message);
|
|
83
|
+
pi.sendMessage(message, { deliverAs: "steer" });
|
|
84
|
+
});
|
|
85
|
+
pi.registerCommand("reminders", {
|
|
86
|
+
description: "Open pi-reminders settings",
|
|
87
|
+
handler: async (_args, ctx) => {
|
|
88
|
+
const cwd = resolveCwd(ctx);
|
|
89
|
+
refreshConfig(cwd);
|
|
90
|
+
const current = debugShowAllInTui(ctx) ? "on" : "off";
|
|
91
|
+
const choice = await ctx.ui.select("Reminders debug: show all in TUI", [`${current === "on" ? "[on]" : "on"}`, `${current === "off" ? "[off]" : "off"}`, "← Back"]);
|
|
92
|
+
if (!choice || choice === "← Back")
|
|
93
|
+
return;
|
|
94
|
+
cfg.debugShowAllInTui = choice.includes("on");
|
|
95
|
+
saveConfig(cfg, cwd);
|
|
96
|
+
ctx.ui.notify(`Reminders debug show-all in TUI ${cfg.debugShowAllInTui ? "enabled" : "disabled"}`, "info");
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
pi.registerCommand("remind", {
|
|
100
|
+
description: "Add a debug system reminder",
|
|
101
|
+
handler: async (args, ctx) => {
|
|
102
|
+
const text = args.trim();
|
|
103
|
+
if (!text) {
|
|
104
|
+
ctx.ui.notify("Usage: /remind <text>", "warning");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
manager.upsert({
|
|
108
|
+
source: "pi-reminders",
|
|
109
|
+
id: "debug-command",
|
|
110
|
+
label: "Reminder",
|
|
111
|
+
text,
|
|
112
|
+
priority: 100,
|
|
113
|
+
ttl: "once",
|
|
114
|
+
});
|
|
115
|
+
ctx.ui.notify("Reminder added", "info");
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
writeReminderDiagnostic({
|
|
119
|
+
event: "reminder_extension_registered",
|
|
120
|
+
hasEvents: eventBus !== undefined,
|
|
121
|
+
hasRegisterCommand: typeof pi.registerCommand === "function",
|
|
122
|
+
hasRegisterMessageRenderer: typeof pi.registerMessageRenderer === "function",
|
|
123
|
+
hasOn: typeof pi.on === "function",
|
|
124
|
+
});
|
|
125
|
+
return manager;
|
|
126
|
+
}
|
|
127
|
+
export default function reminderHost(pi) {
|
|
128
|
+
try {
|
|
129
|
+
registerReminderHost(pi);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
writeReminderDiagnostic({
|
|
133
|
+
event: "reminder_extension_register_error",
|
|
134
|
+
errorName: error instanceof Error ? error.name : undefined,
|
|
135
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
136
|
+
errorStack: error instanceof Error ? error.stack : undefined,
|
|
137
|
+
});
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function handleListRequest(manager, request) {
|
|
142
|
+
try {
|
|
143
|
+
request.resolve(manager.snapshot(request.source));
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
request.reject?.(error);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function isDebugModeEnabled(config) {
|
|
150
|
+
const value = process.env.PI_REMINDERS_DEBUG?.trim().toLowerCase();
|
|
151
|
+
if (value)
|
|
152
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
153
|
+
return config.debugShowAllInTui === true;
|
|
154
|
+
}
|
|
155
|
+
function buildReminderMessage(announcement, usage) {
|
|
156
|
+
const sources = [...new Set(announcement.reminders.map((reminder) => reminder.source))].sort();
|
|
157
|
+
const modelBody = includesDcpReminder(announcement.reminders)
|
|
158
|
+
? appendContextUsageLine(stripSystemReminderWrapper(announcement.text), usage)
|
|
159
|
+
: stripSystemReminderWrapper(announcement.text);
|
|
160
|
+
const displayText = announcement.displayText && includesDcpReminder(announcement.displayReminders)
|
|
161
|
+
? appendContextUsageLine(announcement.displayText, usage)
|
|
162
|
+
: announcement.displayText;
|
|
163
|
+
const content = `<system-reminder>\n${modelBody}\n</system-reminder>`;
|
|
164
|
+
return {
|
|
165
|
+
customType: "pi-reminders",
|
|
166
|
+
content,
|
|
167
|
+
display: displayText !== null,
|
|
168
|
+
details: {
|
|
169
|
+
reminderCount: announcement.reminders.length,
|
|
170
|
+
sources,
|
|
171
|
+
displayText: displayText ?? undefined,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function writeAnnouncementDiagnostic(event, message) {
|
|
176
|
+
const details = message.details;
|
|
177
|
+
writeReminderDiagnostic({
|
|
178
|
+
event,
|
|
179
|
+
activeReminderCount: details?.reminderCount,
|
|
180
|
+
sources: details?.sources,
|
|
181
|
+
trailerChars: message.content.length,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function getContextUsage(ctx) {
|
|
185
|
+
if (!isRecord(ctx) || typeof ctx.getContextUsage !== "function")
|
|
186
|
+
return undefined;
|
|
187
|
+
try {
|
|
188
|
+
const usage = ctx.getContextUsage();
|
|
189
|
+
if (!isRecord(usage) || typeof usage.contextWindow !== "number")
|
|
190
|
+
return undefined;
|
|
191
|
+
const tokens = typeof usage.tokens === "number" ? usage.tokens : null;
|
|
192
|
+
return { tokens, contextWindow: usage.contextWindow };
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function includesDcpReminder(reminders) {
|
|
199
|
+
return reminders.some((reminder) => {
|
|
200
|
+
const source = reminder.source.trim().toLowerCase();
|
|
201
|
+
return source === "dcp" || source === "pi-dynamic-context-pruning";
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function appendContextUsageLine(displayText, usage) {
|
|
205
|
+
if (!usage)
|
|
206
|
+
return displayText;
|
|
207
|
+
const contextWindow = formatTokenCount(usage.contextWindow);
|
|
208
|
+
if (usage.tokens === null)
|
|
209
|
+
return `${displayText}\nContext: unknown / ${contextWindow} tokens.`;
|
|
210
|
+
const percent = usage.contextWindow > 0 ? Math.round((usage.tokens / usage.contextWindow) * 100) : 0;
|
|
211
|
+
return `${displayText}\nContext: ${formatTokenCount(usage.tokens)} / ${contextWindow} tokens (${percent}%).`;
|
|
212
|
+
}
|
|
213
|
+
function formatTokenCount(tokens) {
|
|
214
|
+
if (tokens >= 1_000_000)
|
|
215
|
+
return `${trimFixed(tokens / 1_000_000)}M`;
|
|
216
|
+
if (tokens >= 1_000)
|
|
217
|
+
return `${trimFixed(tokens / 1_000)}k`;
|
|
218
|
+
return String(tokens);
|
|
219
|
+
}
|
|
220
|
+
function trimFixed(value) {
|
|
221
|
+
return value.toFixed(value >= 10 ? 0 : 1).replace(/\.0$/, "");
|
|
222
|
+
}
|
|
223
|
+
function getProviderApi(ctx) {
|
|
224
|
+
return typeof ctx?.model?.api === "string" ? ctx.model.api : undefined;
|
|
225
|
+
}
|
|
226
|
+
function getSessionId(ctx) {
|
|
227
|
+
try {
|
|
228
|
+
return ctx?.sessionManager?.getSessionId?.();
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function getCwd(ctx) {
|
|
235
|
+
try {
|
|
236
|
+
return ctx?.sessionManager?.getCwd?.();
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function resolveCwd(ctx) {
|
|
243
|
+
if (isRecord(ctx) && typeof ctx.cwd === "string" && ctx.cwd.trim())
|
|
244
|
+
return ctx.cwd;
|
|
245
|
+
const sessionCwd = getCwd(ctx);
|
|
246
|
+
return sessionCwd ?? process.env.PWD ?? process.cwd();
|
|
247
|
+
}
|
|
248
|
+
function extractTextContent(content) {
|
|
249
|
+
if (typeof content === "string")
|
|
250
|
+
return content;
|
|
251
|
+
if (!Array.isArray(content))
|
|
252
|
+
return "";
|
|
253
|
+
return content
|
|
254
|
+
.filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
|
|
255
|
+
.map((part) => part.text)
|
|
256
|
+
.join("\n");
|
|
257
|
+
}
|
|
258
|
+
function stripSystemReminderWrapper(content) {
|
|
259
|
+
return content
|
|
260
|
+
.replace(/^\s*<system-reminder>\s*/i, "")
|
|
261
|
+
.replace(/\s*<\/system-reminder>\s*$/i, "")
|
|
262
|
+
.trim();
|
|
263
|
+
}
|
|
264
|
+
function padToWidth(line, width) {
|
|
265
|
+
return `${line}${" ".repeat(Math.max(0, width - visibleWidth(line)))}`;
|
|
266
|
+
}
|
|
267
|
+
const SYSTEM_REMINDER_MIN_BODY_WIDTH = 76;
|
|
268
|
+
const SYSTEM_REMINDER_BODY_WIDTH_RATIO = 0.9;
|
|
269
|
+
class SystemReminderComponent {
|
|
270
|
+
content;
|
|
271
|
+
details;
|
|
272
|
+
theme;
|
|
273
|
+
constructor(content, details, theme) {
|
|
274
|
+
this.content = content;
|
|
275
|
+
this.details = details;
|
|
276
|
+
this.theme = theme;
|
|
277
|
+
}
|
|
278
|
+
invalidate() { }
|
|
279
|
+
render(width) {
|
|
280
|
+
if (width < 3)
|
|
281
|
+
return [truncateToWidth("System Reminder", width)];
|
|
282
|
+
const availableBodyWidth = width - 2;
|
|
283
|
+
const preferredBodyWidth = Math.max(SYSTEM_REMINDER_MIN_BODY_WIDTH, Math.floor(availableBodyWidth * SYSTEM_REMINDER_BODY_WIDTH_RATIO));
|
|
284
|
+
const bodyWidth = Math.max(1, Math.min(availableBodyWidth, preferredBodyWidth));
|
|
285
|
+
const title = " System Reminder ";
|
|
286
|
+
const titleText = truncateToWidth(title, bodyWidth, "");
|
|
287
|
+
const border = (value) => this.theme.fg("accent", value);
|
|
288
|
+
const lines = [border(`╭${titleText}${"─".repeat(Math.max(0, bodyWidth - visibleWidth(titleText)))}╮`)];
|
|
289
|
+
const text = new Text(this.content, 0, 0);
|
|
290
|
+
for (const line of text.render(bodyWidth)) {
|
|
291
|
+
const truncated = truncateToWidth(line, bodyWidth, "");
|
|
292
|
+
lines.push(border("│") + padToWidth(truncated, bodyWidth) + border("│"));
|
|
293
|
+
}
|
|
294
|
+
const meta = [
|
|
295
|
+
this.details?.reminderCount !== undefined ? `${this.details.reminderCount} reminder${this.details.reminderCount === 1 ? "" : "s"}` : undefined,
|
|
296
|
+
this.details?.sources?.length ? this.details.sources.join(", ") : undefined,
|
|
297
|
+
].filter(Boolean).join(" · ");
|
|
298
|
+
if (meta) {
|
|
299
|
+
const metaLine = truncateToWidth(this.theme.fg("dim", meta), bodyWidth, "");
|
|
300
|
+
lines.push(border("│") + padToWidth(metaLine, bodyWidth) + border("│"));
|
|
301
|
+
}
|
|
302
|
+
lines.push(border(`╰${"─".repeat(bodyWidth)}╯`));
|
|
303
|
+
return lines;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function isRecord(value) {
|
|
307
|
+
return typeof value === "object" && value !== null;
|
|
308
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ReminderAnnounceNowRequest, ReminderClearSourceRequest, ReminderIntent, ReminderRecord, ReminderRemoveRequest, ReminderSnapshot } from "./types.ts";
|
|
2
|
+
export interface ReminderAnnouncement {
|
|
3
|
+
text: string;
|
|
4
|
+
displayText: string | null;
|
|
5
|
+
reminders: ReminderRecord[];
|
|
6
|
+
displayReminders: ReminderRecord[];
|
|
7
|
+
}
|
|
8
|
+
/** In-memory store for system reminder intents and announcement state. */
|
|
9
|
+
export declare class ReminderManager {
|
|
10
|
+
private readonly reminders;
|
|
11
|
+
private clock;
|
|
12
|
+
private turnIndex;
|
|
13
|
+
constructor(options?: {
|
|
14
|
+
clock?: () => number;
|
|
15
|
+
});
|
|
16
|
+
upsert(intent: ReminderIntent): boolean;
|
|
17
|
+
remove(request: ReminderRemoveRequest): boolean;
|
|
18
|
+
clearSource(request: ReminderClearSourceRequest): number;
|
|
19
|
+
clearSession(): void;
|
|
20
|
+
advanceTurn(): void;
|
|
21
|
+
forceAnnounce(request?: ReminderAnnounceNowRequest): number;
|
|
22
|
+
snapshot(source?: string): ReminderSnapshot;
|
|
23
|
+
render(): string | null;
|
|
24
|
+
collectDueAnnouncement(options?: {
|
|
25
|
+
showHiddenInDisplay?: boolean;
|
|
26
|
+
}): ReminderAnnouncement | null;
|
|
27
|
+
get size(): number;
|
|
28
|
+
private isDue;
|
|
29
|
+
private markAnnounced;
|
|
30
|
+
private sortedRecords;
|
|
31
|
+
private sortedStoredRecords;
|
|
32
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { renderReminderBody, renderReminderTrailer } from "./renderer.js";
|
|
3
|
+
const DEFAULT_TTL = "once";
|
|
4
|
+
/** In-memory store for system reminder intents and announcement state. */
|
|
5
|
+
export class ReminderManager {
|
|
6
|
+
reminders = new Map();
|
|
7
|
+
clock;
|
|
8
|
+
turnIndex = 0;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.clock = options.clock ?? Date.now;
|
|
11
|
+
}
|
|
12
|
+
upsert(intent) {
|
|
13
|
+
const normalized = normalizeIntent(intent);
|
|
14
|
+
if (!normalized)
|
|
15
|
+
return false;
|
|
16
|
+
if (normalized.text.trim().length === 0) {
|
|
17
|
+
return this.remove({ source: normalized.source, id: normalized.id });
|
|
18
|
+
}
|
|
19
|
+
const now = this.clock();
|
|
20
|
+
const key = reminderKey(normalized.source, normalized.id);
|
|
21
|
+
const existing = this.reminders.get(key);
|
|
22
|
+
const textHash = hashReminderText(normalized);
|
|
23
|
+
this.reminders.set(key, {
|
|
24
|
+
...normalized,
|
|
25
|
+
priority: normalized.priority ?? 0,
|
|
26
|
+
display: normalized.display ?? true,
|
|
27
|
+
ttl: normalized.ttl ?? DEFAULT_TTL,
|
|
28
|
+
createdAt: existing?.createdAt ?? now,
|
|
29
|
+
updatedAt: now,
|
|
30
|
+
lastAnnouncedTextHash: existing?.lastAnnouncedTextHash,
|
|
31
|
+
lastAnnouncedTurn: existing?.lastAnnouncedTurn,
|
|
32
|
+
forceAnnounce: existing?.forceAnnounce || existing?.lastAnnouncedTextHash !== textHash,
|
|
33
|
+
});
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
remove(request) {
|
|
37
|
+
const source = request.source.trim();
|
|
38
|
+
const id = request.id.trim();
|
|
39
|
+
if (!source || !id)
|
|
40
|
+
return false;
|
|
41
|
+
return this.reminders.delete(reminderKey(source, id));
|
|
42
|
+
}
|
|
43
|
+
clearSource(request) {
|
|
44
|
+
const source = request.source.trim();
|
|
45
|
+
if (!source)
|
|
46
|
+
return 0;
|
|
47
|
+
let removed = 0;
|
|
48
|
+
for (const [key, reminder] of this.reminders) {
|
|
49
|
+
if (reminder.source === source) {
|
|
50
|
+
this.reminders.delete(key);
|
|
51
|
+
removed++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return removed;
|
|
55
|
+
}
|
|
56
|
+
clearSession() {
|
|
57
|
+
this.reminders.clear();
|
|
58
|
+
this.turnIndex = 0;
|
|
59
|
+
}
|
|
60
|
+
advanceTurn() {
|
|
61
|
+
this.turnIndex++;
|
|
62
|
+
}
|
|
63
|
+
forceAnnounce(request = {}) {
|
|
64
|
+
const source = request.source?.trim();
|
|
65
|
+
const id = request.id?.trim();
|
|
66
|
+
let count = 0;
|
|
67
|
+
for (const reminder of this.reminders.values()) {
|
|
68
|
+
if (source && reminder.source !== source)
|
|
69
|
+
continue;
|
|
70
|
+
if (id && reminder.id !== id)
|
|
71
|
+
continue;
|
|
72
|
+
reminder.forceAnnounce = true;
|
|
73
|
+
count++;
|
|
74
|
+
}
|
|
75
|
+
return count;
|
|
76
|
+
}
|
|
77
|
+
snapshot(source) {
|
|
78
|
+
const filtered = this.sortedRecords().filter((reminder) => source === undefined || reminder.source === source);
|
|
79
|
+
return {
|
|
80
|
+
reminders: filtered.map(cloneRecord),
|
|
81
|
+
count: filtered.length,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
render() {
|
|
85
|
+
return renderReminderTrailer(this.sortedRecords());
|
|
86
|
+
}
|
|
87
|
+
collectDueAnnouncement(options = {}) {
|
|
88
|
+
const due = this.sortedStoredRecords().filter((reminder) => this.isDue(reminder));
|
|
89
|
+
const text = renderReminderTrailer(due);
|
|
90
|
+
if (!text)
|
|
91
|
+
return null;
|
|
92
|
+
const displayReminders = options.showHiddenInDisplay ? due : due.filter((reminder) => reminder.display);
|
|
93
|
+
const displayText = renderReminderBody(displayReminders);
|
|
94
|
+
const announced = due.map(cloneRecord);
|
|
95
|
+
const displayed = displayReminders.map(cloneRecord);
|
|
96
|
+
this.markAnnounced(due);
|
|
97
|
+
return { text, displayText, reminders: announced, displayReminders: displayed };
|
|
98
|
+
}
|
|
99
|
+
get size() {
|
|
100
|
+
return this.reminders.size;
|
|
101
|
+
}
|
|
102
|
+
isDue(reminder) {
|
|
103
|
+
if (reminder.forceAnnounce)
|
|
104
|
+
return true;
|
|
105
|
+
const textHash = hashReminderText(reminder);
|
|
106
|
+
if (reminder.lastAnnouncedTextHash !== textHash)
|
|
107
|
+
return true;
|
|
108
|
+
if (reminder.ttl !== "persistent")
|
|
109
|
+
return false;
|
|
110
|
+
if (!reminder.repeatEveryTurns || reminder.repeatEveryTurns <= 0)
|
|
111
|
+
return false;
|
|
112
|
+
if (reminder.lastAnnouncedTurn === undefined)
|
|
113
|
+
return true;
|
|
114
|
+
return this.turnIndex - reminder.lastAnnouncedTurn >= reminder.repeatEveryTurns;
|
|
115
|
+
}
|
|
116
|
+
markAnnounced(reminders) {
|
|
117
|
+
for (const reminder of reminders) {
|
|
118
|
+
const key = reminderKey(reminder.source, reminder.id);
|
|
119
|
+
const stored = this.reminders.get(key);
|
|
120
|
+
if (!stored)
|
|
121
|
+
continue;
|
|
122
|
+
if (stored.ttl === "once") {
|
|
123
|
+
this.reminders.delete(key);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
stored.lastAnnouncedTextHash = hashReminderText(stored);
|
|
127
|
+
stored.lastAnnouncedTurn = this.turnIndex;
|
|
128
|
+
stored.forceAnnounce = false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
sortedRecords() {
|
|
132
|
+
return this.sortedStoredRecords().map(cloneRecord);
|
|
133
|
+
}
|
|
134
|
+
sortedStoredRecords() {
|
|
135
|
+
return [...this.reminders.values()].sort(compareRecords);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function normalizeIntent(intent) {
|
|
139
|
+
const source = intent.source?.trim();
|
|
140
|
+
const id = intent.id?.trim();
|
|
141
|
+
if (!source || !id)
|
|
142
|
+
return null;
|
|
143
|
+
const priority = intent.priority;
|
|
144
|
+
const repeatEveryTurns = intent.repeatEveryTurns;
|
|
145
|
+
return {
|
|
146
|
+
...intent,
|
|
147
|
+
source,
|
|
148
|
+
id,
|
|
149
|
+
text: String(intent.text ?? ""),
|
|
150
|
+
label: intent.label?.trim(),
|
|
151
|
+
priority: Number.isFinite(priority) ? Math.trunc(priority) : undefined,
|
|
152
|
+
display: intent.display !== false,
|
|
153
|
+
ttl: normalizeTtl(intent.ttl),
|
|
154
|
+
repeatEveryTurns: Number.isFinite(repeatEveryTurns) && repeatEveryTurns > 0
|
|
155
|
+
? Math.trunc(repeatEveryTurns)
|
|
156
|
+
: undefined,
|
|
157
|
+
metadata: cloneMetadata(intent.metadata),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function normalizeTtl(ttl) {
|
|
161
|
+
if (ttl === undefined)
|
|
162
|
+
return undefined;
|
|
163
|
+
return ttl === "once" || ttl === "persistent" || ttl === "session" ? ttl : undefined;
|
|
164
|
+
}
|
|
165
|
+
function reminderKey(source, id) {
|
|
166
|
+
return `${source}\u0000${id}`;
|
|
167
|
+
}
|
|
168
|
+
function compareRecords(a, b) {
|
|
169
|
+
return b.priority - a.priority || a.source.localeCompare(b.source) || a.id.localeCompare(b.id);
|
|
170
|
+
}
|
|
171
|
+
function cloneRecord(record) {
|
|
172
|
+
return {
|
|
173
|
+
id: record.id,
|
|
174
|
+
source: record.source,
|
|
175
|
+
text: record.text,
|
|
176
|
+
label: record.label,
|
|
177
|
+
priority: record.priority,
|
|
178
|
+
display: record.display,
|
|
179
|
+
ttl: record.ttl,
|
|
180
|
+
repeatEveryTurns: record.repeatEveryTurns,
|
|
181
|
+
metadata: cloneMetadata(record.metadata),
|
|
182
|
+
createdAt: record.createdAt,
|
|
183
|
+
updatedAt: record.updatedAt,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function cloneMetadata(metadata) {
|
|
187
|
+
return metadata === undefined ? undefined : { ...metadata };
|
|
188
|
+
}
|
|
189
|
+
function hashReminderText(reminder) {
|
|
190
|
+
return createHash("sha256")
|
|
191
|
+
.update(JSON.stringify({
|
|
192
|
+
id: reminder.id,
|
|
193
|
+
label: reminder.label,
|
|
194
|
+
priority: reminder.priority,
|
|
195
|
+
display: reminder.display,
|
|
196
|
+
source: reminder.source,
|
|
197
|
+
text: reminder.text,
|
|
198
|
+
}))
|
|
199
|
+
.digest("hex");
|
|
200
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RenderReminderInput } from "./types.ts";
|
|
2
|
+
/** Render reminders into one compact model-visible trailer. */
|
|
3
|
+
export declare function renderReminderTrailer(reminders: readonly RenderReminderInput[]): string | null;
|
|
4
|
+
/** Render reminders without wrapper tags for custom chat display. */
|
|
5
|
+
export declare function renderReminderBody(reminders: readonly RenderReminderInput[]): string | null;
|
|
6
|
+
export declare function sanitizeText(text: string): string;
|
|
7
|
+
export declare function deriveLabel(source: string, label?: string): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const SOURCE_LABELS = {
|
|
2
|
+
"pi-dag-tasks": "Tasks",
|
|
3
|
+
tasks: "Tasks",
|
|
4
|
+
dcp: "DCP",
|
|
5
|
+
"pi-dynamic-context-pruning": "DCP",
|
|
6
|
+
"pi-subagents": "Subagents",
|
|
7
|
+
subagents: "Subagents",
|
|
8
|
+
};
|
|
9
|
+
/** Render reminders into one compact model-visible trailer. */
|
|
10
|
+
export function renderReminderTrailer(reminders) {
|
|
11
|
+
const body = renderReminderBody(reminders);
|
|
12
|
+
return body ? `<system-reminder>\n${body}\n</system-reminder>` : null;
|
|
13
|
+
}
|
|
14
|
+
/** Render reminders without wrapper tags for custom chat display. */
|
|
15
|
+
export function renderReminderBody(reminders) {
|
|
16
|
+
const normalized = reminders
|
|
17
|
+
.map((reminder) => ({
|
|
18
|
+
...reminder,
|
|
19
|
+
text: sanitizeText(reminder.text),
|
|
20
|
+
label: deriveLabel(reminder.source, reminder.label),
|
|
21
|
+
}))
|
|
22
|
+
.filter((reminder) => reminder.text.length > 0);
|
|
23
|
+
if (normalized.length === 0)
|
|
24
|
+
return null;
|
|
25
|
+
const groups = buildGroups(normalized);
|
|
26
|
+
return groups.map((group) => renderGroup(group)).join("\n");
|
|
27
|
+
}
|
|
28
|
+
export function sanitizeText(text) {
|
|
29
|
+
return escapeWrapperText(text.trim().replace(/\s+/g, " "));
|
|
30
|
+
}
|
|
31
|
+
export function deriveLabel(source, label) {
|
|
32
|
+
const explicit = sanitizeLabel(label ?? "");
|
|
33
|
+
if (explicit.length > 0)
|
|
34
|
+
return explicit;
|
|
35
|
+
const mapped = SOURCE_LABELS[source.trim().toLowerCase()];
|
|
36
|
+
if (mapped)
|
|
37
|
+
return mapped;
|
|
38
|
+
const fallback = source
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/^pi[-_]/i, "")
|
|
41
|
+
.split(/[-_\s]+/)
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
44
|
+
.join("");
|
|
45
|
+
return sanitizeLabel(fallback || "Reminder");
|
|
46
|
+
}
|
|
47
|
+
function buildGroups(reminders) {
|
|
48
|
+
const byLabel = new Map();
|
|
49
|
+
for (const reminder of reminders) {
|
|
50
|
+
const label = deriveLabel(reminder.source, reminder.label);
|
|
51
|
+
const key = label.toLowerCase();
|
|
52
|
+
const existing = byLabel.get(key);
|
|
53
|
+
if (existing) {
|
|
54
|
+
existing.maxPriority = Math.max(existing.maxPriority, reminder.priority);
|
|
55
|
+
existing.reminders.push(reminder);
|
|
56
|
+
if (reminder.source < existing.source)
|
|
57
|
+
existing.source = reminder.source;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
byLabel.set(key, {
|
|
61
|
+
label,
|
|
62
|
+
source: reminder.source,
|
|
63
|
+
maxPriority: reminder.priority,
|
|
64
|
+
reminders: [reminder],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...byLabel.values()]
|
|
69
|
+
.map((group) => ({
|
|
70
|
+
...group,
|
|
71
|
+
reminders: [...group.reminders].sort(compareReminders),
|
|
72
|
+
}))
|
|
73
|
+
.sort(compareGroups);
|
|
74
|
+
}
|
|
75
|
+
function renderGroup(group) {
|
|
76
|
+
const text = group.reminders.map((reminder) => reminder.text).join("; ");
|
|
77
|
+
return `${group.label}: ${text}`;
|
|
78
|
+
}
|
|
79
|
+
function compareGroups(a, b) {
|
|
80
|
+
return b.maxPriority - a.maxPriority || a.label.localeCompare(b.label) || a.source.localeCompare(b.source);
|
|
81
|
+
}
|
|
82
|
+
function compareReminders(a, b) {
|
|
83
|
+
return b.priority - a.priority || a.id.localeCompare(b.id);
|
|
84
|
+
}
|
|
85
|
+
function sanitizeLabel(label) {
|
|
86
|
+
return label
|
|
87
|
+
.trim()
|
|
88
|
+
.replace(/[^A-Za-z0-9_-]+/g, "")
|
|
89
|
+
.slice(0, 24);
|
|
90
|
+
}
|
|
91
|
+
function escapeWrapperText(text) {
|
|
92
|
+
return text
|
|
93
|
+
.replace(/&/g, "&")
|
|
94
|
+
.replace(/</g, "<")
|
|
95
|
+
.replace(/>/g, ">");
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export declare const REMINDER_UPSERT_EVENT = "reminder:upsert";
|
|
2
|
+
export declare const REMINDER_REMOVE_EVENT = "reminder:remove";
|
|
3
|
+
export declare const REMINDER_CLEAR_SOURCE_EVENT = "reminder:clear-source";
|
|
4
|
+
export declare const REMINDER_LIST_EVENT = "reminder:list";
|
|
5
|
+
export declare const REMINDER_ANNOUNCE_NOW_EVENT = "reminder:announce-now";
|
|
6
|
+
export type ReminderTtl = "once" | "session" | "persistent";
|
|
7
|
+
export interface ReminderIntent {
|
|
8
|
+
/** Stable unique ID within the producer source. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Producer namespace, e.g. "pi-dag-tasks", "dcp", "pi-subagents". */
|
|
11
|
+
source: string;
|
|
12
|
+
/** Compact model-facing reminder text. Source/id are not rendered by default. */
|
|
13
|
+
text: string;
|
|
14
|
+
/** Optional compact display label, e.g. "Tasks", "DCP", "Subagents". */
|
|
15
|
+
label?: string;
|
|
16
|
+
/** Higher values render earlier. Default: 0. */
|
|
17
|
+
priority?: number;
|
|
18
|
+
/** Whether this reminder should be shown in chat. Default: true. */
|
|
19
|
+
display?: boolean;
|
|
20
|
+
/** Lifecycle policy. Default: "once". */
|
|
21
|
+
ttl?: ReminderTtl;
|
|
22
|
+
/** Optional turn interval for repeating persistent reminders. */
|
|
23
|
+
repeatEveryTurns?: number;
|
|
24
|
+
/** Optional structured metadata for debugging; never rendered in the normal reminder. */
|
|
25
|
+
metadata?: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export interface ReminderRemoveRequest {
|
|
28
|
+
source: string;
|
|
29
|
+
id: string;
|
|
30
|
+
}
|
|
31
|
+
export interface ReminderClearSourceRequest {
|
|
32
|
+
source: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ReminderAnnounceNowRequest {
|
|
35
|
+
source?: string;
|
|
36
|
+
id?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ReminderListRequest {
|
|
39
|
+
source?: string;
|
|
40
|
+
resolve: (snapshot: ReminderSnapshot) => void;
|
|
41
|
+
reject?: (error: unknown) => void;
|
|
42
|
+
}
|
|
43
|
+
export interface ReminderRecord extends ReminderIntent {
|
|
44
|
+
priority: number;
|
|
45
|
+
display: boolean;
|
|
46
|
+
ttl: ReminderTtl;
|
|
47
|
+
createdAt: number;
|
|
48
|
+
updatedAt: number;
|
|
49
|
+
}
|
|
50
|
+
export interface ReminderSnapshot {
|
|
51
|
+
reminders: ReminderRecord[];
|
|
52
|
+
count: number;
|
|
53
|
+
}
|
|
54
|
+
export interface RenderReminderInput {
|
|
55
|
+
id: string;
|
|
56
|
+
source: string;
|
|
57
|
+
text: string;
|
|
58
|
+
label?: string;
|
|
59
|
+
priority: number;
|
|
60
|
+
display?: boolean;
|
|
61
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const REMINDER_UPSERT_EVENT = "reminder:upsert";
|
|
2
|
+
export const REMINDER_REMOVE_EVENT = "reminder:remove";
|
|
3
|
+
export const REMINDER_CLEAR_SOURCE_EVENT = "reminder:clear-source";
|
|
4
|
+
export const REMINDER_LIST_EVENT = "reminder:list";
|
|
5
|
+
export const REMINDER_ANNOUNCE_NOW_EVENT = "reminder:announce-now";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-extension-utils",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Shared Pi extension utilities for coordinated widgets, fullscreen leases, and logging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/src/index.js",
|
|
@@ -11,9 +11,15 @@
|
|
|
11
11
|
"default": "./dist/src/index.js"
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
|
-
"files": [
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
15
19
|
"pi": {
|
|
16
|
-
"extensions": [
|
|
20
|
+
"extensions": [
|
|
21
|
+
"./dist/index.js"
|
|
22
|
+
]
|
|
17
23
|
},
|
|
18
24
|
"scripts": {
|
|
19
25
|
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|