@nklisch/pi-enhanced 0.4.1 → 0.4.3

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v0.4.3 — Prepared
4
+
5
+ ### Fixed
6
+
7
+ - Bundle `@nklisch/pi-plugins` 0.8.3 and select `@nklisch/pi-mcp-adapter` 2.21.0-nklisch.3 so installed MCP calls retain structured facts in model-visible results.
8
+ - Keep all other bundled workspace versions unchanged from 0.4.2.
9
+
3
10
  ## v0.4.1 — 2026-09-04
4
11
 
5
12
  ### Added
@@ -1,62 +1,79 @@
1
1
  # @nklisch/pi-astral-pocket
2
2
 
3
- A persistent note pocket that activates only for `openai-codex/gpt-6-astra`
4
- sessions. Astra gets a dedicated, cross-session note store with recall —
5
- modeled on OpenAI Codex's memories system, minus the always-on cost.
6
-
7
- ## What it does
8
-
9
- When the active model is astra (including mid-session `/model` switches), the
10
- extension:
11
-
12
- - **Injects pocket guidance** into the system prompt: when to consult the
13
- pocket, a budgeted "quick pass" lookup protocol, drift/verification policy,
14
- and note-taking judgment (durable facts only, never secrets).
15
- - **Activates two tools**:
16
- - `pocket_note` — write a durable note (title, body, keywords). The store's
17
- registry and summary are updated mechanically on every write.
18
- - `pocket_recall` — keyword-search pocket notes and past **astra** sessions.
19
- Summarized by default (tool names + truncated args/results); `full: true`
20
- for larger excerpts.
21
- - **Runs a bounded distiller pass** on activation: astra sessions idle past a
22
- threshold (default 6h, max 16 per pass, 30-day window) are distilled into
23
- pocket notes by a cheap configurable model, then consolidated into the
24
- injected summary's durable digest. There is no daemon; the pass only runs
25
- when astra activates. If the distiller is disabled or its model is
26
- unavailable, the mechanical floor (registry + recent-notes index) keeps
27
- working.
28
-
29
- For any other model, the extension is inert: no tools, no injection, no
30
- distiller.
3
+ Astral Pocket keeps durable Markdown notes across Pi sessions. It activates only
4
+ when the current model is `openai-codex/gpt-6-astra`.
31
5
 
32
- ## Commands
6
+ Notes normally belong to the current Git repository. Subdirectories and linked
7
+ worktrees share the same local repository identity. Explicit global notes can
8
+ carry a general preference or a conditional lesson between repositories.
9
+ Foreign repository memories are not injected into the current session.
33
10
 
34
- - `/pocket on` / `/pocket off` — enable/disable the pocket (persisted).
35
- - `/pocket status` — show enablement, activation, note count, distiller config.
11
+ ## Notes and digest
36
12
 
37
- ## Storage
13
+ The extension adds two tools while Astra is active:
38
14
 
39
- Everything lives in `~/.pi/agent/astral-pocket/` (or
40
- `$PI_CODING_AGENT_DIR/astral-pocket/`):
15
+ - `pocket_note` saves a durable note. Its default scope is `project`. The caller
16
+ may set `scope: "global"` only for an intentionally portable preference or
17
+ observation.
18
+ - `pocket_recall` searches current-project and global notes, plus past Astra
19
+ sessions from the current repository. `full: true` returns larger excerpts.
20
+ `scope: "all"` deliberately includes other repositories and labels their
21
+ results as cross-repository precedent. Recall returns at most 20 matches from
22
+ each selected source.
41
23
 
