@fyeeme/pi-goal 1.0.0
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/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/index.ts +651 -0
- package/package.json +65 -0
- package/src/commands.ts +325 -0
- package/src/evaluator.ts +218 -0
- package/src/format.ts +35 -0
- package/src/prompts/evaluator-complete.md +26 -0
- package/src/prompts/evaluator-impossible.md +20 -0
- package/src/prompts/goal-budget-limit.md +15 -0
- package/src/prompts/goal-continuation.md +30 -0
- package/src/prompts/goal-mode-active.md +23 -0
- package/src/prompts/goal-mode-context.md +4 -0
- package/src/prompts/goal-todo-context.md +12 -0
- package/src/prompts/goal.md +11 -0
- package/src/prompts/guided-goal-interview.md +37 -0
- package/src/restore.ts +92 -0
- package/src/runtime.ts +565 -0
- package/src/state.ts +124 -0
- package/src/template.ts +154 -0
- package/src/todo-bridge.ts +136 -0
- package/src/tool.ts +557 -0
- package/test/evaluator.test.ts +186 -0
- package/test/index.test.ts +557 -0
- package/test/omp-alignment.test.ts +975 -0
- package/test/runtime.test.ts +471 -0
- package/test/template.test.ts +89 -0
- package/test/tool.test.ts +528 -0
package/src/template.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal — minimal template renderer.
|
|
3
|
+
*
|
|
4
|
+
* Replaces oh-my-pi's `prompt.render` (from @oh-my-pi/pi-utils) for the goal
|
|
5
|
+
* prompt templates, which use exactly three constructs:
|
|
6
|
+
*
|
|
7
|
+
* {{variable}} interpolate (values arrive pre-stringified)
|
|
8
|
+
* {{#if var}}A{{else}}B{{/if}} conditional; `else` branch optional
|
|
9
|
+
* {{#each arr}}...{{/each}} iteration; item fields shadow outer scope
|
|
10
|
+
*
|
|
11
|
+
* Unknown variables render as empty strings. This is a renderer, not an
|
|
12
|
+
* escaper: callers escape XML-sensitive text with escapeXmlText before passing
|
|
13
|
+
* values in, matching omp call sites.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type TemplateValue = string | number | boolean | undefined | null | TemplateValue[] | Record<string, unknown>;
|
|
17
|
+
export type TemplateScope = Record<string, TemplateValue>;
|
|
18
|
+
|
|
19
|
+
type Node =
|
|
20
|
+
| { kind: "text"; text: string }
|
|
21
|
+
| { kind: "var"; name: string }
|
|
22
|
+
| { kind: "if"; name: string; whenTrue: Node[]; whenFalse: Node[] }
|
|
23
|
+
| { kind: "each"; name: string; body: Node[] };
|
|
24
|
+
|
|
25
|
+
const TAG = /\{\{([^}]+)\}\}/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse from `start` until the closing tag named `stopName` ("if" / "each" /
|
|
29
|
+
* null for top level). When parsing an if-body, an `{{else}}` ends the
|
|
30
|
+
* then-branch and is reported via `sawElse`; the caller parses the else branch.
|
|
31
|
+
*/
|
|
32
|
+
function parseUntil(
|
|
33
|
+
template: string,
|
|
34
|
+
start: number,
|
|
35
|
+
stopName: "if" | "each" | null,
|
|
36
|
+
): { nodes: Node[]; end: number; sawElse: boolean } {
|
|
37
|
+
const nodes: Node[] = [];
|
|
38
|
+
let cursor = start;
|
|
39
|
+
while (cursor < template.length) {
|
|
40
|
+
const rest = template.slice(cursor);
|
|
41
|
+
const match = TAG.exec(rest);
|
|
42
|
+
if (!match || match.index === undefined) {
|
|
43
|
+
if (stopName !== null) {
|
|
44
|
+
throw new Error(`goal template: unclosed {{#${stopName}}}`);
|
|
45
|
+
}
|
|
46
|
+
nodes.push({ kind: "text", text: rest });
|
|
47
|
+
return { nodes, end: template.length, sawElse: false };
|
|
48
|
+
}
|
|
49
|
+
const tagStart = cursor + match.index;
|
|
50
|
+
if (tagStart > cursor) {
|
|
51
|
+
nodes.push({ kind: "text", text: template.slice(cursor, tagStart) });
|
|
52
|
+
}
|
|
53
|
+
const raw = match[1].trim();
|
|
54
|
+
const afterTag = tagStart + match[0].length;
|
|
55
|
+
|
|
56
|
+
if (raw === "else") {
|
|
57
|
+
if (stopName === "if") {
|
|
58
|
+
return { nodes, end: afterTag, sawElse: true };
|
|
59
|
+
}
|
|
60
|
+
throw new Error("goal template: unexpected {{else}} outside {{#if}}");
|
|
61
|
+
}
|
|
62
|
+
if (raw.startsWith("/")) {
|
|
63
|
+
const closing = raw.slice(1);
|
|
64
|
+
if (stopName !== null && closing === stopName) {
|
|
65
|
+
return { nodes, end: afterTag, sawElse: false };
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`goal template: unexpected closing tag {{${raw}}}`);
|
|
68
|
+
}
|
|
69
|
+
if (raw.startsWith("#if ")) {
|
|
70
|
+
const name = raw.slice(4).trim();
|
|
71
|
+
const thenPart = parseUntil(template, afterTag, "if");
|
|
72
|
+
let otherwise: Node[] = [];
|
|
73
|
+
let end = thenPart.end;
|
|
74
|
+
if (thenPart.sawElse) {
|
|
75
|
+
const elsePart = parseUntil(template, thenPart.end, "if");
|
|
76
|
+
otherwise = elsePart.nodes;
|
|
77
|
+
end = elsePart.end;
|
|
78
|
+
}
|
|
79
|
+
nodes.push({ kind: "if", name, whenTrue: thenPart.nodes, whenFalse: otherwise });
|
|
80
|
+
cursor = end;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (raw.startsWith("#each ")) {
|
|
84
|
+
const name = raw.slice(6).trim();
|
|
85
|
+
const body = parseUntil(template, afterTag, "each");
|
|
86
|
+
nodes.push({ kind: "each", name, body: body.nodes });
|
|
87
|
+
cursor = body.end;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
nodes.push({ kind: "var", name: raw });
|
|
91
|
+
cursor = afterTag;
|
|
92
|
+
}
|
|
93
|
+
if (stopName !== null) {
|
|
94
|
+
throw new Error(`goal template: unclosed {{#${stopName}}}`);
|
|
95
|
+
}
|
|
96
|
+
return { nodes, end: template.length, sawElse: false };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function lookup(scope: TemplateScope, name: string): TemplateValue {
|
|
100
|
+
if (!(name in scope)) return "";
|
|
101
|
+
return scope[name];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isTruthy(value: TemplateValue): boolean {
|
|
105
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
106
|
+
return Boolean(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renderNodes(nodes: Node[], scope: TemplateScope): string {
|
|
110
|
+
let out = "";
|
|
111
|
+
for (const node of nodes) {
|
|
112
|
+
if (node.kind === "text") {
|
|
113
|
+
out += node.text;
|
|
114
|
+
} else if (node.kind === "var") {
|
|
115
|
+
out += String(lookup(scope, node.name) ?? "");
|
|
116
|
+
} else if (node.kind === "if") {
|
|
117
|
+
out += isTruthy(lookup(scope, node.name))
|
|
118
|
+
? renderNodes(node.whenTrue, scope)
|
|
119
|
+
: renderNodes(node.whenFalse, scope);
|
|
120
|
+
} else {
|
|
121
|
+
const value = lookup(scope, node.name);
|
|
122
|
+
if (!Array.isArray(value)) continue;
|
|
123
|
+
for (const item of value) {
|
|
124
|
+
const itemScope: TemplateScope =
|
|
125
|
+
item !== null && typeof item === "object" && !Array.isArray(item)
|
|
126
|
+
? { ...scope, ...(item as TemplateScope) }
|
|
127
|
+
: { ...scope, this: item as TemplateValue };
|
|
128
|
+
out += renderNodes(node.body, itemScope);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const parseCache = new Map<string, Node[]>();
|
|
136
|
+
|
|
137
|
+
/** Render a goal prompt template. Templates are static; parse results are cached. */
|
|
138
|
+
export function renderTemplate(template: string, scope: TemplateScope): string {
|
|
139
|
+
let nodes = parseCache.get(template);
|
|
140
|
+
if (!nodes) {
|
|
141
|
+
nodes = parseUntil(template, 0, null).nodes;
|
|
142
|
+
parseCache.set(template, nodes);
|
|
143
|
+
}
|
|
144
|
+
return renderNodes(nodes, scope);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Escape XML-sensitive characters — omp `escapeXmlText`
|
|
149
|
+
* (packages/utils/src/sanitize-text.ts) escapes ONLY the & < > trio; quotes
|
|
150
|
+
* stay verbatim (element-body semantics, pinned by omp's goal-runtime tests).
|
|
151
|
+
*/
|
|
152
|
+
export function escapeXmlText(text: string): string {
|
|
153
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
154
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-goal — todo bridge.
|
|
3
|
+
*
|
|
4
|
+
* omp rendered a `<todo_context>` block inside the goal-mode context message
|
|
5
|
+
* by reading the host todo state directly (`session.getTodoPhases()`), gated
|
|
6
|
+
* on `todo.enabled` + the todo tool being active. pi-goal is a separate
|
|
7
|
+
* extension, so the state arrives over the documented pi-todo integration
|
|
8
|
+
* contract instead:
|
|
9
|
+
*
|
|
10
|
+
* - `pi.events.on("todo_updated")` keeps the live phases (pi-todo emits
|
|
11
|
+
* after every successful mutation, never on view or failure)
|
|
12
|
+
* - session entries (`todo-phases` full snapshots, pi-todo's documented
|
|
13
|
+
* entry type) restore the phases on session_start
|
|
14
|
+
*
|
|
15
|
+
* The rendered block is byte-identical to omp's goal-todo-context.md output:
|
|
16
|
+
* phase names and task contents are XML-escaped with control characters
|
|
17
|
+
* flattened (omp #sanitizeGoalTodoText).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import * as path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { escapeXmlText, renderTemplate } from "./template.ts";
|
|
24
|
+
|
|
25
|
+
const goalTodoContextPrompt = readFileSync(
|
|
26
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "prompts", "goal-todo-context.md"),
|
|
27
|
+
"utf8",
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** pi-todo session entry customType for phase snapshots. */
|
|
31
|
+
const TODO_PHASES_ENTRY_TYPE = "todo-phases";
|
|
32
|
+
|
|
33
|
+
export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned" | "blocked";
|
|
34
|
+
|
|
35
|
+
export interface TodoItem {
|
|
36
|
+
content: string;
|
|
37
|
+
status: TodoStatus;
|
|
38
|
+
blocker?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TodoPhase {
|
|
42
|
+
name: string;
|
|
43
|
+
tasks: TodoItem[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface EntryLike {
|
|
47
|
+
type?: string;
|
|
48
|
+
customType?: string;
|
|
49
|
+
data?: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isTodoStatus(value: unknown): value is TodoStatus {
|
|
53
|
+
return (
|
|
54
|
+
value === "pending" ||
|
|
55
|
+
value === "in_progress" ||
|
|
56
|
+
value === "completed" ||
|
|
57
|
+
value === "abandoned" ||
|
|
58
|
+
value === "blocked"
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isTodoPhase(value: unknown): value is TodoPhase {
|
|
63
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
64
|
+
const record = value as Record<string, unknown>;
|
|
65
|
+
if (typeof record.name !== "string" || !Array.isArray(record.tasks)) return false;
|
|
66
|
+
return record.tasks.every((task) => {
|
|
67
|
+
if (typeof task !== "object" || task === null) return false;
|
|
68
|
+
const t = task as Record<string, unknown>;
|
|
69
|
+
return typeof t.content === "string" && isTodoStatus(t.status);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Restore todo phases from session entries (pi-todo's `todo-phases` snapshots).
|
|
75
|
+
* Latest valid snapshot wins; malformed entries are skipped.
|
|
76
|
+
*/
|
|
77
|
+
export function restoreTodoPhases(entries: readonly unknown[]): TodoPhase[] {
|
|
78
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
79
|
+
const entry = entries[i] as EntryLike | undefined;
|
|
80
|
+
if (!entry || entry.type !== "custom" || entry.customType !== TODO_PHASES_ENTRY_TYPE) continue;
|
|
81
|
+
const data = entry.data as { phases?: unknown } | undefined;
|
|
82
|
+
if (data && Array.isArray(data.phases) && data.phases.every(isTodoPhase)) {
|
|
83
|
+
return data.phases.map((phase) => ({
|
|
84
|
+
name: phase.name,
|
|
85
|
+
tasks: phase.tasks.map((task) => ({ content: task.content, status: task.status })),
|
|
86
|
+
}));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** omp #sanitizeGoalTodoText: XML-escape and flatten control characters. */
|
|
93
|
+
function sanitizeTodoText(text: string): string {
|
|
94
|
+
return escapeXmlText(text)
|
|
95
|
+
.replace(/\r\n/g, "\\n")
|
|
96
|
+
.replace(/\r/g, "\\r")
|
|
97
|
+
.replace(/\n/g, "\\n")
|
|
98
|
+
.replace(/\t/g, "\\t")
|
|
99
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/g, " ");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Render the `<todo_context>` block for the goal-mode context message.
|
|
104
|
+
* Returns undefined when the todo tool is not active or there are no
|
|
105
|
+
* non-empty phases (omp #buildGoalTodoContext gates).
|
|
106
|
+
*/
|
|
107
|
+
export function buildTodoContext(phases: TodoPhase[], todoToolActive: boolean): string | undefined {
|
|
108
|
+
const canCallTodoTool = todoToolActive;
|
|
109
|
+
if (!canCallTodoTool) return undefined;
|
|
110
|
+
const nonEmpty = phases.filter((phase) => phase.tasks.length > 0);
|
|
111
|
+
if (nonEmpty.length === 0) return undefined;
|
|
112
|
+
|
|
113
|
+
let total = 0;
|
|
114
|
+
let closed = 0;
|
|
115
|
+
let open = 0;
|
|
116
|
+
const promptPhases = nonEmpty.map((phase) => ({
|
|
117
|
+
name: sanitizeTodoText(phase.name),
|
|
118
|
+
tasks: phase.tasks.map((task) => {
|
|
119
|
+
total++;
|
|
120
|
+
if (task.status === "completed" || task.status === "abandoned") {
|
|
121
|
+
closed++;
|
|
122
|
+
} else {
|
|
123
|
+
open++;
|
|
124
|
+
}
|
|
125
|
+
return { content: sanitizeTodoText(task.content), status: task.status };
|
|
126
|
+
}),
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
return renderTemplate(goalTodoContextPrompt, {
|
|
130
|
+
canCallTodoTool,
|
|
131
|
+
closed: String(closed),
|
|
132
|
+
open: String(open),
|
|
133
|
+
phases: promptPhases,
|
|
134
|
+
total: String(total),
|
|
135
|
+
});
|
|
136
|
+
}
|