@quantmind-br/pi-goal-x 0.35.0 → 0.35.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/docs/prompt-caching.md
CHANGED
|
@@ -6,7 +6,7 @@ The request layout is:
|
|
|
6
6
|
|
|
7
7
|
1. Host system prompt and tool schemas.
|
|
8
8
|
2. Conversation history, with each goal checkpoint normalized to a tiny, stable trigger in its original position.
|
|
9
|
-
3. Request-only goal policy
|
|
9
|
+
3. Request-only goal policy, then a task snapshot, then compact counter/context snapshots, replayed at verified history anchors when safe. Current policy includes the objective, lifecycle rules, the task gate, limits, scheduling state and applicable recovery/auditor reminders. The task snapshot carries task progress: the `[TASK LIST]` header with completed/total counts, the current task with its contract, pending tasks, and the count of omitted pending tasks.
|
|
10
10
|
|
|
11
11
|
Changing goal state never rewrites the system prompt or historical messages. The full goal-state message is not persisted into the session. Old checkpoints remain until Pi compacts history; retaining small markers avoids shifting the prefix on every continuation. Display-only auditor messages are consistently excluded, and tool calls stay adjacent to their results.
|
|
12
12
|
|
|
@@ -24,7 +24,7 @@ Validation includes consecutive request-prefix regressions; actual Anthropic, An
|
|
|
24
24
|
|
|
25
25
|
Provider references: [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching) describes exact-prefix matching and explicit breakpoints; [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/) describes its automatic prefix cache and usage counters.
|
|
26
26
|
|
|
27
|
-
Active requests split policy
|
|
27
|
+
Active requests split policy, task snapshots and compact counter/context snapshots into separate tails, always in that order. Already-sent tails are replayed at verified history anchors so implicit-cache requests extend their previous prefix. Each kind is appended only when it differs from its previous copy, so starting, completing or skipping a task, or changing the current task, appends one task snapshot instead of resetting retention (#108 measured four prefix breaks across one three-task run; now only the task setup that enables the completion gate resets). With `blockCompletion` enabled, the policy carries a fixed TASK GATE line that defers to the latest task snapshot, so the pending count never changes policy. Policy changes (including budget/run-limit removal and enabling or disabling the task gate), dropping the task list, rewritten history and unsafe tool-result boundaries reset retention. At most 32 tails, 16 KiB of task snapshots and 4 KiB of counter text are retained per session, across at most 16 session/model identities; exceeding a bound performs one full reset. No full-state tails are persisted. Unchanged requests add no tail. Older task and counter snapshots are historical: the latest snapshot supersedes earlier ones, and current policy takes precedence over cache reuse.
|
|
28
28
|
|
|
29
29
|
Explicit-marker relocation skips all retained live text and tolerates Pi's trailing effort-only messages. Another extension's content-bearing suffix is left alone. Changing models/providers starts a separate retention identity. Unknown session identities do not retain history between requests.
|
|
30
30
|
|
|
@@ -108,8 +108,8 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
108
108
|
if (parts) {
|
|
109
109
|
// Retain previously sent tails verbatim at their anchors so this
|
|
110
110
|
// request extends the previous one instead of displacing its tail.
|
|
111
|
-
// Only genuinely new state
|
|
112
|
-
const { messages: retained } = liveRetention.apply(sessionKey, base, { state: parts.state, counters: parts.counters });
|
|
111
|
+
// Only genuinely new state, task, or counter content is appended.
|
|
112
|
+
const { messages: retained } = liveRetention.apply(sessionKey, base, { state: parts.state, tasks: parts.tasks, counters: parts.counters });
|
|
113
113
|
return retained;
|
|
114
114
|
}
|
|
115
115
|
liveRetention.clear(sessionKey);
|
|
@@ -468,8 +468,8 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
468
468
|
});
|
|
469
469
|
|
|
470
470
|
/** Request-only state: no live counters, focus, or reminders enter the system prefix. */
|
|
471
|
-
/** Request-only goal state split into a rarely-changing policy block and per-turn counters. Other branches are stable-only. */
|
|
472
|
-
function currentGoalContextParts(ctx: ExtensionContext): { state: string; counters?: string } | undefined {
|
|
471
|
+
/** Request-only goal state split into a rarely-changing policy block, a task snapshot, and per-turn counters. Other branches are stable-only. */
|
|
472
|
+
function currentGoalContextParts(ctx: ExtensionContext): { state: string; tasks?: string; counters?: string } | undefined {
|
|
473
473
|
const checkpoint = core.runtime.getCheckpointGoalId();
|
|
474
474
|
if (checkpoint !== null && (core.scheduler.isDenied() || !core.isActionableContinuationGoal(checkpoint))) {
|
|
475
475
|
return { state: staleContinuationPrompt(checkpoint, core.state.goal) };
|
|
@@ -536,7 +536,7 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
536
536
|
}
|
|
537
537
|
const activeGoal = core.state.goal;
|
|
538
538
|
const settings = loadGoalSettings(ctx.cwd);
|
|
539
|
-
const { state: policy, counters } = goalPromptParts(activeGoal, { ...settings, commands: core.loadedCommands }, ctx.getContextUsage?.());
|
|
539
|
+
const { state: policy, tasks, counters } = goalPromptParts(activeGoal, { ...settings, commands: core.loadedCommands }, ctx.getContextUsage?.());
|
|
540
540
|
// Rare transitional steering rides in the stable block; per-turn usage and
|
|
541
541
|
// scheduling counters stay in the volatile block so retention grows slowly.
|
|
542
542
|
let state = policy;
|
|
@@ -568,7 +568,7 @@ export function registerGoalEvents(core: GoalCore): void {
|
|
|
568
568
|
state = `${state}\n\n[POST-COMPACTION RESYNC goalId=${core.state.goal.id}]\nThe conversation was just compacted. Re-read the objective and continue from the actual artifacts/state; do not rely on memory of the prior chat.`;
|
|
569
569
|
}
|
|
570
570
|
}
|
|
571
|
-
return { state, counters };
|
|
571
|
+
return { state, tasks, counters };
|
|
572
572
|
}
|
|
573
573
|
|
|
574
574
|
pi.on("agent_end", async (event, ctx) => {
|
|
@@ -21,11 +21,15 @@ import { asRecord } from "./goal-record.ts";
|
|
|
21
21
|
* edits), an unsafe insertion point, or overflow all perform one documented
|
|
22
22
|
* full reset instead of silently shifting the prefix or corrupting message
|
|
23
23
|
* structure.
|
|
24
|
+
*
|
|
25
|
+
* Task snapshots and usage counters are separate tail kinds. Each is appended
|
|
26
|
+
* only when it differs from its previous emission, so task progress extends
|
|
27
|
+
* the prefix instead of resetting it; only the policy block forces a reset.
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
30
|
export const LIVE_CONTEXT_TYPE = "pi-goal-live-context";
|
|
27
31
|
|
|
28
|
-
export type LiveTailKind = "goal-state" | "goal-counters";
|
|
32
|
+
export type LiveTailKind = "goal-state" | "goal-tasks" | "goal-counters";
|
|
29
33
|
|
|
30
34
|
/** A request-only tail message. Never persisted; the hook replays it per request. */
|
|
31
35
|
export interface LiveMessage {
|
|
@@ -48,19 +52,28 @@ export const MAX_RETAINED_LIVE_TAILS = 32;
|
|
|
48
52
|
export const MAX_TRACKED_SESSIONS = 16;
|
|
49
53
|
/** Extra historical observations, excluding the single current policy block. */
|
|
50
54
|
export const MAX_RETAINED_COUNTER_BYTES = 4096;
|
|
55
|
+
/**
|
|
56
|
+
* Retained task snapshots. Each task transition appends one snapshot, so a
|
|
57
|
+
* long task run stays prefix-stable until this bound forces a single reset.
|
|
58
|
+
*/
|
|
59
|
+
export const MAX_RETAINED_TASK_BYTES = 16_384;
|
|
51
60
|
|
|
52
61
|
export interface FreshLiveTails {
|
|
53
62
|
state: string;
|
|
63
|
+
tasks?: string | undefined;
|
|
54
64
|
counters?: string | undefined;
|
|
55
65
|
}
|
|
56
66
|
|
|
57
|
-
interface
|
|
58
|
-
anchorIndex: number;
|
|
59
|
-
anchorFingerprint: string | null;
|
|
67
|
+
interface LiveBlock {
|
|
60
68
|
content: string;
|
|
61
69
|
kind: LiveTailKind;
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
interface RetainedTail extends LiveBlock {
|
|
73
|
+
anchorIndex: number;
|
|
74
|
+
anchorFingerprint: string | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
interface SessionRetention {
|
|
65
78
|
tails: RetainedTail[];
|
|
66
79
|
lastFresh: FreshLiveTails | undefined;
|
|
@@ -68,8 +81,15 @@ interface SessionRetention {
|
|
|
68
81
|
baseFingerprints: Array<string | null>;
|
|
69
82
|
}
|
|
70
83
|
|
|
71
|
-
function freshBlocks(fresh: FreshLiveTails):
|
|
72
|
-
|
|
84
|
+
function freshBlocks(fresh: FreshLiveTails): LiveBlock[] {
|
|
85
|
+
const blocks: LiveBlock[] = [{ content: fresh.state, kind: "goal-state" }];
|
|
86
|
+
if (fresh.tasks) blocks.push({ content: fresh.tasks, kind: "goal-tasks" });
|
|
87
|
+
if (fresh.counters !== undefined) blocks.push({ content: fresh.counters, kind: "goal-counters" });
|
|
88
|
+
return blocks;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function kindBytes(blocks: readonly LiveBlock[], kind: LiveTailKind): number {
|
|
92
|
+
return blocks.reduce((n, block) => block.kind === kind ? n + Buffer.byteLength(block.content) : n, 0);
|
|
73
93
|
}
|
|
74
94
|
|
|
75
95
|
/** Full-message digest, or null (never a match) when serialization fails. */
|
|
@@ -144,8 +164,10 @@ export class LiveTailRetention {
|
|
|
144
164
|
};
|
|
145
165
|
// A changed state block supersedes every previously sent tail: start
|
|
146
166
|
// fresh rather than leave a stale objective or a cancelled scheduling
|
|
147
|
-
// instruction lingering mid-history.
|
|
167
|
+
// instruction lingering mid-history. A dropped task snapshot or counter
|
|
168
|
+
// block resets too, so its last copy never reads as the current one.
|
|
148
169
|
if (session.lastFresh !== undefined && (session.lastFresh.state !== fresh.state
|
|
170
|
+
|| (session.lastFresh.tasks !== undefined && !fresh.tasks)
|
|
149
171
|
|| (session.lastFresh.counters !== undefined && fresh.counters === undefined))) reset();
|
|
150
172
|
// Tails anchored while history was empty would linger at the head once
|
|
151
173
|
// real history arrives. Reset once so fresh tails anchor at the tail.
|
|
@@ -178,34 +200,32 @@ export class LiveTailRetention {
|
|
|
178
200
|
}
|
|
179
201
|
if (valid < session.tails.length) reset();
|
|
180
202
|
const blocks = freshBlocks(fresh);
|
|
181
|
-
// Append only
|
|
182
|
-
// unchanged content across repeats, tool loops, and history
|
|
183
|
-
// adds zero blocks while keeping the prefix literal.
|
|
203
|
+
// Append only blocks that differ from the last emitted block of the same
|
|
204
|
+
// kind, so unchanged content across repeats, tool loops, and history
|
|
205
|
+
// advances adds zero blocks while keeping the prefix literal. The state
|
|
206
|
+
// block never differs here: a changed state already reset above.
|
|
184
207
|
const previous = session.lastFresh === undefined ? [] : freshBlocks(session.lastFresh);
|
|
185
|
-
let
|
|
186
|
-
while (shared < blocks.length && shared < previous.length && blocks[shared] === previous[shared]) shared++;
|
|
208
|
+
let appended = blocks.filter(block => !previous.some(prior => prior.kind === block.kind && prior.content === block.content));
|
|
187
209
|
// Overflow is measured against what this request actually appends: an
|
|
188
210
|
// unchanged request adds nothing and must never trigger a reset.
|
|
189
|
-
if (session.tails.length +
|
|
190
|
-
|| session.tails
|
|
191
|
-
|
|
211
|
+
if (session.tails.length + appended.length > MAX_RETAINED_LIVE_TAILS
|
|
212
|
+
|| kindBytes(session.tails, "goal-counters") + kindBytes(appended, "goal-counters") > MAX_RETAINED_COUNTER_BYTES
|
|
213
|
+
|| kindBytes(session.tails, "goal-tasks") + kindBytes(appended, "goal-tasks") > MAX_RETAINED_TASK_BYTES) {
|
|
192
214
|
reset();
|
|
193
|
-
|
|
215
|
+
appended = blocks;
|
|
194
216
|
}
|
|
195
217
|
const messages: Array<T | LiveMessage> = [...base];
|
|
196
218
|
for (let i = session.tails.length - 1; i >= 0; i--) {
|
|
197
219
|
const tail = session.tails[i]!;
|
|
198
220
|
messages.splice(tail.anchorIndex, 0, makeLiveMessage(tail.content));
|
|
199
221
|
}
|
|
200
|
-
for (
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
messages.push(makeLiveMessage(content));
|
|
204
|
-
session.tails.push({ anchorIndex: base.length, anchorFingerprint: fingerprints.at(-1) ?? null, content, kind });
|
|
222
|
+
for (const block of appended) {
|
|
223
|
+
messages.push(makeLiveMessage(block.content));
|
|
224
|
+
session.tails.push({ anchorIndex: base.length, anchorFingerprint: fingerprints.at(-1) ?? null, content: block.content, kind: block.kind });
|
|
205
225
|
}
|
|
206
226
|
session.baseFingerprints = fingerprints;
|
|
207
|
-
session.lastFresh = { state: fresh.state, counters: fresh.counters };
|
|
208
|
-
return { messages, transientContents: [...new Set([...session.tails.map(tail => tail.content), ...blocks])] };
|
|
227
|
+
session.lastFresh = { state: fresh.state, tasks: fresh.tasks || undefined, counters: fresh.counters };
|
|
228
|
+
return { messages, transientContents: [...new Set([...session.tails.map(tail => tail.content), ...blocks.map(block => block.content)])] };
|
|
209
229
|
}
|
|
210
230
|
|
|
211
231
|
contents(sessionKey: string): string[] | undefined {
|
|
@@ -100,6 +100,10 @@ function renderPendingTasks(tasks: GoalTask[], indent: number, rendered: { count
|
|
|
100
100
|
* indentation and contract snippets; completed/skipped are collapsed to the
|
|
101
101
|
* header counts. Previously the ENTIRE tree (up to 50 tasks + subtrees) was
|
|
102
102
|
* injected into every continuation prompt — most of it already-completed work.
|
|
103
|
+
*
|
|
104
|
+
* This is the request-only task snapshot: it changes with task progress, so it
|
|
105
|
+
* travels apart from the stable policy block (see goalPromptParts). The
|
|
106
|
+
* completion gate is policy and lives in taskGateBlock.
|
|
103
107
|
*/
|
|
104
108
|
export function taskListBlock(goal: GoalRecord, settings?: GoalSettings): string {
|
|
105
109
|
if (settings?.disableTasks) return "";
|
|
@@ -145,12 +149,19 @@ export function taskListBlock(goal: GoalRecord, settings?: GoalSettings): string
|
|
|
145
149
|
lines.push(` ${hiddenPending} additional pending tasks omitted; retrieve with get_goal(section="tasks").`);
|
|
146
150
|
}
|
|
147
151
|
}
|
|
148
|
-
if (goal.taskList.blockCompletion && pending! > 0) {
|
|
149
|
-
lines.push(" TASK GATE: do not request completion while tasks remain in [ ] pending state");
|
|
150
|
-
}
|
|
151
152
|
return lines.join("\n");
|
|
152
153
|
}
|
|
153
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Stable completion gate for the policy block. It depends only on the
|
|
157
|
+
* blockCompletion setting, never on how many tasks are pending, so task
|
|
158
|
+
* progress cannot change the policy block and reset prompt-cache retention.
|
|
159
|
+
*/
|
|
160
|
+
export function taskGateBlock(goal: GoalRecord, settings?: GoalSettings): string {
|
|
161
|
+
if (settings?.disableTasks || goal.taskList?.blockCompletion !== true) return "";
|
|
162
|
+
return "TASK GATE: do not request completion while the latest [TASK LIST] snapshot shows tasks in [ ] pending state.";
|
|
163
|
+
}
|
|
164
|
+
|
|
154
165
|
/** Bounded verification-contract block. */
|
|
155
166
|
function excerpt(text: string, cap: number, section: "objective" | "tasks"): string {
|
|
156
167
|
const safe = promptSafeObjective(text);
|
|
@@ -207,46 +218,44 @@ const promptFragmentCache: Array<{key: readonly unknown[]; value: string; chars:
|
|
|
207
218
|
let promptCacheChars = 0;
|
|
208
219
|
|
|
209
220
|
function cachedPrompt(goal: GoalRecord, settings: GoalSettings | undefined, kind: "goal" | "continuation", build: () => string): string {
|
|
221
|
+
// Task progress is deliberately absent: it renders in the separate task
|
|
222
|
+
// snapshot, so starting or finishing a task reuses the cached policy block.
|
|
210
223
|
const key = [kind, goal.id, goal.status, goal.autoContinue, goal.sisyphus, goal.objective,
|
|
211
|
-
goal.verificationContract, settings?.disableTasks
|
|
212
|
-
goal.taskList?.blockCompletion, promptProfile(), goal.currentTaskId, settings?.disableTasks, settings?.disableContracts, settings?.maxAutonomousRuns !== 0, settings?.strictExecutionContract === true, !!goal.scheduler?.wait, settings?.commands ? JSON.stringify(settings.commands) : undefined];
|
|
224
|
+
goal.verificationContract, goal.taskList?.blockCompletion, promptProfile(), settings?.disableTasks, settings?.disableContracts, settings?.maxAutonomousRuns !== 0, settings?.strictExecutionContract === true, !!goal.scheduler?.wait, settings?.commands ? JSON.stringify(settings.commands) : undefined];
|
|
213
225
|
for (let i = promptFragmentCache.length - 1; i >= 0; i--) {
|
|
214
226
|
const entry = promptFragmentCache[i]!;
|
|
215
227
|
if (key.every((part, j) => part === entry.key[j])) return entry.value;
|
|
216
228
|
}
|
|
217
229
|
const value = build();
|
|
218
230
|
// Include source text retained by this entry in the cache's memory allowance.
|
|
219
|
-
const chars = goal.objective.length + (goal.verificationContract?.length ?? 0) + value.length
|
|
220
|
-
+ (goal.taskList ? goal.taskList.tasks.reduce((n, task) => n + retainedTaskChars(task), 0) : 0);
|
|
231
|
+
const chars = goal.objective.length + (goal.verificationContract?.length ?? 0) + value.length;
|
|
221
232
|
if (chars <= 2_000_000) {
|
|
222
233
|
while (promptFragmentCache.length >= 32 || promptCacheChars + chars > 2_000_000) promptCacheChars -= promptFragmentCache.shift()!.chars;
|
|
223
234
|
promptFragmentCache.push({key, value, chars}); promptCacheChars += chars;
|
|
224
235
|
}
|
|
225
236
|
return value;
|
|
226
237
|
}
|
|
227
|
-
function retainedTaskChars(task: GoalTask): number {
|
|
228
|
-
return task.id.length + task.title.length + (task.verificationContract?.length ?? 0) + (task.evidence?.length ?? 0)
|
|
229
|
-
+ (task.skipReason?.length ?? 0) + (task.subtasks?.reduce((n, child) => n + retainedTaskChars(child), 0) ?? 0);
|
|
230
|
-
}
|
|
231
238
|
|
|
232
|
-
export function goalPromptParts(goal: GoalRecord, settings?: GoalSettings, contextUsage?: GoalContextUsage): { state: string; counters: string } {
|
|
233
|
-
// The policy block changes only when the goal itself changes; the
|
|
234
|
-
//
|
|
235
|
-
// each at its own rate so
|
|
239
|
+
export function goalPromptParts(goal: GoalRecord, settings?: GoalSettings, contextUsage?: GoalContextUsage): { state: string; tasks?: string; counters: string } {
|
|
240
|
+
// The policy block changes only when the goal itself changes; the task
|
|
241
|
+
// snapshot changes with task progress and the counters nearly every turn.
|
|
242
|
+
// Splitting them lets request-only injection retain each at its own rate so
|
|
243
|
+
// consecutive provider requests stay prefix-stable.
|
|
236
244
|
// Scheduling instructions ride with the policy block: they are cleared by
|
|
237
245
|
// omission, so a retained copy would keep issuing a cancelled order.
|
|
238
246
|
const fixed = cachedPrompt(goal, settings, "goal", () => buildGoalPrompt(goal, settings));
|
|
239
247
|
const { runs, instructions } = schedulerSummaryParts(goal.scheduler, settings?.maxAutonomousRuns, settings?.showAutonomousRuns, settings?.idleContinuationBackoff === true, settings?.commands);
|
|
240
248
|
const limits = `Limits: lifetime tokens=${goal.tokenBudget ?? "none"}; runs=${settings?.maxAutonomousRuns ?? "unlimited"}.`;
|
|
241
|
-
const state = [fixed, instructions, limits, "Usage spans goal turns, not context.
|
|
249
|
+
const state = [fixed, instructions, limits, "Usage spans goal turns, not context. The latest task and usage snapshots supersede earlier snapshots."].filter(Boolean).join("\n");
|
|
250
|
+
const tasks = taskListBlock(goal, settings);
|
|
242
251
|
const context = settings?.showContextUsage === true ? `\n${contextUsageLine(contextUsage)}` : "";
|
|
243
252
|
const counters = `Goal snapshot: ${formatUsage(goal)}${context}\n${runs}`;
|
|
244
|
-
return { state, counters };
|
|
253
|
+
return tasks ? { state, tasks, counters } : { state, counters };
|
|
245
254
|
}
|
|
246
255
|
|
|
247
256
|
export function goalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
|
|
248
|
-
const { state, counters } = goalPromptParts(goal, settings);
|
|
249
|
-
return
|
|
257
|
+
const { state, tasks, counters } = goalPromptParts(goal, settings);
|
|
258
|
+
return [state, tasks, counters].filter(Boolean).join("\n");
|
|
250
259
|
}
|
|
251
260
|
|
|
252
261
|
function buildGoalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
|
|
@@ -256,7 +265,7 @@ function buildGoalPrompt(goal: GoalRecord, settings?: GoalSettings): string {
|
|
|
256
265
|
`[PI GOAL ACTIVE goalId=${goal.id}]`,
|
|
257
266
|
lifecyclePolicyBlock(settings?.maxAutonomousRuns !== 0, settings?.strictExecutionContract === true || !!goal.scheduler?.wait, settings?.commands), sisyphusDisciplineBlock(goal),
|
|
258
267
|
`Status: ${statusLabel(goal)}\nMode: ${goal.sisyphus ? "sisyphus" : "regular"}`,
|
|
259
|
-
untrustedObjectiveBlock(goal),
|
|
268
|
+
untrustedObjectiveBlock(goal), taskGateBlock(goal, settings), verificationContractBlock(goal, settings),
|
|
260
269
|
].filter(Boolean).join("\n\n");
|
|
261
270
|
}
|
|
262
271
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quantmind-br/pi-goal-x",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.1",
|
|
4
4
|
"description": "Fork of pi-goal-x with a fixed, cache-stable goal tool profile. Adds /goal to pi: conversational goal planning, flexible or ordered goals, persistent progress, and an independent completion auditor.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "pi-goal-x contributors",
|