@pencil-agent/nano-pencil 1.13.9 → 1.13.10

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.
Files changed (46) hide show
  1. package/dist/build-meta.json +3 -3
  2. package/dist/builtin-extensions.js +12 -0
  3. package/dist/core/runtime/pencil-agent.d.ts +28 -1
  4. package/dist/core/runtime/pencil-agent.js +56 -0
  5. package/dist/extensions/defaults/AGENT.md +7 -2
  6. package/dist/extensions/defaults/diagnostics/diagnostic-buffer.d.ts +19 -0
  7. package/dist/extensions/defaults/diagnostics/diagnostic-buffer.js +125 -0
  8. package/dist/extensions/defaults/diagnostics/index.d.ts +8 -0
  9. package/dist/extensions/defaults/diagnostics/index.js +101 -0
  10. package/dist/extensions/defaults/diagnostics/redaction.d.ts +8 -0
  11. package/dist/extensions/defaults/diagnostics/redaction.js +45 -0
  12. package/dist/extensions/defaults/diagnostics/reporter.d.ts +17 -0
  13. package/dist/extensions/defaults/diagnostics/reporter.js +203 -0
  14. package/dist/extensions/defaults/diagnostics/types.d.ts +61 -0
  15. package/dist/extensions/defaults/diagnostics/types.js +7 -0
  16. package/dist/extensions/defaults/sal/eval/index.js +22 -3
  17. package/dist/extensions/defaults/sal/eval/insforge-sink.d.ts +3 -1
  18. package/dist/extensions/defaults/sal/eval/insforge-sink.js +46 -25
  19. package/dist/extensions/defaults/sal/eval/jsonl-sink.d.ts +2 -0
  20. package/dist/extensions/defaults/sal/eval/jsonl-sink.js +15 -2
  21. package/dist/extensions/defaults/sal/eval/types.d.ts +10 -0
  22. package/dist/extensions/defaults/sal/index.js +74 -18
  23. package/dist/node_modules/@pencil-agent/ai/models.generated.d.ts +68 -0
  24. package/dist/node_modules/@pencil-agent/ai/models.generated.js +80 -12
  25. package/dist/packages/mem-core/consolidation.js +14 -1
  26. package/dist/packages/mem-core/diagnostics.d.ts +24 -0
  27. package/dist/packages/mem-core/diagnostics.js +62 -0
  28. package/dist/packages/mem-core/extension.js +48 -28
  29. package/dist/packages/mem-core/extraction.js +14 -1
  30. package/dist/packages/soul-core/diagnostics.d.ts +24 -0
  31. package/dist/packages/soul-core/diagnostics.js +62 -0
  32. package/dist/packages/soul-core/manager.js +13 -2
  33. package/dist/packages/soul-core/src/diagnostics.d.ts +23 -0
  34. package/dist/packages/soul-core/src/diagnostics.js +61 -0
  35. package/dist/packages/soul-core/src/manager.js +13 -2
  36. package/dist/utils/diagnostics.d.ts +38 -0
  37. package/dist/utils/diagnostics.js +89 -0
  38. package/package.json +1 -1
  39. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/200/273/347/273/223.md" +0 -251
  40. package/docs/loop /351/207/215/346/236/204/345/256/214/346/210/220/346/212/245/345/221/212.md" +0 -123
  41. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210.md" +0 -1222
  42. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/256/236/347/216/260/346/212/245/345/221/212.md" +0 -158
  43. package/docs/loop /351/207/215/346/236/204/346/226/271/346/241/210/345/257/271/346/257/224/345/210/206/346/236/220.md" +0 -128
  44. package/docs/loop /351/207/215/346/236/204/350/256/241/345/210/222.md" +0 -321
  45. package/docs/loop-usage-examples.md +0 -215
  46. package/docs/planmode.md +0 -1987
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "1.13.9",
3
- "commitHash": "593bf56",
2
+ "version": "1.13.10",
3
+ "commitHash": "027d681",
4
4
  "branch": "main",
5
- "builtAt": "2026-04-26T17:04:12.832Z"
5
+ "builtAt": "2026-04-27T10:15:14.235Z"
6
6
  }
