@aexol/spectral 0.7.5 → 0.7.6

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.
@@ -472,7 +472,7 @@ function makeRelaySubscriber(sessionId, relay) {
472
472
  * an `error` event to subscribers, so we don't double-report.
473
473
  */
474
474
  export function handleClientMessage(frame, deps) {
475
- const { sessionId, message, modelId } = frame;
475
+ const { sessionId, message, modelId, reasoningEffort } = frame;
476
476
  const { manager, relay, subscribers } = deps;
477
477
  const logger = deps.logger ?? console;
478
478
  // 0. Shutdown gate. Once `gracefulShutdown` flips the flag we refuse
@@ -583,7 +583,7 @@ export function handleClientMessage(frame, deps) {
583
583
  //
584
584
  // When `loop: true`, loop state is set before prompting; the normal
585
585
  // model is used — loop replay is handled by session-stream on agent_end.
586
- manager.prompt(sessionId, content, modelId, validImages).catch((err) => {
586
+ manager.prompt(sessionId, content, modelId, validImages, reasoningEffort).catch((err) => {
587
587
  logger.error?.(`[dispatcher] manager.prompt failed for ${sessionId}:`, err);
588
588
  });
589
589
  }
@@ -842,6 +842,65 @@ export class PiBridge {
842
842
  return false;
843
843
  }
844
844
  }
845
+ /**
846
+ * Map a frontend reasoning-effort string to pi's ThinkingLevel.
847
+ * Frontend sends: xhigh | high | medium | low | minimal | none | undefined
848
+ * Pi expects: "high" | "medium" | "low" | "minimal" | "off"
849
+ *
850
+ * Mapping:
851
+ * xhigh → high (pi doesn't have xhigh, default to max)
852
+ * high → high
853
+ * medium → medium
854
+ * low → low
855
+ * minimal → minimal
856
+ * none → off
857
+ * undefined → no-op (pi keeps whatever it has currently)
858
+ */
859
+ mapReasoningEffortToThinkingLevel(effort) {
860
+ if (effort === undefined)
861
+ return undefined;
862
+ switch (effort) {
863
+ case "xhigh":
864
+ return "high";
865
+ case "high":
866
+ case "medium":
867
+ case "low":
868
+ case "minimal":
869
+ return effort;
870
+ case "none":
871
+ return "off";
872
+ default:
873
+ return undefined;
874
+ }
875
+ }
876
+ /**
877
+ * Set the reasoning/thinking effort level for the next prompt.
878
+ * Pass `undefined` to leave pi's current level unchanged.
879
+ *
880
+ * The caller (SessionStreamManager) is responsible for persisting the
881
+ * value to SQLite; this method only applies it to pi's in-memory session.
882
+ */
883
+ setReasoningEffort(effort) {
884
+ const level = this.mapReasoningEffortToThinkingLevel(effort);
885
+ if (level === undefined)
886
+ return;
887
+ if (!this.session) {
888
+ // Bridge hasn't started yet — the reasoning effort will be picked
889
+ // up from storage on the next prompt via session-stream's persistence.
890
+ return;
891
+ }
892
+ try {
893
+ this.session.setThinkingLevel(level);
894
+ }
895
+ catch (err) {
896
+ const e = err instanceof Error ? err : new Error(String(err));
897
+ this.opts.onError?.(e);
898
+ this.opts.emit({
899
+ type: "error",
900
+ message: `Failed to set reasoning effort: ${e.message}`,
901
+ });
902
+ }
903
+ }
845
904
  /**
846
905
  * Forward a user message to pi. Resolves when the full turn ends.
847
906
  * The caller is responsible for persisting the user message to SQLite
@@ -483,7 +483,7 @@ export class SessionStreamManager {
483
483
  * - When neither envelope nor SQLite have a value, we leave model
484
484
  * selection to pi's own settings file (pre-Phase-3 behaviour).
485
485
  */
486
- async prompt(sessionId, content, modelId, images) {
486
+ async prompt(sessionId, content, modelId, images, reasoningEffort) {
487
487
  if (this.disposed)
488
488
  throw new Error("SessionStreamManager disposed");
489
489
  let stream = this.streams.get(sessionId);
@@ -599,6 +599,24 @@ export class SessionStreamManager {
599
599
  console.warn(`[spectral] warn: failed to persist sticky model for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
600
600
  }
601
601
  }
602
+ // Sticky reasoning-effort resolution. Same order as modelId:
603
+ // a) Envelope-supplied value → apply + persist.
604
+ // b) Otherwise, look up the persisted value from SQLite.
605
+ // c) When neither is present, leave pi's current thinking level unchanged.
606
+ // We apply BEFORE persisting the user message so the turn runs with the
607
+ // correct reasoning level.
608
+ const effectiveReasoningEffort = reasoningEffort ?? this.store.getSessionReasoningEffort(sessionId) ?? undefined;
609
+ if (effectiveReasoningEffort && stream.bridge.setReasoningEffort) {
610
+ stream.bridge.setReasoningEffort(effectiveReasoningEffort);
611
+ }
612
+ if (reasoningEffort !== undefined) {
613
+ try {
614
+ this.store.setSessionReasoningEffort(sessionId, reasoningEffort || null);
615
+ }
616
+ catch (err) {
617
+ console.warn(`[spectral] warn: failed to persist reasoning effort for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
618
+ }
619
+ }
602
620
  // 1. Persist user message first (survives mid-prompt failures).
603
621
  let stored;
604
622
  try {
@@ -32,9 +32,10 @@ import { dirname, join } from "node:path";
32
32
  import { getConfigDir } from "../config.js";
33
33
  import { stripJsoncComments } from "../studio-binding.js";
34
34
  /**
35
- * Schema version. Bump + the on-open migration drops & recreates every table.
36
- * Since this is local-only conversation history pre-1.0, we explicitly do not
37
- * preserve user data across schema changes.
35
+ * Schema version informational, stamped into every new database.
36
+ *
37
+ * ALL schema changes are additive (ALTER TABLE ADD COLUMN). The version is
38
+ * never used to trigger destructive drops; it is purely for troubleshooting.
38
39
  */
39
40
  const SCHEMA_VERSION = 4;
40
41
  const SCHEMA_SQL = `
@@ -54,7 +55,8 @@ CREATE TABLE IF NOT EXISTS sessions (
54
55
  title TEXT NOT NULL,
55
56
  created_at INTEGER NOT NULL,
56
57
  updated_at INTEGER NOT NULL,
57
- model_id TEXT
58
+ model_id TEXT,
59
+ reasoning_effort TEXT
58
60
  );
59
61
 
60
62
  CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id, updated_at DESC);
@@ -130,7 +132,6 @@ function applyBindingFields(project) {
130
132
  };
131
133
  }
132
134
  /** Tables we own — used by the migration drop step. */
133
- const KNOWN_TABLES = ["prompt_queue", "project_observations", "session_memory_snapshots", "messages", "sessions", "projects"];
134
135
  /** Best-effort parse of the `images_json` column into `ImageAttachment[]`. */
135
136
  function parseImagesJson(raw) {
136
137
  if (!raw || raw === "")
@@ -188,6 +189,8 @@ export class SessionStore {
188
189
  // Phase 3: per-session sticky model.
189
190
  stmtGetSessionModel;
190
191
  stmtSetSessionModel;
192
+ stmtGetSessionReasoningEffort;
193
+ stmtSetSessionReasoningEffort;
191
194
  // Fork & Compact: flag + per-session source tracking.
192
195
  stmtSetForkCompactSource;
193
196
  stmtGetForkCompactSource;
@@ -210,28 +213,17 @@ export class SessionStore {
210
213
  this.db = new Database(path);
211
214
  this.db.pragma("journal_mode = WAL");
212
215
  this.db.pragma("foreign_keys = ON");
213
- // ---- migration --------------------------------------------------------
214
- // Detect schema version and drop+recreate on mismatch. Pre-1.0: no data
215
- // preservation. The first open on a brand-new file reports version 0
216
- // and falls through to the create path below (drops are no-ops on
217
- // missing tables).
218
- const versionRow = this.db.pragma("user_version", { simple: true });
219
- if (versionRow !== SCHEMA_VERSION) {
220
- // Disable FKs for the duration of the drop so order doesn't matter
221
- // (we drop children first regardless, but this keeps it bullet-proof
222
- // if KNOWN_TABLES order is ever reshuffled).
223
- this.db.pragma("foreign_keys = OFF");
224
- const tx = this.db.transaction(() => {
225
- for (const t of KNOWN_TABLES) {
226
- this.db.exec(`DROP TABLE IF EXISTS ${t}`);
227
- }
228
- });
229
- tx();
230
- this.db.pragma("foreign_keys = ON");
231
- }
216
+ // ---- schema creation -------------------------------------------------
217
+ // SCHEMA_SQL uses CREATE TABLE IF NOT EXISTS for every table, so it is
218
+ // always safe to run on a fresh database all tables are created;
219
+ // on an existing one every statement is a no-op. Schema changes that
220
+ // add columns are handled by the additive migration block below.
221
+ //
222
+ // We NO LONGER drop tables when the stored user_version differs from
223
+ // SCHEMA_VERSION. The previous destructive behaviour caused permanent
224
+ // data loss on version bumps. SCHEMA_VERSION is now informational it
225
+ // is stamped into every new database purely for troubleshooting.
232
226
  this.db.exec(SCHEMA_SQL);
233
- // Stamp the version AFTER schema creation so a crash mid-create is
234
- // detected next open and re-runs the (idempotent) create.
235
227
  this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
236
228
  // ---- additive column migration ---------------------------------------
237
229
  // Phase 3 (Available Models): per-session sticky model selection lives in
@@ -248,6 +240,9 @@ export class SessionStore {
248
240
  if (!sessionCols.some((c) => c.name === "model_id")) {
249
241
  this.db.exec(`ALTER TABLE sessions ADD COLUMN model_id TEXT`);
250
242
  }
243
+ if (!sessionCols.some((c) => c.name === "reasoning_effort")) {
244
+ this.db.exec(`ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT`);
245
+ }
251
246
  // Additive migration: images_json column for base64 image attachments on
252
247
  // user messages. Older rows default to '' (no images).
253
248
  const msgCols = this.db
@@ -338,6 +333,8 @@ export class SessionStore {
338
333
  this.stmtDeleteSessionMemorySnapshot = this.db.prepare(`DELETE FROM session_memory_snapshots WHERE session_id = ?`);
339
334
  this.stmtGetSessionModel = this.db.prepare(`SELECT model_id FROM sessions WHERE id = ?`);
340
335
  this.stmtSetSessionModel = this.db.prepare(`UPDATE sessions SET model_id = ? WHERE id = ?`);
336
+ this.stmtGetSessionReasoningEffort = this.db.prepare(`SELECT reasoning_effort FROM sessions WHERE id = ?`);
337
+ this.stmtSetSessionReasoningEffort = this.db.prepare(`UPDATE sessions SET reasoning_effort = ? WHERE id = ?`);
341
338
  this.stmtSetForkCompactSource = this.db.prepare(`UPDATE sessions SET fork_compact_source_id = ? WHERE id = ?`);
342
339
  this.stmtGetForkCompactSource = this.db.prepare(`SELECT fork_compact_source_id FROM sessions WHERE id = ?`);
343
340
  this.stmtInsertProjectObs = this.db.prepare(`INSERT OR REPLACE INTO project_observations (id, project_id, session_id, content, relevance, created_at)
@@ -645,6 +642,23 @@ export class SessionStore {
645
642
  setSessionModel(sessionId, modelId) {
646
643
  this.stmtSetSessionModel.run(modelId, sessionId);
647
644
  }
645
+ /**
646
+ * Get the persisted reasoningEffort for a session, or null if none
647
+ * was ever set. Returns null for unknown sessions (consistent with
648
+ * getSessionModel's contract).
649
+ */
650
+ getSessionReasoningEffort(sessionId) {
651
+ const row = this.stmtGetSessionReasoningEffort.get(sessionId);
652
+ return row?.reasoning_effort ?? null;
653
+ }
654
+ /**
655
+ * Persist the reasoningEffort for a session. Pass `null` to clear.
656
+ * Same semantics as setSessionModel — best-effort metadata, does
657
+ * not bump `updated_at`.
658
+ */
659
+ setSessionReasoningEffort(sessionId, effort) {
660
+ this.stmtSetSessionReasoningEffort.run(effort, sessionId);
661
+ }
648
662
  // ----------------------------------------------------------------------
649
663
  // Fork & Compact
650
664
  // ----------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,