24
+ Distillation means extracting durable decisions, constraints, preferences, and
25
+ pitfalls from an idle session into a source-linked note. Automatic extraction
26
+ always stays project-scoped. A digest is then rebuilt from the actual notes for
27
+ the current repository. Explicit global notes have a separate, smaller digest.
28
+ The extension never builds a new digest by recursively summarizing an old one.
29
+
30
+ Memory is historical evidence, not an instruction source. The current user
31
+ request and current repository guidance take priority. Verify remembered facts
32
+ when their relevance or freshness is uncertain.
33
+
34
+ ## Commands
35
+
36
+ ```text
37
+ /pocket status
38
+ /pocket on
39
+ /pocket off
40
+ /pocket distiller on
41
+ /pocket distiller off
42
+ /pocket model openai-codex/gpt-6-astra
43
+ /pocket model reset
44
+ /pocket reasoning minimal
45
+ /pocket reasoning off|minimal|low|medium|high|xhigh|max
46
+ /pocket reasoning reset
47
+ /pocket distill
48
+ /pocket rebuild
42
49
  ```
43
- config.json # enabled flag + distiller settings
44
- SUMMARY.md # injected into astra's prompt; pinned block + durable digest + recent notes
45
- POCKET.md # searchable registry, one line per note
46
- notes/ # append-only note files (<timestamp>-<slug>.md)
47
- distilled.json # distiller bookkeeping
48
- ```
49
50
 
50
- ### Configuration
51
+ `/pocket status` shows the requested and resolved model, requested and effective
52
+ reasoning, whether distillation is enabled, and the last pass outcome. Pi may
53
+ map a requested reasoning level to a model-supported effort; status reports that
54
+ mapping.
55
+
56
+ `/pocket distill` retries changed or previously failed session work and stale
57
+ digests. `/pocket rebuild` also forces the current project and global digest
58
+ caches to be regenerated. Both commands require an active Astra session and an
59
+ enabled distiller.
51
60
 
52
- `config.json` (all optional, defaults shown):
61
+ Changing the model or reasoning setting cancels the current pass before starting
62
+ a replacement. Switching away from Astra, disabling the pocket, reloading, or
63
+ shutting down cancels the session-owned pass.
64
+
65
+ ## Configuration
66
+
67
+ Configuration lives in `config.json`. All fields are optional. These are the
68
+ defaults:
53
69
 
54
70
  ```json