@@ -20,6 +20,7 @@ const BUNDLED_PRESENCE_EXTENSION = join(__dirname, "extensions", "defaults", "pr
20
20
  const BUNDLED_INTERVIEW_EXTENSION = join(__dirname, "extensions", "defaults", "interview", "index.js");
21
21
  const BUNDLED_LOOP_EXTENSION = join(__dirname, "extensions", "defaults", "loop", "index.js");
22
22
  const BUNDLED_PLAN_EXTENSION = join(__dirname, "extensions", "defaults", "plan", "index.js");
23
+ const BUNDLED_DIAGNOSTICS_EXTENSION = join(__dirname, "extensions", "defaults", "diagnostics", "index.js");
23
24
  const BUNDLED_SAL_EXTENSION = join(__dirname, "extensions", "defaults", "sal", "index.js");
24
25
  const BUNDLED_GRUB_EXTENSION = join(__dirname, "extensions", "defaults", "grub", "index.js");
25
26
  const BUNDLED_SUBAGENT_EXTENSION = join(__dirname, "extensions", "defaults", "subagent", "index.js");
@@ -68,6 +69,17 @@ function findPackageRoot(startDir) {
68
69
  */
69
70
  export function getBuiltinExtensionPaths() {
70
71
  const paths = [];
72
+ // === Diagnostics extension (extension-owned issue buffer and reporting) ===
73
+ // Loaded first so it can subscribe to diagnostic:event before producer
74
+ // extensions such as SAL publish background failures.
75
+ if (existsSync(BUNDLED_DIAGNOSTICS_EXTENSION)) {
76
+ paths.push(BUNDLED_DIAGNOSTICS_EXTENSION);
77
+ }
78
+ else {
79
+ const diagnosticsTs = join(__dirname, "extensions", "defaults", "diagnostics", "index.ts");
80
+ if (existsSync(diagnosticsTs))
81
+ paths.push(diagnosticsTs);
82
+ }
71
83
  // === SAL extension (Structural Anchor Localization, default-on, experimental) ===
72
84
  // Loaded ahead of NanoMem because turn-context producers must publish before
73
85
  // turn-context consumers read. SAL is a producer of structuralAnchor; NanoMem
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { type SDKLogger } from "./sdk.js";
8
8
  import { type AgentSessionEvent } from "./agent-session.js";
9
+ import type { Api } from "@pencil-agent/ai";
9
10
  import type { ThinkingLevel } from "@pencil-agent/agent-core";
10
11
  /**
11
12
  * Simplified options for PencilAgent wrapper.
@@ -13,10 +14,21 @@ import type { ThinkingLevel } from "@pencil-agent/agent-core";
13
14
  export interface PencilAgentOptions {
14
15
  /** API key for the provider. If omitted, uses environment variable. */
15
16
  apiKey?: string;
16
- /** Provider name: 'anthropic', 'openai', 'google', etc. */
17
+ /** Provider name: 'anthropic', 'openai', 'google', or any custom provider in models.json. */
17
18
  provider?: string;
18
19
  /** Model ID: 'claude-4-5-20250920', 'gpt-4o', etc. */
19
20
  model?: string;
21
+ /**
22
+ * Optional base URL when registering a custom provider on the fly.
23
+ * Required when `provider` + `model` is not already defined in
24
+ * ~/.nanopencil/agent/models.json. Ignored when the model is found.
25
+ */
26
+ baseUrl?: string;
27
+ /**
28
+ * Optional API protocol for the dynamically-registered provider.
29
+ * Defaults to "openai-completions". Ignored when the model is found.
30
+ */
31
+ api?: Api;
20
32
  /** Thinking level: 'off' | 'low' | 'medium' | 'high' */
21
33
  thinkingLevel?: ThinkingLevel;
22
34
  /** Working directory. Default: process.cwd() */
@@ -69,6 +81,21 @@ export declare class PencilAgent {
69
81
  * Must be called before run/chat.
70
82
  */
71
83
  init(): Promise<void>;
84
+ /**
85
+ * Resolve constructor-provided provider/model into a Model<any>.
86
+ *
87
+ * Lookup order:
88
+ * 1. Existing entry in modelRegistry (e.g. ~/.nanopencil/agent/models.json
89
+ * already declares this provider/model — common case for users who ran
90
+ * /sal:setup or hand-edited models.json).
91
+ * 2. Dynamic registration when caller supplied baseUrl + apiKey — lets a
92
+ * one-line constructor call wire up a brand-new OpenAI-compatible
93
+ * endpoint without touching disk.
94
+ * 3. Otherwise return undefined and let createAgentSession fall back to
95
+ * findInitialModel() (built-in default). The logger surfaces a warning
96
+ * in this case so the caller knows their args were not honoured.
97
+ */
98
+ private resolveRequestedModel;
72
99
  /**
73
100
  * Handle session events - collects text for run()
74
101
  */
@@ -63,6 +63,10 @@ export class PencilAgent {
63
63
  key: this.options.apiKey,
64
64
  });
65
65
  }
66
+ // Resolve user-specified provider/model into a Model<any> for createAgentSession.
67
+ // Without this, createAgentSession falls back to findInitialModel() which
68
+ // picks the first available built-in — silently ignoring constructor args.
69
+ const resolvedModel = this.resolveRequestedModel(modelRegistry);
66
70
  // Resolve tools
67
71
  let tools = undefined;
68
72
  if (this.options.tools && this.options.tools.length > 0) {
@@ -73,6 +77,7 @@ export class PencilAgent {
73
77
  // Create session
74
78
  this.sessionResult = await createAgentSession({
75
79
  cwd: this.cwd,
80
+ model: resolvedModel,
76
81
  thinkingLevel: this.options.thinkingLevel,
77
82
  tools,
78
83
  authStorage,
@@ -94,6 +99,57 @@ export class PencilAgent {
94
99
  this.session.subscribe(this.handleEvent.bind(this));
95
100
  this.initialized = true;
96
101
  }
102
+ /**
103
+ * Resolve constructor-provided provider/model into a Model<any>.
104
+ *
105
+ * Lookup order:
106
+ * 1. Existing entry in modelRegistry (e.g. ~/.nanopencil/agent/models.json
107
+ * already declares this provider/model — common case for users who ran
108
+ * /sal:setup or hand-edited models.json).
109
+ * 2. Dynamic registration when caller supplied baseUrl + apiKey — lets a
110
+ * one-line constructor call wire up a brand-new OpenAI-compatible
111
+ * endpoint without touching disk.
112
+ * 3. Otherwise return undefined and let createAgentSession fall back to
113
+ * findInitialModel() (built-in default). The logger surfaces a warning
114
+ * in this case so the caller knows their args were not honoured.
115
+ */
116
+ resolveRequestedModel(registry) {
117
+ const provider = this.options.provider;
118
+ const modelId = this.options.model;
119
+ if (!provider || !modelId)
120
+ return undefined;
121
+ const existing = registry.find(provider, modelId);
122
+ if (existing)
123
+ return existing;
124
+ if (this.options.baseUrl) {
125
+ try {
126
+ registry.registerProvider(provider, {
127
+ api: this.options.api ?? "openai-completions",
128
+ baseUrl: this.options.baseUrl,
129
+ apiKey: this.options.apiKey,
130
+ models: [{
131
+ id: modelId,
132
+ name: modelId,
133
+ reasoning: false,
134
+ input: ["text"],
135
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
136
+ contextWindow: 128_000,
137
+ maxTokens: 8192,
138
+ }],
139
+ });
140
+ const registered = registry.find(provider, modelId);
141
+ if (registered)
142
+ return registered;
143
+ }
144
+ catch (err) {
145
+ this.logger.warn(`[PencilAgent] dynamic provider registration failed for ${provider}/${modelId}: ${err.message}`);
146
+ }
147
+ }
148
+ this.logger.warn(`[PencilAgent] model ${provider}/${modelId} not found in registry. ` +
149
+ `Either add it to ~/.nanopencil/agent/models.json or pass { baseUrl, apiKey } to register it dynamically. ` +
150
+ `Falling back to default model selection.`);
151
+ return undefined;
152
+ }
97
153
  /**
98
154
  * Handle session events - collects text for run()
99
155
  */
@@ -3,6 +3,11 @@
3
3
  > P2 | Parent: ../AGENT.md
4
4
 
5
5
  Member List
6
+ diagnostics/index.ts: Diagnostics extension entry, subscribes to diagnostic:event, buffers session-local diagnostic records, prompts only after threshold at agent_end, registers /report-issue
7
+ diagnostics/types.ts: Diagnostic event/report type contract and diagnostic:event channel name
8
+ diagnostics/diagnostic-buffer.ts: DiagnosticBuffer, event coercion, fingerprint dedupe, prompt gating
9
+ diagnostics/reporter.ts: User-approved InsForge pencil_issue_events reporter, configured via NANOPENCIL_ISSUE_* env vars
10
+ diagnostics/redaction.ts: Diagnostic message normalization and secret/path redaction helpers
6
11
  link-world/index.ts: Internet access extension, provides internet-search Skill after setup
7
12
  mcp/index.ts: MCP protocol integration extension, MCP guidance resources
8
13
  presence/index.ts: AI-driven opening + idle presence lines, uses NanoMemEngine episodes/preferences/lessons + git/cwd snapshot, injects latest line into agent systemPrompt every turn for main-conversation perception, 30s debounce + idle in-flight lock, configurable via settings.presence.enabled, PRESENCE_MESSAGE_TYPE renderer
@@ -37,12 +42,12 @@ loop/scheduler-controller.ts: SchedulerController - in-memory recurring task sto
37
42
  loop/scheduler-parser.ts: Loop command parsing with flags/subcommands, parseSchedulerCommand/parseDurationSpec/buildSchedulerHelp, --name/--max/--quiet
38
43
  loop/scheduler-types.ts: Scheduled loop types, LoopPayloadKind/ScheduledLoopTask/LoopStartSpec/ParsedSchedulerCommand
39
44
  loop/README.md: Loop extension documentation - recurring scheduler usage and flags
40
- sal/index.ts: SAL extension entry, enabled by default, registers --nosal/--sal-ab/--sal-rebuild-terrain flags, /sal:coverage /sal:status /sal:setup commands, before_agent_start/tool_execution_start/agent_end hooks; /sal:setup writes ~/.memory-experiments/credentials.json with adapter inference (insforge/jsonl/noop); publishes structuralAnchor via core/runtime/turn-context (no SAL-specific globals); emits run_start/turn_anchor/run_end eval events through pluggable EvalSink with best-effort shutdown flushing; writes local .memory-experiments sidecar anchors only when --sal-ab or NANOPENCIL_SAL_AB=1 is enabled; runtime no-op when --nosal is set
45
+ sal/index.ts: SAL extension entry, enabled by default, registers --nosal/--sal-ab/--sal-rebuild-terrain flags, /sal:coverage /sal:status /sal:setup commands, before_agent_start/tool_execution_start/agent_end hooks; /sal:setup writes ~/.memory-experiments/credentials.json with adapter inference (insforge/jsonl/noop); publishes structuralAnchor via core/runtime/turn-context (no SAL-specific globals); emits run_start/turn_anchor/run_end eval events through pluggable EvalSink with best-effort shutdown flushing; publishes SAL eval background failures to diagnostic:event; writes local .memory-experiments sidecar anchors only when --sal-ab or NANOPENCIL_SAL_AB=1 is enabled; runtime no-op when --nosal is set
41
46
  sal/terrain.ts: TerrainSnapshot/TerrainNode/TerrainEdge model, buildTerrainIndex(), checkDipCoverage(), isSnapshotStale(), moduleIdForPath(), parses P2 AGENT.md and P3 file headers
42
47
  sal/anchors.ts: StructuralAnchor/AnchorResolution model, locateTask(), locateAction(), evidence-driven scoring with tunable SalWeights, CJK bigram tokenization
43
48
  sal/weights.ts: SalWeights interface, SAL_DEFAULT_WEIGHTS, loadSalWeights() reads sal-config.json from workspace or .memory-experiments/sal/
44
49
  sal/eval/index.ts: createEvalSink() factory + barrel re-exports; adapter selection via options.adapter or endpoint scheme inference (http(s)→insforge, file://|/|./|../→jsonl, missing→noop); ONLY entry point SAL imports from
45
- sal/eval/types.ts: EvalSink interface, EvalEventEnvelope/EvalEventType (run_start/run_end/turn_anchor), EvalAdapterId ("insforge"|"jsonl"|"noop"), CreateEvalSinkOptions, createEvalEvent factory; zero-dependency type surface
50
+ sal/eval/types.ts: EvalSink interface, EvalEventEnvelope/EvalEventType (run_start/run_end/turn_anchor), EvalAdapterId ("insforge"|"jsonl"|"noop"), CreateEvalSinkOptions with optional onDiagnostic callback, createEvalEvent factory; zero-dependency type surface
46
51
  sal/eval/noop-sink.ts: noopSink — silent EvalSink used when eval disabled or no adapter configured
47
52
  sal/eval/insforge-sink.ts: InsForgeEvalSink — PostgREST adapter, routes run_start→eval_runs INSERT (merge-duplicates) with legacy-schema fallback, writes turn_anchor/tool_trace/memory_recalls/run_end only after parent run confirmation, tool_trace→eval_tool_traces with PGRST204 fallback, memory_recalls→eval_memory_recalls batch INSERT, run_end→eval_runs PATCH; allowSelfSigned TLS option logs only in development runtime, batching with default 2000ms interval
48
53
  sal/eval/jsonl-sink.ts: JsonlEvalSink — append-only filesystem adapter, one JSON object per line, accepts file:// URLs or plain paths, auto-creates parent dir, batched writes
@@ -0,0 +1,19 @@
1
+ /**
2
+ * [WHO]: DiagnosticBuffer, coerceDiagnosticEvent()
3
+ * [FROM]: Depends on ./types.js and ./redaction.js for event schema and privacy normalization
4
+ * [TO]: Consumed by extensions/defaults/diagnostics/index.ts
5
+ * [HERE]: extensions/defaults/diagnostics/diagnostic-buffer.ts - session-local dedupe and prompt gating state
6
+ */
7
+ import { type DiagnosticEvent, type DiagnosticRecord } from "./types.js";
8
+ export declare class DiagnosticBuffer {
9
+ private records;
10
+ add(event: DiagnosticEvent): DiagnosticRecord;
11
+ all(): DiagnosticRecord[];
12
+ last(): DiagnosticRecord | undefined;
13
+ findPromptCandidate(): DiagnosticRecord | undefined;
14
+ findUnreported(): DiagnosticRecord[];
15
+ markPrompted(fingerprint: string): void;
16
+ markReported(fingerprint: string): void;
17
+ private trim;
18
+ }
19
+ export declare function coerceDiagnosticEvent(value: unknown): DiagnosticEvent | undefined;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * [WHO]: DiagnosticBuffer, coerceDiagnosticEvent()
3
+ * [FROM]: Depends on ./types.js and ./redaction.js for event schema and privacy normalization
4
+ * [TO]: Consumed by extensions/defaults/diagnostics/index.ts
5
+ * [HERE]: extensions/defaults/diagnostics/diagnostic-buffer.ts - session-local dedupe and prompt gating state
6
+ */
7
+ import { normalizeDiagnosticMessage, sanitizeDiagnosticValue } from "./redaction.js";
8
+ const MAX_RECORDS = 100;
9
+ export class DiagnosticBuffer {
10
+ records = new Map();
11
+ add(event) {
12
+ const now = event.created_at ?? new Date().toISOString();
13
+ const sanitized = {
14
+ source: event.source,
15
+ severity: event.severity,
16
+ category: event.category,
17
+ message: normalizeDiagnosticMessage(event.message),
18
+ detail: sanitizeDiagnosticValue(event.detail),
19
+ context: sanitizeDiagnosticValue(event.context),
20
+ created_at: now,
21
+ };
22
+ const fingerprint = event.fingerprint ?? buildFingerprint(sanitized);
23
+ const existing = this.records.get(fingerprint);
24
+ if (existing) {
25
+ existing.last_seen_at = now;
26
+ existing.occurrence_count += 1;
27
+ existing.severity = maxSeverity(existing.severity, sanitized.severity);
28
+ existing.detail = sanitized.detail;
29
+ existing.context = { ...(existing.context ?? {}), ...(sanitized.context ?? {}) };
30
+ // New occurrences invalidate a prior auto-report so the next
31
+ // agent_end batch uploads the updated count.
32
+ existing.reported = false;
33
+ return existing;
34
+ }
35
+ const record = {
36
+ ...sanitized,
37
+ fingerprint,
38
+ first_seen_at: now,
39
+ last_seen_at: now,
40
+ occurrence_count: 1,
41
+ prompted: false,
42
+ reported: false,
43
+ };
44
+ this.records.set(fingerprint, record);
45
+ this.trim();
46
+ return record;
47
+ }
48
+ all() {
49
+ return Array.from(this.records.values()).sort((a, b) => b.last_seen_at.localeCompare(a.last_seen_at));
50
+ }
51
+ last() {
52
+ return this.all()[0];
53
+ }
54
+ findPromptCandidate() {
55
+ return this.all().find((record) => !record.prompted && shouldPrompt(record));
56
+ }
57
+ findUnreported() {
58
+ return this.all().filter((record) => !record.reported);
59
+ }
60
+ markPrompted(fingerprint) {
61
+ const record = this.records.get(fingerprint);
62
+ if (record)
63
+ record.prompted = true;
64
+ }
65
+ markReported(fingerprint) {
66
+ const record = this.records.get(fingerprint);
67
+ if (record)
68
+ record.reported = true;
69
+ }
70
+ trim() {
71
+ if (this.records.size <= MAX_RECORDS)
72
+ return;
73
+ const sorted = this.all();
74
+ for (const record of sorted.slice(MAX_RECORDS)) {
75
+ this.records.delete(record.fingerprint);
76
+ }
77
+ }
78
+ }
79
+ export function coerceDiagnosticEvent(value) {
80
+ if (!value || typeof value !== "object")
81
+ return undefined;
82
+ const input = value;
83
+ const source = typeof input.source === "string" ? input.source : undefined;
84
+ const severity = isSeverity(input.severity) ? input.severity : undefined;
85
+ const category = isCategory(input.category) ? input.category : "unknown";
86
+ const message = typeof input.message === "string" ? input.message : undefined;
87
+ if (!source || !severity || !message)
88
+ return undefined;
89
+ return {
90
+ source,
91
+ severity,
92
+ category,
93
+ message,
94
+ detail: input.detail,
95
+ fingerprint: typeof input.fingerprint === "string" ? input.fingerprint : undefined,
96
+ context: input.context && typeof input.context === "object" ? input.context : undefined,
97
+ created_at: typeof input.created_at === "string" ? input.created_at : undefined,
98
+ };
99
+ }
100
+ function shouldPrompt(record) {
101
+ if (record.severity === "error")
102
+ return record.occurrence_count >= 3;
103
+ if (record.severity === "warning")
104
+ return record.occurrence_count >= 5 || record.category === "fallback";
105
+ return false;
106
+ }
107
+ function buildFingerprint(event) {
108
+ return `${event.source}:${event.category}:${normalizeDiagnosticMessage(event.message).toLowerCase()}`;
109
+ }
110
+ function isSeverity(value) {
111
+ return value === "debug" || value === "info" || value === "warning" || value === "error";
112
+ }
113
+ function isCategory(value) {
114
+ return (value === "network" ||
115
+ value === "fallback" ||
116
+ value === "persistence" ||
117
+ value === "config" ||
118
+ value === "extension_timeout" ||
119
+ value === "schema" ||
120
+ value === "unknown");
121
+ }
122
+ function maxSeverity(a, b) {
123
+ const rank = { debug: 0, info: 1, warning: 2, error: 3 };
124
+ return rank[b] > rank[a] ? b : a;
125
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * [WHO]: diagnosticsExtension - diagnostic:event listener, /report-issue command, silent auto-upload on agent_end
3
+ * [FROM]: Depends on core/extensions/types, @pencil-agent/tui, ./diagnostic-buffer, ./reporter, ./types
4
+ * [TO]: Auto-loaded by builtin-extensions.ts as a default extension before diagnostic producers
5
+ * [HERE]: extensions/defaults/diagnostics/index.ts - extension-owned diagnostic buffer; background failures auto-upload silently at agent_end, /report-issue stays for explicit user-initiated bundles
6
+ */
7
+ import type { ExtensionAPI } from "../../../core/extensions/types.js";
8
+ export default function diagnosticsExtension(api: ExtensionAPI): Promise<void>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * [WHO]: diagnosticsExtension - diagnostic:event listener, /report-issue command, silent auto-upload on agent_end
3
+ * [FROM]: Depends on core/extensions/types, @pencil-agent/tui, ./diagnostic-buffer, ./reporter, ./types
4
+ * [TO]: Auto-loaded by builtin-extensions.ts as a default extension before diagnostic producers
5
+ * [HERE]: extensions/defaults/diagnostics/index.ts - extension-owned diagnostic buffer; background failures auto-upload silently at agent_end, /report-issue stays for explicit user-initiated bundles
6
+ */
7
+ import { Box, Container, Spacer, Text } from "@pencil-agent/tui";
8
+ import { subscribeDiagnostics } from "../../../utils/diagnostics.js";
9
+ import { coerceDiagnosticEvent, DiagnosticBuffer } from "./diagnostic-buffer.js";
10
+ import { reportDiagnostics } from "./reporter.js";
11
+ import { DIAGNOSTIC_EVENT_CHANNEL } from "./types.js";
12
+ const MESSAGE_TYPE = "diagnostics";
13
+ export default async function diagnosticsExtension(api) {
14
+ const buffer = new DiagnosticBuffer();
15
+ api.registerMessageRenderer(MESSAGE_TYPE, (message, _options, theme) => {
16
+ const text = typeof message.content === "string" ? message.content : JSON.stringify(message.content, null, 2);
17
+ const box = new Box(1, 1, (v) => theme.bg("customMessageBg", v));
18
+ box.addChild(new Text(theme.fg("dim", text), 0, 0));
19
+ const container = new Container();
20
+ container.addChild(new Spacer(1));
21
+ container.addChild(box);
22
+ return container;
23
+ });
24
+ // Legacy path: producers that still call api.events.emit("diagnostic:event", ...)
25
+ api.events.on(DIAGNOSTIC_EVENT_CHANNEL, (payload) => {
26
+ const event = coerceDiagnosticEvent(payload);
27
+ if (!event)
28
+ return;
29
+ buffer.add(event);
30
+ });
31
+ // Canonical path: producers (including deep utilities without api access)
32
+ // using utils/diagnostics.ts → reportDiagnostic(...). The shared Symbol.for
33
+ // slot also relays mem-core (separate package) events here.
34
+ subscribeDiagnostics((event) => {
35
+ const coerced = coerceDiagnosticEvent(event);
36
+ if (coerced)
37
+ buffer.add(coerced);
38
+ });
39
+ api.on("agent_end", async (_event, ctx) => {
40
+ // Background subsystem failures (SAL eval, mem-core, presence, etc.)
41
+ // auto-upload silently — they did not interrupt the user, so prompting
42
+ // for permission would be reverse-value. /report-issue stays available
43
+ // for the user to bundle records manually (info/debug included).
44
+ const unreported = buffer.findUnreported();
45
+ if (unreported.length === 0)
46
+ return;
47
+ // pencil_issue_events is for actionable issues. info/debug telemetry
48
+ // (e.g. Soul evolution success notes) shows up in dev console via the
49
+ // bus but should not pollute the issue table. Mark them reported so
50
+ // they don't accumulate forever.
51
+ const uploadable = unreported.filter((r) => r.severity === "warning" || r.severity === "error");
52
+ const skipped = unreported.filter((r) => r.severity !== "warning" && r.severity !== "error");
53
+ for (const record of skipped)
54
+ buffer.markReported(record.fingerprint);
55
+ if (uploadable.length === 0)
56
+ return;
57
+ const result = await reportDiagnostics(uploadable, undefined, ctx);
58
+ // Mark reported when the upload landed OR when the reporter has no
59
+ // endpoint configured (no point re-trying every turn against missing
60
+ // config). Transient HTTP/network failures stay unreported and will
61
+ // retry on the next agent_end.
62
+ if (result.ok || !result.configured) {
63
+ for (const record of uploadable)
64
+ buffer.markReported(record.fingerprint);
65
+ }
66
+ });
67
+ api.registerCommand("report-issue", {
68
+ description: "Report recent diagnostics (/report-issue [last|all|note])",
69
+ handler: (args, ctx) => handleReportIssue(args, ctx, buffer),
70
+ });
71
+ }
72
+ async function handleReportIssue(args, ctx, buffer) {
73
+ const trimmed = args.trim();
74
+ const records = selectRecords(trimmed, buffer);
75
+ if (records.length === 0) {
76
+ ctx.ui.notify("No diagnostics recorded in this session.", "info");
77
+ return;
78
+ }
79
+ const userNote = trimmed && trimmed !== "last" && trimmed !== "all" ? stripQuotes(trimmed) : undefined;
80
+ const result = await reportDiagnostics(records, userNote, ctx);
81
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
82
+ if (result.ok) {
83
+ ctx.ui.setStatus("diagnostics", undefined);
84
+ }
85
+ }
86
+ function selectRecords(args, buffer) {
87
+ if (args === "all")
88
+ return buffer.all();
89
+ if (args === "last") {
90
+ const last = buffer.last();
91
+ return last ? [last] : [];
92
+ }
93
+ const all = buffer.all();
94
+ return all.length > 0 ? all.slice(0, 5) : [];
95
+ }
96
+ function stripQuotes(value) {
97
+ if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
98
+ return value.slice(1, -1);
99
+ }
100
+ return value;
101
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * [WHO]: sanitizeDiagnosticValue(), normalizeDiagnosticMessage()
3
+ * [FROM]: No external dependencies
4
+ * [TO]: Consumed by diagnostic-buffer.ts and reporter.ts before local persistence or network upload
5
+ * [HERE]: extensions/defaults/diagnostics/redaction.ts - privacy-preserving diagnostic normalization
6
+ */
7
+ export declare function normalizeDiagnosticMessage(message: string): string;
8
+ export declare function sanitizeDiagnosticValue(value: unknown, depth?: number): unknown;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * [WHO]: sanitizeDiagnosticValue(), normalizeDiagnosticMessage()
3
+ * [FROM]: No external dependencies
4
+ * [TO]: Consumed by diagnostic-buffer.ts and reporter.ts before local persistence or network upload
5
+ * [HERE]: extensions/defaults/diagnostics/redaction.ts - privacy-preserving diagnostic normalization
6
+ */
7
+ const SECRET_KEY_PATTERN = /(api[_-]?key|authorization|token|secret|password|credential|cookie)/i;
8
+ const SECRET_VALUE_PATTERN = /\b(?:sk|ik|pk|ak|xox[baprs]?|gh[pousr])_[A-Za-z0-9_-]{8,}\b/g;
9
+ const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi;
10
+ const HOME_PATH_PATTERN = /\/(?:Users|home|root)\/[^\s"',:)]+/g;
11
+ export function normalizeDiagnosticMessage(message) {
12
+ return sanitizeString(message)
13
+ .replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "<uuid>")
14
+ .replace(/\b\d{4}-\d{2}-\d{2}T[\d:.]+Z\b/g, "<timestamp>")
15
+ .replace(/\s+/g, " ")
16
+ .trim();
17
+ }
18
+ export function sanitizeDiagnosticValue(value, depth = 0) {
19
+ if (depth > 5)
20
+ return "[MaxDepth]";
21
+ if (typeof value === "string")
22
+ return sanitizeString(value);
23
+ if (typeof value === "number" || typeof value === "boolean" || value == null)
24
+ return value;
25
+ if (Array.isArray(value))
26
+ return value.slice(0, 20).map((item) => sanitizeDiagnosticValue(item, depth + 1));
27
+ if (typeof value !== "object")
28
+ return String(value);
29
+ const out = {};
30
+ for (const [key, item] of Object.entries(value)) {
31
+ if (SECRET_KEY_PATTERN.test(key)) {
32
+ out[key] = "[Redacted]";
33
+ continue;
34
+ }
35
+ out[key] = sanitizeDiagnosticValue(item, depth + 1);
36
+ }
37
+ return out;
38
+ }
39
+ function sanitizeString(value) {
40
+ return value
41
+ .replace(BEARER_PATTERN, "Bearer [Redacted]")
42
+ .replace(SECRET_VALUE_PATTERN, "[Redacted]")
43
+ .replace(HOME_PATH_PATTERN, "<path>")
44
+ .slice(0, 2000);
45
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * [WHO]: reportDiagnostics(), buildReportPayload()
3
+ * [FROM]: Depends on node:http, node:https, node:fs, node:os, node:path, node:crypto, core extension context types, ./types.js, ./redaction.js
4
+ * [TO]: Consumed by extensions/defaults/diagnostics/index.ts for silent auto-upload + /report-issue manual bundles
5
+ * [HERE]: extensions/defaults/diagnostics/reporter.ts - InsForge pencil_issue_events adapter; reads NANOPENCIL_ISSUE_* env first, falls back to <workspace>/.memory-experiments/credentials.json then ~/.memory-experiments/credentials.json (shared with SAL eval) so issue reporting "just works" once SAL is set up
6
+ */
7
+ import type { ExtensionContext } from "../../../core/extensions/types.js";
8
+ import type { DiagnosticRecord, DiagnosticReportPayload } from "./types.js";
9
+ interface ReportResult {
10
+ ok: boolean;
11
+ configured: boolean;
12
+ statusCode?: number;
13
+ message: string;
14
+ }
15
+ export declare function reportDiagnostics(records: DiagnosticRecord[], userNote: string | undefined, ctx: ExtensionContext): Promise<ReportResult>;
16
+ export declare function buildReportPayload(records: DiagnosticRecord[], userNote: string | undefined, ctx: ExtensionContext): DiagnosticReportPayload;
17
+ export {};