55
71
  {
56
72
  "enabled": true,
57
73
  "distiller": {
58
74
  "enabled": true,
59
- "model": null,
75
+ "model": "openai-codex/gpt-6-astra",
76
+ "reasoning": "minimal",
60
77
  "minIdleHours": 6,
61
78
  "maxSessionsPerPass": 16,
62
79
  "maxSessionAgeDays": 30
@@ -64,19 +81,58 @@ distilled.json # distiller bookkeeping
64
81
  }
65
82
  ```
66
83
 
67
- `distiller.model` is a `"provider/modelId"` override; when null the first
68
- resolvable entry of a cheap-model preference list is used. If no model
69
- resolves, the distiller skips with a notice.
84
+ The model must use the exact `provider/modelId` form. Astral Pocket asks Pi's
85
+ model registry for current authentication and headers on every request. It does
86
+ not silently select another provider when that model is unavailable. A malformed
87
+ configuration falls back to defaults without blocking note access.
88
+
89
+ ## Storage and recovery
70
90
 
71
- ## Privacy note
91
+ Files are stored under `~/.pi/agent/astral-pocket/`, or under
92
+ `$PI_CODING_AGENT_DIR/astral-pocket/` when that variable is set:
72
93
 
73
- Pi session files record tool calls and results verbatim. `pocket_recall`
74
- searches only astra sessions and summarizes by default, but full excerpts can
75
- re-surface anything that appeared in past output. Keep the default summarized
76
- mode unless you need exact commands or error text.
94
+ ```text
95
+ config.json settings
96
+ notes/ canonical Markdown notes
97
+ digests/ rebuildable per-project and global digest caches
98
+ POCKET.md rebuildable note registry
99
+ SUMMARY.md legacy-compatible derived summary surface
100
+ distilled.json processed source revisions and digest fingerprints
101
+ ```
102
+
103
+ Canonical note files remain readable if a model call fails or a derived registry
104
+ or digest lags. Changed sessions replace their one stable generated note. If a
105
+ fresh extraction returns `NONE`, that generated note is removed rather than
106
+ leaving superseded knowledge behind. Legacy notes are not rewritten: notes with
107
+ project metadata remain project-scoped, while notes with unknown scope appear
108
+ only in an explicit all-project recall.
109
+
110
+ There is no daemon or global worker. Work runs only on Astra activation or an
111
+ explicit command. Pi's file mutation queue serializes writes inside one process,
112
+ and files are atomically replaced to avoid torn output. Two simultaneous Pi
113
+ processes can still duplicate model calls or publish competing derived snapshots;
114
+ the extension does not claim cross-process locking. A later rebuild recovers the
115
+ registry and digest from canonical notes.
116
+
117
+ ## Privacy
118
+
119
+ Session files can contain prompts, tool arguments, and tool results. Distillation
120
+ uses a bounded transcript that omits most tool output and instructs the model to
121
+ exclude credentials, personal data, quoted instructions, rejected proposals,
122
+ and facts already documented in the repository. A prompt cannot guarantee that
123
+ every sensitive value is removed.
124
+
125
+ Choosing a distiller model sends the bounded source material to that model's
126
+ configured provider. Astral Pocket never falls back to a different provider.
127
+ Use `pocket_recall` without `full` first; larger session excerpts can re-surface
128
+ sensitive text from past work. Use all-project recall only when you intentionally
129
+ want foreign repository precedent.
77
130
 
78
131
  ## Install
79
132
 
80
133
  ```sh
81
- pi install npm:@nklisch/pi-astral-pocket
134
+ pi install npm:@nklisch/pi-enhanced
82
135
  ```
136
+
137
+ Astral Pocket is currently distributed as part of Pi Enhanced rather than as a
138
+ standalone npm package.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nklisch/pi-astral-pocket",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Model-gated note pocket and session recall for gpt-6-astra",
6
6
  "type": "module",
@@ -40,10 +40,12 @@
40
40
  "typecheck": "tsc --noEmit"
41
41
  },
42
42
  "peerDependencies": {
43
+ "@earendil-works/pi-ai": "*",
43
44
  "@earendil-works/pi-coding-agent": "*",
44
45
  "typebox": "*"
45
46
  },
46
47
  "devDependencies": {
48
+ "@earendil-works/pi-ai": "0.82.0",
47
49
  "@earendil-works/pi-coding-agent": "*",
48
50
  "@types/node": "^24.0.0",
49
51
  "typescript": ">=7.0.0 <8",
@@ -1,21 +1,28 @@
1
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
5
+
6
+ export const DEFAULT_DISTILLER_MODEL = "openai-codex/gpt-6-astra";
7
+ export const DEFAULT_DISTILLER_REASONING: ModelThinkingLevel = "minimal";
8
+ export const REASONING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
9
+
4
10
  export interface DistillerConfig {
5
- /** Master switch for the startup-pass distiller. */
11
+ /** Master switch for activation-time and explicit distillation. */
6
12
  enabled: boolean;
7
- /** "provider/modelId" override; null walks the cheap-model preference list. */
8
- model: string | null;
13
+ /** Exact provider/modelId selection. No provider fallback is performed. */
14
+ model: string;
15
+ /** Requested Pi reasoning level; the model may map it to another effective effort. */
16
+ reasoning: ModelThinkingLevel;
9
17
  /** Sessions younger than this are still in progress; leave them alone. */
10
18
  minIdleHours: number;
11
- /** Bound on LLM extraction calls per activation pass (Codex parity: 16). */
19
+ /** Bound on extraction calls per pass. */
12
20
  maxSessionsPerPass: number;
13
- /** Sessions older than this are not worth distilling (Codex parity: 30). */
21
+ /** Sessions older than this are not automatically distilled. */
14
22
  maxSessionAgeDays: number;
15
23
  }
16
24
 
17
25
  export interface PocketConfig {
18
- /** Master switch for the whole pocket, toggled by /pocket on|off. */
19
26
  enabled: boolean;
20
27
  distiller: DistillerConfig;
21
28
  }
@@ -24,22 +31,14 @@ export const DEFAULT_CONFIG: PocketConfig = {
24
31
  enabled: true,
25
32
  distiller: {
26
33
  enabled: true,
27
- model: null,
34
+ model: DEFAULT_DISTILLER_MODEL,
35
+ reasoning: DEFAULT_DISTILLER_REASONING,
28
36
  minIdleHours: 6,
29
37
  maxSessionsPerPass: 16,
30
38
  maxSessionAgeDays: 30,
31
39
  },
32
40
  };
33
41
 
34
- /** Cheap-model preference order when config.distiller.model is null. The first
35
- * entry resolvable in the user's model registry wins; if none resolve, the
36
- * distiller degrades to "mechanical floor only" rather than failing. */
37
- export const DISTILLER_MODEL_PREFERENCE: readonly string[] = [
38
- "zai/glm-5.3-flash",
39
- "openrouter/deepseek-v4-flash-latest",
40
- "ollama-cloud/glm-5.3-flash",
41
- ];
42
-
43
42
  export function configPath(root: string): string {
44
43
  return join(root, "config.json");
45
44
  }
@@ -49,9 +48,17 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
49
48
  return Math.min(max, Math.max(min, n));
50
49
  }
51
50
 
52
- /** Load config, tolerating a missing or corrupt file by falling back to
53
- * defaults. The pocket is a convenience feature: a broken config must never
54
- * block extension load, so every read failure degrades to defaults. */
51
+ export function isReasoningLevel(value: unknown): value is ModelThinkingLevel {
52
+ return typeof value === "string" && (REASONING_LEVELS as readonly string[]).includes(value);
53
+ }
54
+
55
+ export function isModelSpec(value: unknown): value is string {
56
+ if (typeof value !== "string") return false;
57
+ const slash = value.indexOf("/");
58
+ return slash > 0 && slash < value.length - 1;
59
+ }
60
+
61
+ /** A malformed configuration degrades to defaults so note access remains usable. */
55
62
  export function loadConfig(root: string): PocketConfig {
56
63
  let raw: Record<string, unknown> = {};
57
64
  try {
@@ -59,12 +66,16 @@ export function loadConfig(root: string): PocketConfig {
59
66
  } catch {
60
67
  return structuredClone(DEFAULT_CONFIG);
61
68
  }
62
- const d = (raw.distiller ?? {}) as Record<string, unknown>;
69
+ const d = typeof raw.distiller === "object" && raw.distiller !== null
70
+ ? raw.distiller as Record<string, unknown>
71
+ : {};
63
72
  return {
64
73
  enabled: typeof raw.enabled === "boolean" ? raw.enabled : DEFAULT_CONFIG.enabled,
65
74
  distiller: {
66
75
  enabled: typeof d.enabled === "boolean" ? d.enabled : DEFAULT_CONFIG.distiller.enabled,
67
- model: typeof d.model === "string" && d.model.includes("/") ? d.model : null,
76
+ // Legacy null meant "choose a cheap fallback". It now resets to the explicit Astra default.
77
+ model: isModelSpec(d.model) ? d.model : DEFAULT_DISTILLER_MODEL,
78
+ reasoning: isReasoningLevel(d.reasoning) ? d.reasoning : DEFAULT_DISTILLER_REASONING,
68
79
  minIdleHours: clampNumber(d.minIdleHours, DEFAULT_CONFIG.distiller.minIdleHours, 1, 48),
69
80
  maxSessionsPerPass: clampNumber(d.maxSessionsPerPass, DEFAULT_CONFIG.distiller.maxSessionsPerPass, 1, 128),
70
81
  maxSessionAgeDays: clampNumber(d.maxSessionAgeDays, DEFAULT_CONFIG.distiller.maxSessionAgeDays, 0, 90),
@@ -72,7 +83,13 @@ export function loadConfig(root: string): PocketConfig {
72
83
  };
73
84
  }
74
85
 
86
+ function atomicWrite(path: string, contents: string): void {
87
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
88
+ writeFileSync(temporary, contents, "utf8");
89
+ renameSync(temporary, path);
90
+ }
91
+
75
92
  export function saveConfig(root: string, config: PocketConfig): void {
76
93
  mkdirSync(root, { recursive: true });
77
- writeFileSync(configPath(root), `${JSON.stringify(config, null, 2)}\n`, "utf8");
94
+ atomicWrite(configPath(root), `${JSON.stringify(config, null, 2)}\n`);
78
95
  }
@@ -0,0 +1,78 @@
1
+ import type { DistillerResult } from "./distiller.js";
2
+
3
+ export type PassOutcome =
4
+ | { state: "idle" }
5
+ | { state: "running"; startedAt: string }
6
+ | { state: "completed"; finishedAt: string; result: DistillerResult }
7
+ | { state: "failed"; finishedAt: string; error: string }
8
+ | { state: "cancelled"; finishedAt: string };
9
+
10
+ export type PassReporter = (message: string, level: "info" | "warning" | "error") => void;
11
+
12
+ /** Own one serialized, revocable background pass for this extension instance. */
13
+ export class DistillerController {
14
+ private generation = 0;
15
+ private controller: AbortController | undefined;
16
+ private queue: Promise<void> = Promise.resolve();
17
+ private reporter: PassReporter | undefined;
18
+ private outcome: PassOutcome = { state: "idle" };
19
+
20
+ status(): PassOutcome {
21
+ return this.outcome;
22
+ }
23
+
24
+ stop(): void {
25
+ this.generation += 1;
26
+ this.reporter = undefined;
27
+ this.controller?.abort();
28
+ this.controller = undefined;
29
+ if (this.outcome.state === "running") {
30
+ this.outcome = { state: "cancelled", finishedAt: new Date().toISOString() };
31
+ }
32
+ }
33
+
34
+ start(
35
+ task: (signal: AbortSignal) => Promise<DistillerResult>,
36
+ reporter?: PassReporter,
37
+ ): Promise<void> {
38
+ this.stop();
39
+ const generation = this.generation;
40
+ const controller = new AbortController();
41
+ this.controller = controller;
42
+ this.reporter = reporter;
43
+ const run = async () => {
44
+ if (controller.signal.aborted || generation !== this.generation) return;
45
+ this.outcome = { state: "running", startedAt: new Date().toISOString() };
46
+ try {
47
+ const result = await task(controller.signal);
48
+ if (controller.signal.aborted || generation !== this.generation) return;
49
+ this.outcome = { state: "completed", finishedAt: new Date().toISOString(), result };
50
+ if (result.errors.length > 0) {
51
+ this.report(generation, `Astral Pocket distillation finished with ${result.errors.length} error(s); source notes remain available.`, "warning");
52
+ }
53
+ } catch (error) {
54
+ if (controller.signal.aborted || generation !== this.generation) return;
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ this.outcome = { state: "failed", finishedAt: new Date().toISOString(), error: message };
57
+ this.report(generation, `Astral Pocket distillation failed: ${message}. Source notes remain available.`, "warning");
58
+ } finally {
59
+ if (generation === this.generation) {
60
+ this.controller = undefined;
61
+ this.reporter = undefined;
62
+ }
63
+ }
64
+ };
65
+ this.queue = this.queue.catch(() => undefined).then(run);
66
+ return this.queue;
67
+ }
68
+
69
+ private report(generation: number, message: string, level: "info" | "warning" | "error"): void {
70
+ if (generation !== this.generation || !this.reporter) return;
71
+ try {
72
+ this.reporter(message, level);
73
+ } catch {
74
+ // Session replacement can revoke a UI sink between the generation check
75
+ // and notification. Reporting failure must never escape this boundary.
76
+ }
77
+ }
78
+ }