Merge pull request 'chore: bring upstream v1.20.0 into custom' (#2) from release/v1.20.0 into custom

This commit is contained in:
2026-08-28 22:05:23 -04:00
281 changed files with 10504 additions and 2180 deletions
+1
View File
@@ -1291,6 +1291,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({
return sanitizeProjects(settings?.projects || []).map((project) => project.path);
},
resolvePrimaryWorktreeRoot,
managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats')],
});
/**
@@ -24,7 +24,8 @@ const normalize = (value) => {
};
export const createMemoryProjectResolver = (dependencies) => {
const { listProjectPaths, resolvePrimaryWorktreeRoot } = dependencies;
const { listProjectPaths, resolvePrimaryWorktreeRoot, managedProjectRoots = [] } = dependencies;
const managedRoots = managedProjectRoots.map(normalize).filter(Boolean);
return async (directory) => {
const resolved = normalize(directory);
@@ -32,6 +33,14 @@ export const createMemoryProjectResolver = (dependencies) => {
return '';
}
const managedRoot = managedRoots.find((root) => {
const relative = path.relative(root, resolved);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
});
if (managedRoot) {
return createProjectIdFromPath(managedRoot);
}
let configured = [];
try {
configured = ((await listProjectPaths()) || []).map(normalize).filter(Boolean);
@@ -51,6 +51,15 @@ describe('resolving a session directory to its project', () => {
expect(await resolve('/tmp/loose')).toBe(createProjectIdFromPath('/tmp/loose'));
});
test('managed chat session directories share the Chats root store', async () => {
const chatsRoot = '/Users/x/.config/openchamber/chats';
const resolve = createResolver({ managedProjectRoots: [chatsRoot] });
expect(await resolve(`${chatsRoot}/2026-08-21/session-a`)).toBe(createProjectIdFromPath(chatsRoot));
expect(await resolve(`${chatsRoot}/2026-08-21/session-b`)).toBe(createProjectIdFromPath(chatsRoot));
expect(await resolve('/Users/x/.config/openchamber/chats-other/session-a')).not.toBe(createProjectIdFromPath(chatsRoot));
});
test('no directory resolves to nothing rather than to some default project', async () => {
const resolve = createResolver();
@@ -236,8 +236,13 @@ export const createAgentMemoryRuntime = (deps) => {
const writeJsonAtomic = async (filePath, value) => {
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
try {
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
} catch (error) {
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
};
const withWriteLock = async (key, mutate) => {
@@ -1,9 +1,15 @@
# Dictation module
Server-authoritative streaming speech-to-text for the chat composer, plus
local text-to-speech. The client streams 16 kHz mono PCM16 chunks (base64)
over a WebSocket; the server runs the transcription and streams live partial
transcripts back.
Server-authoritative speech-to-text for the chat composer, plus local
text-to-speech. The client streams 16 kHz mono PCM16 chunks (base64) over a
WebSocket while the user speaks; the server buffers them and transcribes each
segment exactly once, when the segment is committed.
Transcription is deliberately not incremental. Parakeet is an offline model
trained on whole utterances, so re-decoding the growing buffer to animate a
live transcript costs O(n^2) work for a result the final decode replaces. The
composer shows no text while recording and inserts the full transcript on
stop.
Local TTS (Kokoro via sherpa-onnx OfflineTts) runs in the same worker process
and is exposed as `POST /api/dictation/tts/speak` (JSON `{text, speakerId?,
@@ -21,9 +27,9 @@ same status/download/delete routes.
Created from the startup pipeline (`startup-pipeline-runtime.js`) before
the generic OpenCode proxy so routes are not shadowed.
- `stream-manager.js` — `DictationStreamManager`, one per WS connection.
Chunk reordering by `seq` + ack, resampling to the provider rate,
auto-commit every ~15 s of audio, silence suppression by PCM peak,
partial-transcript concatenation, adaptive finalization timeout.
Chunk reordering by `seq` + ack, resampling to the provider rate, segment
splitting, silence suppression by PCM peak, partial-transcript
concatenation, adaptive finalization timeout.
- `service.js` — provider resolution and readiness. Providers:
- `local` (default): sherpa-onnx Parakeet TDT in a forked worker process.
Models auto-download in the background on first use; while missing, the
@@ -33,7 +39,7 @@ same status/download/delete routes.
OpenAI-compatible `/v1/audio/transcriptions` endpoint
(`openai-compatible-session.js`, reuses `../tts/stt.js`).
- `local/` — worker process + client (IPC, idle shutdown TTL), sherpa
recognizer engine and realtime session (throttled re-decode for partials),
recognizer engine and segment session (one decode per committed segment),
model catalog and downloader. The native `sherpa-onnx-node` addon is only
ever loaded inside the worker process.
- `audio.js` — PCM16 helpers: format parsing, peak, WAV wrapping, streaming
@@ -53,9 +59,27 @@ Server → client: `ready`, `ack {ackSeq}`, `partial {text}`,
`{ provider: 'local' | 'openai-compatible', language?, localModel?,
openaiCompatible?: { baseUrl, model, apiKey } }`.
## Segmentation
A dictation is one segment unless it runs long. Past `segmentMinSeconds`
(60 s) the manager commits on the first silent chunk, so cuts land at a pause
rather than mid-word; `segmentMaxSeconds` (90 s) is a hard cap for speech with
no pause in it. Client chunks are ~1 s, so "silent chunk" is roughly a second
of silence.
The bounds exist because Parakeet is a full-attention conformer: decode cost
and peak memory grow quadratically with segment length. Measured on Parakeet
v3 int8 with 2 threads: 60 s took 2.1 s and +90 MB, 180 s took 9.3 s and
+490 MB, 300 s took 21.3 s and +1.5 GB. Committed segments decode while the
user is still speaking, so only the tail is left to transcribe on stop.
## Invariants
- Never load `sherpa-onnx-node` in the main server process.
- Transcription happens on commit only; sessions never emit non-final
transcripts. The `partial` messages a client receives are the concatenation
of already-committed segments, and exist so a dictation that fails partway
can be salvaged instead of losing minutes of speech.
- The stream manager acks only the highest contiguous seq; the client is
expected to retain unacked segments for retry/replay.
- Silence-only segments (peak < 300) are cleared, never committed, so
@@ -1,7 +1,12 @@
/**
* Sherpa-onnx offline recognizer engine (NeMo transducer / Parakeet) plus a
* realtime streaming transcription session that re-decodes the accumulated
* segment audio on a throttle to produce live partial transcripts.
* segment transcription session that decodes each segment exactly once, when
* the segment is committed.
*
* Parakeet is an offline model: it is trained to see a whole utterance at
* once. Decoding the accumulated audio repeatedly to animate a live transcript
* costs O(n^2) work for a result the final decode throws away, so this session
* only decodes on commit.
*
* Runs inside the dictation worker process only — never load the native
* addon in the main server process.
@@ -147,31 +152,26 @@ export class SherpaOfflineRecognizerEngine {
}
/**
* Streaming transcription session backed by the offline recognizer.
* Accumulates the current segment's PCM and re-decodes it at most every
* `minDecodeIntervalMs` to emit non-final partial transcripts; `commit()`
* finalizes the segment and starts a new one.
* Segment transcription session backed by the offline recognizer.
* Accumulates the current segment's PCM and decodes it once in `commit()`,
* which emits the segment's final transcript and starts a new segment.
*
* Implements the StreamingTranscriptionSession contract used by
* DictationStreamManager.
* DictationStreamManager. It never emits non-final transcripts: the manager's
* live `partial` messages are the concatenation of already-committed segments.
*/
export class SherpaRealtimeTranscriptionSession extends EventEmitter {
export class SherpaSegmentTranscriptionSession extends EventEmitter {
/**
* @param {{ engine: SherpaOfflineRecognizerEngine, minDecodeIntervalMs?: number }} params
* @param {{ engine: SherpaOfflineRecognizerEngine }} params
*/
constructor({ engine, minDecodeIntervalMs }) {
constructor({ engine }) {
super();
this.engine = engine;
this.requiredSampleRate = engine.sampleRate;
this.minDecodeIntervalMs = minDecodeIntervalMs ?? 350;
this.connected = false;
this.currentSegmentId = null;
this.previousSegmentId = null;
this.lastPartialText = '';
this.pcm16 = Buffer.alloc(0);
this.lastDecodeAt = 0;
this.decoding = false;
this.pendingDecode = false;
}
async connect() {
@@ -184,39 +184,38 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
appendPcm16(chunk) {
if (!this.connected || !this.currentSegmentId) {
this.emit('error', new Error('Sherpa realtime session not connected'));
this.emit('error', new Error('Sherpa transcription session not connected'));
return;
}
this.pcm16 = this.pcm16.length === 0 ? chunk : Buffer.concat([this.pcm16, chunk]);
this.maybeDecode(false).catch((err) => {
this.emit('error', err instanceof Error ? err : new Error(String(err)));
});
}
commit() {
if (!this.connected || !this.currentSegmentId) {
this.emit('error', new Error('Sherpa realtime session not connected'));
this.emit('error', new Error('Sherpa transcription session not connected'));
return;
}
void (async () => {
try {
await this.maybeDecode(true);
const finalText = this.lastPartialText;
const segmentId = this.currentSegmentId;
const previousSegmentId = this.previousSegmentId;
const segmentId = this.currentSegmentId;
const previousSegmentId = this.previousSegmentId;
const pcm16 = this.pcm16;
this.emit('committed', { segmentId, previousSegmentId });
this.emit('transcript', { segmentId, transcript: finalText, isFinal: true });
// Start the next segment before decoding: decoding blocks the worker for
// seconds on long segments, and audio for the next one keeps arriving.
this.previousSegmentId = segmentId;
this.currentSegmentId = randomUUID();
this.pcm16 = Buffer.alloc(0);
this.previousSegmentId = segmentId;
this.currentSegmentId = randomUUID();
this.lastPartialText = '';
this.pcm16 = Buffer.alloc(0);
} catch (err) {
this.emit('error', err instanceof Error ? err : new Error(String(err)));
}
})();
this.emit('committed', { segmentId, previousSegmentId });
let transcript;
try {
transcript = this.engine.decodePcm16(pcm16);
} catch (err) {
this.emit('error', err instanceof Error ? err : new Error(String(err)));
return;
}
this.emit('transcript', { segmentId, transcript, isFinal: true });
}
clear() {
@@ -225,7 +224,6 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
}
this.pcm16 = Buffer.alloc(0);
this.currentSegmentId = randomUUID();
this.lastPartialText = '';
}
close() {
@@ -233,45 +231,4 @@ export class SherpaRealtimeTranscriptionSession extends EventEmitter {
this.currentSegmentId = null;
this.pcm16 = Buffer.alloc(0);
}
async maybeDecode(force) {
if (!this.connected || !this.currentSegmentId) {
return;
}
const now = Date.now();
if (!force && now - this.lastDecodeAt < this.minDecodeIntervalMs) {
return;
}
if (this.decoding) {
this.pendingDecode = true;
return;
}
this.decoding = true;
try {
const decodeStartedAt = Date.now();
const text = this.engine.decodePcm16(this.pcm16);
this.lastDecodeAt = Date.now();
// Adaptive throttle: on slow hardware (or heavy models) re-decoding the
// growing segment every 350ms would monopolize the worker. Space partial
// decodes to ~1.5x the observed decode time.
this.minDecodeIntervalMs = Math.max(350, (this.lastDecodeAt - decodeStartedAt) * 1.5);
if (text !== this.lastPartialText) {
this.lastPartialText = text;
this.emit('transcript', {
segmentId: this.currentSegmentId,
transcript: text,
isFinal: false,
});
}
} finally {
this.decoding = false;
if (this.pendingDecode) {
this.pendingDecode = false;
await this.maybeDecode(true);
}
}
}
}
@@ -17,7 +17,7 @@
import {
SherpaOfflineRecognizerEngine,
SherpaRealtimeTranscriptionSession,
SherpaSegmentTranscriptionSession,
} from './sherpa-recognizer.js';
import { SherpaTtsEngine } from './sherpa-tts.js';
import { getLocalSttModelDir, getLocalSttModelSpec } from './model-catalog.js';
@@ -126,7 +126,7 @@ async function handleRequest(message) {
case 'session.create': {
cleanupSession(message.sessionId);
const engine = getEngine(message.modelsDir, message.modelId);
const session = new SherpaRealtimeTranscriptionSession({ engine });
const session = new SherpaSegmentTranscriptionSession({ engine });
session.on('committed', (payload) => {
sendToParent({ type: 'session.committed', sessionId: message.sessionId, payload });
});
@@ -3,8 +3,9 @@
* endpoints (faster-whisper, whisper.cpp, OpenAI, ...).
*
* The Whisper HTTP API cannot stream, so audio is buffered per segment and
* transcribed on commit(). Live partials therefore only advance at segment
* boundaries (the DictationStreamManager auto-commits every ~15s of speech).
* transcribed on commit(). This matches how the local session behaves: the
* DictationStreamManager splits long dictations at pauses, and everything
* shorter is one request on stop.
*
* Implements the StreamingTranscriptionSession contract used by
* DictationStreamManager.
@@ -7,23 +7,54 @@
* Responsibilities:
* - Reorders inbound chunks by `seq` and acks the highest contiguous seq.
* - Resamples client PCM (16 kHz by default) to the provider's required rate.
* - Auto-commits a segment every `autoCommitSeconds` of audio, but clears
* silence-only segments instead of committing them.
* - Segments long dictations at natural pauses: past `segmentMinSeconds` of
* audio it commits on the first silent chunk, and `segmentMaxSeconds` is a
* hard cap for speech with no pause in it. Silence-only segments are
* cleared instead of committed.
* - Concatenates per-segment transcripts into live partials and emits the
* final text once every committed segment has a final transcript.
* final text once every committed segment has a final transcript. The
* manager counts the commits it issued rather than trusting the session's
* echoed events, so a commit still in flight when the client finishes
* cannot be silently dropped from the transcript.
* - Applies an adaptive finalization timeout budget based on pending work.
*/
import { Pcm16MonoResampler, parsePcmRateFromFormat, pcm16lePeakAbs } from './audio.js';
const DEFAULT_FINAL_TIMEOUT_MS = 10000;
const DEFAULT_AUTO_COMMIT_SECONDS = 15;
// Parakeet is a full-attention conformer: decode cost and peak memory grow
// quadratically with segment length (measured: 60s -> 2.1s/+90MB,
// 300s -> 21.3s/+1.5GB). Segmenting keeps a long dictation off that curve and
// lets committed segments decode while the user is still speaking, so only the
// tail is left to transcribe on stop. Typical dictations are shorter than the
// minimum and are decoded as a single segment.
const DEFAULT_SEGMENT_MIN_SECONDS = 60;
const DEFAULT_SEGMENT_MAX_SECONDS = 90;
const FINAL_TIMEOUT_MAX_MS = 5 * 60 * 1000;
const FINAL_TIMEOUT_PER_PENDING_SEGMENT_MS = 15 * 1000;
const FINAL_TIMEOUT_PER_PENDING_AUDIO_SECOND_MS = 1500;
const FINAL_TIMEOUT_PER_MISSING_SEQ_MS = 250;
const SILENCE_PEAK_THRESHOLD = 300;
const secondsToPcm16Bytes = (seconds, sampleRate) =>
seconds > 0 ? Math.max(1, Math.round(seconds * sampleRate * 2)) : 0;
/**
* Split the current segment once it is long enough to be worth decoding on its
* own and the speaker has just gone quiet, or unconditionally at the hard cap.
* Client chunks are ~1s, so a quiet chunk is roughly a second of silence — long
* enough to be a sentence boundary rather than a gap between words.
*/
function shouldSplitSegment(state) {
if (state.segmentMaxBytes > 0 && state.bytesSinceCommit >= state.segmentMaxBytes) {
return true;
}
if (state.segmentMinBytes <= 0 || state.bytesSinceCommit < state.segmentMinBytes) {
return false;
}
return state.lastChunkPeak < SILENCE_PEAK_THRESHOLD;
}
export class DictationStreamManager {
/**
* @param {object} params
@@ -33,13 +64,15 @@ export class DictationStreamManager {
* The streaming transcription session contract:
* { requiredSampleRate, appendPcm16(buf), commit(), clear(), close(), on(event, handler) }
* @param {number} [params.finalTimeoutMs]
* @param {number} [params.autoCommitSeconds]
* @param {number} [params.segmentMinSeconds] audio before a pause may split a segment
* @param {number} [params.segmentMaxSeconds] hard segment cap for pauseless speech
*/
constructor({ emit, createSttSession, finalTimeoutMs, autoCommitSeconds }) {
constructor({ emit, createSttSession, finalTimeoutMs, segmentMinSeconds, segmentMaxSeconds }) {
this.emit = emit;
this.createSttSession = createSttSession;
this.finalTimeoutMs = finalTimeoutMs ?? DEFAULT_FINAL_TIMEOUT_MS;
this.autoCommitSeconds = autoCommitSeconds ?? DEFAULT_AUTO_COMMIT_SECONDS;
this.segmentMinSeconds = segmentMinSeconds ?? DEFAULT_SEGMENT_MIN_SECONDS;
this.segmentMaxSeconds = segmentMaxSeconds ?? DEFAULT_SEGMENT_MAX_SECONDS;
this.streams = new Map();
}
@@ -87,13 +120,12 @@ export class DictationStreamManager {
if (!state) {
return;
}
// Segment accounting is reset where the commit is issued, not here: this
// event arrives after an async hop, and zeroing the counters on arrival
// would discard audio that came in meanwhile — up to and including
// mistaking the tail of the dictation for silence and clearing it.
state.committedSegmentIds.push(segmentId);
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
if (state.finishRequested && state.awaitingFinalCommit) {
state.awaitingFinalCommit = false;
}
state.pendingCommits = Math.max(0, state.pendingCommits - 1);
this.maybeFinalizeStream(dictationId);
});
@@ -108,10 +140,6 @@ export class DictationStreamManager {
state.finalTranscriptSegmentIds.add(segmentId);
}
if (state.finishRequested && state.awaitingFinalCommit && isFinal) {
state.awaitingFinalCommit = false;
}
const orderedIds = state.committedSegmentIds.includes(segmentId)
? state.committedSegmentIds
: [...state.committedSegmentIds, segmentId];
@@ -143,16 +171,15 @@ export class DictationStreamManager {
receivedChunks: new Map(),
nextSeqToForward: 0,
ackSeq: -1,
autoCommitBytes:
this.autoCommitSeconds > 0
? Math.max(1, Math.round(this.autoCommitSeconds * stt.requiredSampleRate * 2))
: 0,
segmentMinBytes: secondsToPcm16Bytes(this.segmentMinSeconds, stt.requiredSampleRate),
segmentMaxBytes: secondsToPcm16Bytes(this.segmentMaxSeconds, stt.requiredSampleRate),
bytesSinceCommit: 0,
peakSinceCommit: 0,
lastChunkPeak: 0,
committedSegmentIds: [],
transcriptsBySegmentId: new Map(),
finalTranscriptSegmentIds: new Set(),
awaitingFinalCommit: false,
pendingCommits: 0,
finishRequested: false,
finishSealed: false,
finalSeq: null,
@@ -203,7 +230,8 @@ export class DictationStreamManager {
if (resampled.length > 0) {
state.stt.appendPcm16(resampled);
state.bytesSinceCommit += resampled.length;
state.peakSinceCommit = Math.max(state.peakSinceCommit, pcm16lePeakAbs(resampled));
state.lastChunkPeak = pcm16lePeakAbs(resampled);
state.peakSinceCommit = Math.max(state.peakSinceCommit, state.lastChunkPeak);
try {
this.maybeAutoCommitSegment(state);
} catch (error) {
@@ -325,9 +353,7 @@ export class DictationStreamManager {
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
}, 0);
const pendingSegments =
pendingCommittedSegments +
pendingUncommittedTranscriptSegments +
(state.awaitingFinalCommit ? 1 : 0);
pendingCommittedSegments + pendingUncommittedTranscriptSegments + state.pendingCommits;
const pendingAudioSeconds = Math.ceil(Math.max(0, state.bytesSinceCommit) / bytesPerSecond);
const missingSeqCount =
state.finalSeq === null ? 0 : Math.max(0, state.finalSeq - state.ackSeq);
@@ -347,19 +373,36 @@ export class DictationStreamManager {
if (state.finishRequested) {
return;
}
if (state.autoCommitBytes <= 0 || state.bytesSinceCommit < state.autoCommitBytes) {
if (!shouldSplitSegment(state)) {
return;
}
if (state.peakSinceCommit < SILENCE_PEAK_THRESHOLD) {
state.stt.clear();
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.lastChunkPeak = 0;
return;
}
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.stt.commit();
state.lastChunkPeak = 0;
this.commitSegment(state);
}
/**
* Issue a commit and record it as in flight. The session acknowledges with a
* `committed` event; until then the manager must not finalize, or the
* segment's transcript would be missing from the final text.
*/
commitSegment(state) {
state.pendingCommits += 1;
try {
state.stt.commit();
} catch (error) {
state.pendingCommits -= 1;
throw error;
}
}
maybeSealStreamFinish(dictationId) {
@@ -382,19 +425,19 @@ export class DictationStreamManager {
state.stt.clear();
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.awaitingFinalCommit = false;
state.lastChunkPeak = 0;
this.dropUncommittedNonFinalTranscripts(state);
} else {
state.awaitingFinalCommit = true;
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
state.lastChunkPeak = 0;
try {
state.stt.commit();
this.commitSegment(state);
} catch (error) {
this.failAndCleanupStream(dictationId, error?.message || String(error), true);
return;
}
}
} else {
state.awaitingFinalCommit = false;
}
state.finishSealed = true;
@@ -425,7 +468,7 @@ export class DictationStreamManager {
if (state.ackSeq < state.finalSeq) {
return;
}
if (state.awaitingFinalCommit) {
if (state.pendingCommits > 0) {
return;
}
@@ -175,8 +175,8 @@ describe('DictationStreamManager', () => {
},
});
const { manager, messages } = createManager(session);
// Force auto-commit after ~0.05s of audio so two segments form.
manager.autoCommitSeconds = 0.05;
// Force a hard-cap split after ~0.05s of audio so two segments form.
manager.segmentMaxSeconds = 0.05;
await manager.handleStart('d1', FORMAT, {});
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(1600) });
@@ -191,4 +191,62 @@ describe('DictationStreamManager', () => {
const partials = messages.filter((m) => m.type === 'partial');
expect(partials.length).toBeGreaterThan(0);
});
it('keeps a short dictation as one segment even across pauses', async () => {
const session = new FakeSttSession();
const { manager } = createManager(session);
await manager.handleStart('d1', FORMAT, {});
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: silentChunkBase64(16000) });
manager.handleChunk({ dictationId: 'd1', seq: 2, audioBase64: loudChunkBase64(16000) });
expect(session.commits).toBe(0);
manager.handleFinish('d1', 2);
await waitFor(() => session.commits === 1);
});
it('splits at a pause once the segment passes the minimum length', async () => {
const session = new FakeSttSession();
const { manager } = createManager(session);
manager.segmentMinSeconds = 3;
await manager.handleStart('d1', FORMAT, {});
// 2s of audio: below the minimum, so this pause must not split.
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: silentChunkBase64(16000) });
expect(session.commits).toBe(0);
// Past the minimum, the next quiet chunk is a segment boundary.
manager.handleChunk({ dictationId: 'd1', seq: 2, audioBase64: loudChunkBase64(16000) });
expect(session.commits).toBe(0);
manager.handleChunk({ dictationId: 'd1', seq: 3, audioBase64: silentChunkBase64(16000) });
expect(session.commits).toBe(1);
});
it('splits pauseless speech at the hard cap', async () => {
const session = new FakeSttSession();
const { manager } = createManager(session);
manager.segmentMinSeconds = 60;
manager.segmentMaxSeconds = 2;
await manager.handleStart('d1', FORMAT, {});
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: loudChunkBase64(16000) });
expect(session.commits).toBe(0);
manager.handleChunk({ dictationId: 'd1', seq: 1, audioBase64: loudChunkBase64(16000) });
expect(session.commits).toBe(1);
});
it('clears a silence-only segment at the hard cap instead of committing it', async () => {
const session = new FakeSttSession();
const { manager } = createManager(session);
manager.segmentMaxSeconds = 1;
await manager.handleStart('d1', FORMAT, {});
manager.handleChunk({ dictationId: 'd1', seq: 0, audioBase64: silentChunkBase64(16000) });
expect(session.commits).toBe(0);
expect(session.clears).toBe(1);
});
});
+76
View File
@@ -3,6 +3,7 @@ import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mintOutsideFileGrant, registerFsRoutes } from './routes.js';
import { createProjectDirectoryRuntime } from '../opencode/project-directory-runtime.js';
const createRouteRegistry = () => {
const routes = new Map();
@@ -1114,3 +1115,78 @@ describe('fs list symlink path space (issue 2627)', () => {
});
}
});
describe('fs stat directory scope (issue 3019)', () => {
// Wires the real project-directory runtime so the stat route resolves the
// workspace exactly as the server does: explicit x-opencode-directory header
// first, then the settings.lastDirectory fallback. The renderer's file
// reference probes must send the header because lastDirectory reflects the
// directory the UI last browsed, not the session's directory.
const registerStatWithProjectDirectoryRuntime = () => {
const projectDirectoryRuntime = createProjectDirectoryRuntime({
fsPromises: {
stat: async (targetPath) => {
if (targetPath === '/repo-a' || targetPath === '/repo-b') {
return { isDirectory: () => true };
}
return { isDirectory: () => false, isFile: () => true, size: 12 };
},
realpath: async (targetPath) => targetPath,
},
path: { resolve: (p) => path.posix.resolve(p) },
normalizeDirectoryPath: (p) => p,
readSettingsFromDiskMigrated: async () => ({ lastDirectory: '/repo-a', projects: [] }),
getReadSettingsFromDiskMigrated: undefined,
sanitizeProjects: (input) => input,
});
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
stat: async () => ({ isFile: () => true, size: 12 }),
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: projectDirectoryRuntime.resolveProjectDirectory,
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('GET', '/api/fs/stat');
};
const callStat = async (handler, { headers = {}, query }) => {
const res = createMockResponse();
const req = {
query,
get: (name) => headers[name.toLowerCase()] ?? undefined,
};
await handler(req, res);
return res;
};
it('rejects a stat for a file under the session directory when only lastDirectory resolves the workspace', async () => {
const handler = registerStatWithProjectDirectoryRuntime();
const res = await callStat(handler, { query: { path: '/repo-b/src/index.ts', optional: 'true' } });
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Path is outside of active workspace' });
});
it('accepts the same stat when the session directory rides the x-opencode-directory header', async () => {
const handler = registerStatWithProjectDirectoryRuntime();
const res = await callStat(handler, {
headers: { 'x-opencode-directory': '/repo-b' },
query: { path: '/repo-b/src/index.ts', optional: 'true' },
});
expect(res.statusCode).toBe(200);
expect(res.body.isFile).toBe(true);
});
});
+43
View File
@@ -428,6 +428,49 @@ export function registerGitRoutes(app) {
}
});
app.get('/api/git/branch-base', async (req, res) => {
const { getBranchBase } = await getGitLibraries();
try {
const directory = resolveDirectoryQuery(req.query.directory);
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const branch = resolveDirectoryQuery(req.query.branch);
if (!branch) {
return res.status(400).json({ error: 'branch parameter is required' });
}
const result = await getBranchBase(directory, branch);
res.json(result);
} catch (error) {
console.error('Failed to get branch base:', error);
res.status(500).json({ error: error.message || 'Failed to get branch base' });
}
});
app.get('/api/git/range-files', async (req, res) => {
const { getRangeFiles } = await getGitLibraries();
try {
const directory = resolveDirectoryQuery(req.query.directory);
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const base = resolveDirectoryQuery(req.query.base);
const head = resolveDirectoryQuery(req.query.head);
if (!base || !head) {
return res.status(400).json({ error: 'base and head parameters are required' });
}
const files = await getRangeFiles(directory, { base, head });
res.json({ files });
} catch (error) {
console.error('Failed to get git range files:', error);
res.status(500).json({ error: error.message || 'Failed to get git range files' });
}
});
app.post('/api/git/revert', async (req, res) => {
const { revertFile } = await getGitLibraries();
try {
+85 -5
View File
@@ -2654,6 +2654,71 @@ export async function getRangeDiff(directory, { base, head, path: filePath, cont
return diff;
}
const BRANCH_CREATION_SOURCE_RE = /^branch: Created from (.+)$/;
/**
* Parse a branch reflog (`git reflog show --format=%gs <branch>`) and return the
* ref the branch was created from, when that source is itself a named ref.
*
* Returns null when the branch was created from `HEAD@{...}` or a raw commit
* (detached start): the original branch name is not recorded anywhere in that
* case, and guessing a base from commit topology would be a heuristic, not an
* answer. Callers should ask the user to pick a base instead.
*/
export function parseBranchCreationSource(reflogText) {
const lines = String(reflogText || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
// Reflog lists newest entries first; the creation entry is the oldest one.
for (let index = lines.length - 1; index >= 0; index -= 1) {
const match = lines[index].match(BRANCH_CREATION_SOURCE_RE);
if (!match) continue;
const source = match[1].trim();
if (!source || /^HEAD@/.test(source) || /^[0-9a-f]{7,40}$/i.test(source)) {
return null;
}
return source;
}
return null;
}
/**
* Resolve the branch the given branch was created from, from its reflog.
* Returns { base: null } when git has no authoritative record (clone, detached
* start, reflog expired) — callers must not fall back to main/master.
*/
export async function getBranchBase(directory, branch) {
const branchName = String(branch || '').trim();
if (!branchName) {
throw new Error('branch is required');
}
const { git } = await createRepositoryGitContext(directory);
let reflog = '';
try {
reflog = await git.raw(['reflog', 'show', '--format=%gs', branchName]);
} catch {
return { base: null };
}
const source = parseBranchCreationSource(reflog);
if (!source || source === branchName) {
return { base: null };
}
const resolves = await git
.raw(['rev-parse', '--verify', '--quiet', source])
.then((value) => Boolean(String(value || '').trim()))
.catch(() => false);
if (!resolves) {
return { base: null };
}
return { base: source };
}
export async function getRangeFiles(directory, { base, head } = {}) {
const { git } = await createRepositoryGitContext(directory);
const baseRef = typeof base === 'string' ? base.trim() : '';
@@ -2673,11 +2738,26 @@ export async function getRangeFiles(directory, { base, head } = {}) {
// ignore
}
const raw = await git.raw(['diff', '--name-only', `${resolvedBase}...${headRef}`]);
return String(raw || '')
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
// `-C` (copy detection among changed files only, so cheap) makes copies
// surface as C entries instead of plain additions; rename detection is on
// by default.
const raw = await git.raw(['diff', '--name-status', '-z', '-C', `${resolvedBase}...${headRef}`]);
// -z format: STATUS\0PATH\0[ORIG\0] repeated. For rename/copy entries
// (`R100`, `C75`) the first path token is the ORIGINAL path and the second
// is the DESTINATION — the diff (and the UI) must address the destination.
const tokens = String(raw || '').split('\0');
const files = [];
for (let index = 0; index < tokens.length; index += 1) {
const status = (tokens[index] || '').trim();
if (!status) continue;
const isRenameOrCopy = status.startsWith('R') || status.startsWith('C');
const path = isRenameOrCopy ? (tokens[index + 2] || '').trim() : (tokens[index + 1] || '').trim();
index += isRenameOrCopy ? 2 : 1;
if (path) {
files.push({ path, status: status.charAt(0) });
}
}
return files;
}
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp', 'avif'];
@@ -28,6 +28,8 @@ import {
getDiff,
getFileDiff,
validateWorktreeCreate,
parseBranchCreationSource,
getRangeFiles,
} from './service.js';
// ---------------------------------------------------------------------------
@@ -1336,3 +1338,94 @@ describe.runIf(canRunGit())('getRangeDiff', () => {
expect(diff).toContain('feature.txt');
});
});
describe('parseBranchCreationSource', () => {
it('returns the source ref from the oldest creation entry', () => {
// Reflog lists newest entries first; creation is the last line.
const reflog = [
'commit: abc123',
'branch: Created from origin/main',
].join('\n');
expect(parseBranchCreationSource(reflog)).toBe('origin/main');
});
it('returns null when the branch was created from a detached HEAD pointer', () => {
const reflog = 'branch: Created from HEAD@{0}';
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null when the branch was created from a raw commit', () => {
const reflog = 'branch: Created from 9a3b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b';
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null when there is no creation entry', () => {
const reflog = ['commit: abc123', 'reset: moving to HEAD'].join('\n');
expect(parseBranchCreationSource(reflog)).toBeNull();
});
it('returns null for empty input', () => {
expect(parseBranchCreationSource('')).toBeNull();
expect(parseBranchCreationSource(undefined)).toBeNull();
});
});
describe.runIf(canRunGit())('getRangeFiles', () => {
it('returns added and modified paths with their status letters', async () => {
const { repository } = createRepositoryWithRemote();
fs.writeFileSync(path.join(repository, 'added.txt'), 'new\n');
fs.writeFileSync(path.join(repository, 'README.md'), '# Test\nchanged\n');
runGit(repository, ['add', 'added.txt', 'README.md']);
runGit(repository, ['commit', '-m', 'changes']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
expect(files).toEqual(expect.arrayContaining([
{ path: 'added.txt', status: 'A' },
{ path: 'README.md', status: 'M' },
]));
});
it('reports the destination path for renamed files, including spaces', async () => {
const { repository } = createRepositoryWithRemote();
// The original file must exist in the base: rename detection pairs a
// deletion against an addition relative to base, not within the branch.
fs.writeFileSync(path.join(repository, 'old name with spaces.md'), '# Test\n');
runGit(repository, ['add', 'old name with spaces.md']);
runGit(repository, ['commit', '-m', 'add file to rename']);
runGit(repository, ['push', 'origin', 'HEAD:react']);
// Spaces in filenames exercise the -z token split: a newline split would
// mangle these paths long before status letters matter.
fs.renameSync(path.join(repository, 'old name with spaces.md'), path.join(repository, 'new name with spaces.md'));
runGit(repository, ['add', '-A']);
runGit(repository, ['commit', '-m', 'rename']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
const renameEntry = files.find((file) => file.status === 'R');
expect(renameEntry).toBeDefined();
expect(renameEntry.path).toBe('new name with spaces.md');
expect(files.some((file) => file.path === 'old name with spaces.md')).toBe(false);
});
it('reports the destination path for copied files', async () => {
const { repository } = createRepositoryWithRemote();
// The source must exist in the base. Copy detection needs the repository's
// own `diff.renames=copies` setting on top of the service's -C flag; the
// parser must survive whatever C entries git emits.
runGit(repository, ['config', 'diff.renames', 'copies']);
fs.writeFileSync(path.join(repository, 'copied source.md'), '# Copy me\n');
runGit(repository, ['add', 'copied source.md']);
runGit(repository, ['commit', '-m', 'add source']);
runGit(repository, ['push', 'origin', 'HEAD:react']);
fs.copyFileSync(path.join(repository, 'copied source.md'), path.join(repository, 'copied destination.md'));
runGit(repository, ['add', '-A']);
runGit(repository, ['commit', '-m', 'copy']);
const files = await getRangeFiles(repository, { base: 'react', head: 'next' });
const copyEntry = files.find((file) => file.status === 'C');
expect(copyEntry).toBeDefined();
expect(copyEntry.path).toBe('copied destination.md');
});
});
@@ -207,7 +207,8 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn
- `readSettingsFromDiskMigrated()`
- `writeSettingsToDisk(settings)`
- `persistSettings(changes)`
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
- Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`.
## Public exports (settings-helpers.js)
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -9,6 +9,11 @@ import {
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
const CUSTOM_PROVIDER_NPM_PACKAGES = new Set([
OPENAI_COMPATIBLE_NPM,
'@ai-sdk/openai',
'@ai-sdk/anthropic',
]);
function getProviderSources(providerId, workingDirectory) {
const layers = readConfigLayers(workingDirectory);
@@ -42,7 +47,7 @@ function getProviderSources(providerId, workingDirectory) {
}
/**
* Validate a custom OpenAI-compatible provider config payload before persistence.
* Validate a custom provider config payload before persistence.
* Returns { ok: true, value } or { ok: false, error }.
*
* Credentials: either config.env contains a variable name, or hasStoredAuth is true
@@ -63,8 +68,8 @@ function validateCustomProviderConfig(providerId, config, options = {}) {
}
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
if (npm !== OPENAI_COMPATIBLE_NPM) {
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
if (!CUSTOM_PROVIDER_NPM_PACKAGES.has(npm)) {
return { ok: false, error: 'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic' };
}
const optionsBlock = isPlainObject(config.options) ? config.options : null;
@@ -102,7 +107,7 @@ function validateCustomProviderConfig(providerId, config, options = {}) {
}
const normalized = {
npm: OPENAI_COMPATIBLE_NPM,
npm,
name,
options: {
baseURL,
@@ -71,6 +71,33 @@ describe('custom provider config persistence', () => {
}).ok).toBe(true);
});
test('accepts the OpenCode Responses and Anthropic adapter packages', () => {
for (const npm of ['@ai-sdk/openai', '@ai-sdk/anthropic']) {
const result = validateCustomProviderConfig('ok', {
name: 'X',
npm,
env: ['MY_KEY'],
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
});
expect(result.ok).toBe(true);
expect(result.value.config.npm).toBe(npm);
}
});
test('rejects unsupported adapter packages', () => {
const result = validateCustomProviderConfig('ok', {
name: 'X',
npm: '@example/unsupported',
env: ['MY_KEY'],
options: { baseURL: 'https://api.example.com/v1' },
models: { m: { name: 'M' } },
});
expect(result.ok).toBe(false);
expect(result.error).toContain('@ai-sdk/openai');
});
test('upsertProviderConfig writes and round-trips project config', () => {
const result = upsertProviderConfig('campus-llm', {
name: 'Campus LLM',
@@ -30,6 +30,9 @@ export const createSettingsHelpers = (dependencies) => {
const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']);
const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']);
const TERMINAL_SHELL_VALUES = new Set(['auto', 'bash', 'zsh', 'sh', 'fish', 'pwsh', 'powershell', 'cmd', 'dash', 'ksh', 'nu']);
const SIDEBAR_PROJECT_DISPLAY_MODE_VALUES = new Set(['all', 'single']);
const SIDEBAR_SESSION_GROUPING_MODE_VALUES = new Set(['by-worktree', 'flat']);
const SIDEBAR_PROJECT_SORT_ORDER_VALUES = new Set(['manual', 'a-z', 'z-a', 'date-added', 'recent']);
const HIDDEN_MODELS_MAX = 1024;
const RECENT_EFFORTS_MAX_KEYS = 128;
const RECENT_EFFORTS_MAX_VARIANTS_PER_KEY = 5;
@@ -244,6 +247,18 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.activeProjectId === 'string' && candidate.activeProjectId.length > 0) {
result.activeProjectId = candidate.activeProjectId;
}
if (SIDEBAR_PROJECT_DISPLAY_MODE_VALUES.has(candidate.sidebarProjectDisplayMode)) {
result.sidebarProjectDisplayMode = candidate.sidebarProjectDisplayMode;
}
if (SIDEBAR_SESSION_GROUPING_MODE_VALUES.has(candidate.sidebarSessionGroupingMode)) {
result.sidebarSessionGroupingMode = candidate.sidebarSessionGroupingMode;
}
if (SIDEBAR_PROJECT_SORT_ORDER_VALUES.has(candidate.sidebarProjectSortOrder)) {
result.sidebarProjectSortOrder = candidate.sidebarProjectSortOrder;
}
if (typeof candidate.sidebarShowRecentSection === 'boolean') {
result.sidebarShowRecentSection = candidate.sidebarShowRecentSection;
}
if (Array.isArray(candidate.securityScopedBookmarks)) {
result.securityScopedBookmarks = normalizeStringArray(candidate.securityScopedBookmarks);
@@ -66,6 +66,28 @@ describe('settings helpers', () => {
expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({});
});
it('sanitizes shared sidebar display preferences', () => {
const helpers = createTestHelpers();
expect(helpers.sanitizeSettingsUpdate({
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'z-a',
sidebarShowRecentSection: false,
})).toEqual({
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'z-a',
sidebarShowRecentSection: false,
});
expect(helpers.sanitizeSettingsUpdate({
sidebarProjectDisplayMode: 'grid',
sidebarSessionGroupingMode: 'project',
sidebarProjectSortOrder: 'random',
sidebarShowRecentSection: 'false',
})).toEqual({});
});
it('accepts only booleans for wide chat layout', () => {
const helpers = createTestHelpers();
@@ -155,6 +155,7 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
const iconBackground = normalizeIconBackground(candidate.iconBackground);
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
const defaultModel = typeof candidate.defaultModel === 'string' ? candidate.defaultModel.trim() : '';
const defaultVariant = typeof candidate.defaultVariant === 'string' ? candidate.defaultVariant.trim() : '';
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
? Number(candidate.lastOpenedAt)
@@ -175,6 +176,8 @@ export const createSettingsNormalizationRuntime = (dependencies) => {
...(iconBackground ? { iconBackground } : {}),
...(color ? { color } : {}),
...(defaultModel && defaultModel.includes('/') ? { defaultModel } : {}),
// A variant is meaningless without the model it belongs to.
...(defaultModel && defaultModel.includes('/') && defaultVariant ? { defaultVariant } : {}),
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
};
@@ -106,6 +106,22 @@ describe('settings normalization runtime - symlink resolution', () => {
expect(result[0].path).toBe('/resolved/missing/path');
});
it('keeps a default thinking level next to its model and drops it alone', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p,
path: { resolve: (p) => p, sep: '/', dirname: (p) => p.split('/').slice(0, -1).join('/') || '/' },
});
const projects = [
{ id: 'proj1', path: '/a', defaultModel: 'anthropic/claude-opus-5', defaultVariant: 'high' },
{ id: 'proj2', path: '/b', defaultVariant: 'high' },
];
const result = runtime.sanitizeProjects(projects);
expect(result[0].defaultVariant).toBe('high');
expect(result[1].defaultVariant).toBe(undefined);
});
it('deduplicates projects that resolve to the same realpath', () => {
const runtime = createTestRuntime({
realpathSync: (p) => p.startsWith('/symlink') ? '/real/project' : p,
@@ -547,25 +547,41 @@ export const createSettingsRuntime = (deps) => {
// briefly opens the target file. Preserve atomic rename everywhere it works,
// but fall back to a direct replacement so settings persistence does not
// get permanently wedged on Windows desktop installs.
await fsPromises.copyFile(tmp, target);
await fsPromises.rm(tmp, { force: true });
try {
await fsPromises.copyFile(tmp, target);
} finally {
await fsPromises.rm(tmp, { force: true }).catch(() => {});
}
};
const cleanupOrphanedSettingsTempFiles = async (directory) => {
try {
const entries = await fsPromises.readdir(directory, { withFileTypes: true });
const cleanupTasks = entries
.filter((entry) => entry.isFile() && entry.name.startsWith('settings.json.tmp-'))
.map((entry) => fsPromises.rm(path.join(directory, entry.name), { force: true }).catch(() => {}));
await Promise.all(cleanupTasks);
} catch {
// Best-effort cleanup: errors reading directory must not fail settings operations
}
};
const writeSettingsToDisk = async (settings) => {
const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
// Atomic write: Electron main and ssh-manager read this file via plain
// readFile + JSON.parse and silently coerce parse errors to {}. A
// partial read during a non-atomic writeFile would make their next
// read-modify-write wipe the settings file.
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
const settingsDirectory = path.dirname(SETTINGS_FILE_PATH);
await fsPromises.mkdir(settingsDirectory, { recursive: true, mode: 0o700 });
if (process.platform !== 'win32') await fsPromises.chmod(settingsDirectory, 0o700);
// Atomic write: Electron main and ssh-manager read this file via plain
// readFile + JSON.parse and silently coerce parse errors to {}. A
// partial read during a non-atomic writeFile would make their next
// read-modify-write wipe the settings file.
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), { encoding: 'utf8', mode: 0o600 });
if (process.platform !== 'win32') await fsPromises.chmod(tmp, 0o600);
await replaceFile(tmp, SETTINGS_FILE_PATH);
if (process.platform !== 'win32') await fsPromises.chmod(SETTINGS_FILE_PATH, 0o600);
} catch (error) {
await fsPromises.rm(tmp, { force: true }).catch(() => {});
console.warn('Failed to write settings file:', error);
throw error;
}
@@ -854,7 +870,13 @@ export const createSettingsRuntime = (deps) => {
return { settings: next, changed: true };
};
let hasCleanedOrphanedTempFiles = false;
const readSettingsFromDiskMigrated = async () => {
if (!hasCleanedOrphanedTempFiles) {
hasCleanedOrphanedTempFiles = true;
await cleanupOrphanedSettingsTempFiles(path.dirname(SETTINGS_FILE_PATH));
}
const current = await readSettingsFromDisk();
const migration1 = await migrateSettingsFromLegacyLastDirectory(current);
const migration2 = await migrateSettingsFromLegacyThemePreferences(migration1.settings);
@@ -39,6 +39,24 @@ const createRuntime = async () => {
};
describe('settings runtime', () => {
it('round-trips shared sidebar preferences through settings.json', async () => {
const { runtime, settingsFilePath, cleanup } = await createRuntime();
const preferences = {
sidebarProjectDisplayMode: 'single',
sidebarSessionGroupingMode: 'flat',
sidebarProjectSortOrder: 'date-added',
sidebarShowRecentSection: false,
};
try {
await runtime.persistSettings(preferences);
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify(preferences, null, 2));
} finally {
await cleanup();
}
});
it.skipIf(process.platform === 'win32')('writes settings with restrictive directory and file permissions', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
@@ -133,4 +151,71 @@ describe('settings runtime', () => {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
try {
const settingsDir = path.dirname(settingsFilePath);
const orphan1 = path.join(settingsDir, 'settings.json.tmp-1234-11111-abc');
const orphan2 = path.join(settingsDir, 'settings.json.tmp-5678-22222-def');
const unrelated = path.join(settingsDir, 'other-file.json');
await fsPromises.writeFile(orphan1, '{"broken": true}', 'utf8');
await fsPromises.writeFile(orphan2, '{"broken": true}', 'utf8');
await fsPromises.writeFile(unrelated, '{"keep": true}', 'utf8');
await fsPromises.writeFile(settingsFilePath, '{"theme": "light"}', 'utf8');
await runtime.readSettingsFromDiskMigrated();
const files = await fsPromises.readdir(settingsDir);
expect(files).toContain('settings.json');
expect(files).toContain('other-file.json');
expect(files).not.toContain('settings.json.tmp-1234-11111-abc');
expect(files).not.toContain('settings.json.tmp-5678-22222-def');
} finally {
await cleanup();
}
});
it('removes temp file when writeSettingsToDisk encounters a write error', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
const settingsFilePath = path.join(tempRoot, 'settings.json');
let capturedTmp = null;
const wrappedFs = {
...fsPromises,
rename: async (src, dst) => {
capturedTmp = src;
const error = new Error('unexpected disk failure');
error.code = 'EIO';
throw error;
},
};
const runtime = createSettingsRuntime({
fsPromises: wrappedFs,
path,
crypto,
SETTINGS_FILE_PATH: settingsFilePath,
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
sanitizeSettingsUpdate: (settings) => settings,
mergePersistedSettings: (_current, changes) => changes,
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
formatSettingsResponse: (settings) => settings,
resolveDirectoryCandidate: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: (value) => value,
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
syncManagedRemoteTunnelConfigWithPresets: async () => {},
upsertManagedRemoteTunnelToken: async () => {},
});
try {
await expect(runtime.writeSettingsToDisk({ theme: 'dark' })).rejects.toThrow('unexpected disk failure');
expect(capturedTmp).toBeTruthy();
const files = await fsPromises.readdir(tempRoot);
expect(files.some((f) => f.startsWith('settings.json.tmp-'))).toBe(false);
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
});
@@ -3,6 +3,8 @@
Server-owned storage for the Project Notes surface: free-form notes, todos, and
plan markdown files.
The managed Chats root (`~/.config/openchamber/chats`) is also one context owner. Every dated per-session directory beneath it resolves to that root, so Notes, Todo, Plans, pinned knowledge, and project memory are shared across ordinary chats without registering Chats as a user project.
## Ownership
| Path | Owner | Contents |
@@ -231,8 +231,13 @@ export const createProjectContextRuntime = (deps) => {
const writeJsonAtomic = async (filePath, value) => {
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
try {
await fsPromises.writeFile(temporaryPath, JSON.stringify(value, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
} catch (error) {
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
};
const withWriteLock = async (projectId, mutate) => {
@@ -536,8 +536,13 @@ export const createProjectConfigRuntime = (deps) => {
};
await fsPromises.mkdir(parentDirectory, { recursive: true });
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
try {
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
await fsPromises.rename(temporaryPath, filePath);
} catch (error) {
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
};
const withProjectWriteLock = async (projectID, mutate) => {
@@ -101,4 +101,4 @@ The provider computes `usedPercent` from whichever of `used`/`remaining` is pres
- Keep provider IDs stable; clients use them directly.
- Avoid adding alias-based dispatch in `fetchQuotaForProvider`; dispatch currently expects exact provider IDs.
- Keep Google behavior changes isolated and review `providers/google/*` together.
- Z.ai Coding Plan exposes separate 5-hour and weekly `TOKENS_LIMIT` entries plus a monthly `TIME_LIMIT` for MCP tools; web and VS Code must preserve all three windows.
- Z.ai Coding Plan exposes separate 5-hour and weekly token/credit limit entries plus a monthly `TIME_LIMIT` for MCP tools. The API renamed the limit type from `TOKENS_LIMIT` to `CREDIT_LIMIT` (same `unit`/`number` window semantics); `CREDIT_LIMIT` entries additionally carry `usage` (total), `currentValue` (consumed), and `remaining`, surfaced as a credit `valueLabel`, and the payload's `data.level` becomes `planLabel`. Web and VS Code must preserve these windows and stay in sync.
@@ -81,7 +81,7 @@ const fetchQuotaUncoalesced = async () => {
if (Date.now() < cooldownUntil) {
return cachedResultFor(fingerprint, credential.planLabel)
?? failure('Rate limited by Anthropic. Retrying shortly.');
?? failure('Rate limited. Retrying soon.');
}
let response;
@@ -100,7 +100,7 @@ const fetchQuotaUncoalesced = async () => {
if (response.status === 429) {
cooldownUntil = Date.now() + cooldownFromHeader(response);
return cachedResultFor(fingerprint, credential.planLabel)
?? failure('Rate limited by Anthropic. Retrying shortly.');
?? failure('Rate limited. Retrying soon.');
}
if (response.status === 401 || response.status === 403) {
@@ -3,7 +3,7 @@ import { asObject, buildResult, getAuthEntry, normalizeAuthEntry, toNumber, toUs
export const providerId = 'command-code';
export const providerName = 'Command Code';
export const aliases = ['command-code'];
export const aliases = ['command-code', 'commandcode', 'command_code', 'command code'];
const API_BASE_URL = 'https://api.commandcode.ai';
@@ -69,4 +69,17 @@ describe('Command Code quota provider', () => {
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-token');
vi.unstubAllGlobals();
});
it('recognizes Command Code auth entries under supported provider ID variants', async () => {
for (const providerId of ['commandcode', 'command_code', 'command code']) {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } })))
.mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload)));
vi.stubGlobal('fetch', fetchMock);
const result = await fetchQuota({ [providerId]: { type: 'oauth', access: 'test-token' } });
expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true });
vi.unstubAllGlobals();
}
});
});
@@ -160,6 +160,13 @@ const registry = {
const pendingFetches = new Map();
const normalizeQuotaProviderId = (providerId) => {
if (typeof providerId !== 'string') return providerId;
return ['command-code', 'commandcode', 'command_code', 'command code'].includes(providerId.trim().toLowerCase())
? 'command-code'
: providerId;
};
export const listConfiguredQuotaProviders = () => {
const configured = [];
@@ -203,13 +210,14 @@ const fetchQuotaForProviderUncoalesced = async (providerId) => {
};
export const fetchQuotaForProvider = (providerId) => {
const existing = pendingFetches.get(providerId);
const normalizedProviderId = normalizeQuotaProviderId(providerId);
const existing = pendingFetches.get(normalizedProviderId);
if (existing) return existing;
const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => {
if (pendingFetches.get(providerId) === pending) pendingFetches.delete(providerId);
const pending = fetchQuotaForProviderUncoalesced(normalizedProviderId).finally(() => {
if (pendingFetches.get(normalizedProviderId) === pending) pendingFetches.delete(normalizedProviderId);
});
pendingFetches.set(providerId, pending);
pendingFetches.set(normalizedProviderId, pending);
return pending;
};
+26 -6
View File
@@ -4,6 +4,7 @@ import {
normalizeAuthEntry,
buildResult,
toUsageWindow,
toNumber,
resolveWindowSeconds,
resolveWindowLabel,
normalizeTimestamp
@@ -13,6 +14,20 @@ export const providerId = 'zai-coding-plan';
export const providerName = 'z.ai';
const aliases = ['zai-coding-plan', 'zai', 'z.ai'];
// CREDIT_LIMIT entries carry `usage` (total credits), `currentValue` (consumed),
// and `remaining`; TOKENS_LIMIT entries only carry a percentage.
const formatCreditAmount = (value) => {
if (value < 1000) return value.toLocaleString('en-US');
return `${Math.round(value / 100) / 10}k`;
};
const formatCreditValueLabel = (limit) => {
const used = toNumber(limit?.currentValue);
const total = toNumber(limit?.usage);
if (used === null || total === null) return null;
return `${formatCreditAmount(used)} / ${formatCreditAmount(total)} credits`;
};
export const isConfigured = () => {
const auth = readAuthFile();
const entry = normalizeAuthEntry(getAuthEntry(auth, aliases));
@@ -56,16 +71,20 @@ export const fetchQuota = async () => {
const payload = await response.json();
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
const windows = {};
for (const tokensLimit of limits.filter((limit) => limit?.type === 'TOKENS_LIMIT')) {
const windowSeconds = resolveWindowSeconds(tokensLimit);
// The API renamed TOKENS_LIMIT to CREDIT_LIMIT; field semantics stayed the same,
// so both limit types map to the same windows.
for (const limit of limits.filter((entry) => entry?.type === 'TOKENS_LIMIT' || entry?.type === 'CREDIT_LIMIT')) {
const windowSeconds = resolveWindowSeconds(limit);
const windowLabel = resolveWindowLabel(windowSeconds);
const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null;
const resetAt = limit?.nextResetTime ? normalizeTimestamp(limit.nextResetTime) : null;
const usedPercent = typeof limit?.percentage === 'number' ? limit.percentage : null;
const creditValueLabel = formatCreditValueLabel(limit);
windows[windowLabel] = toUsageWindow({
usedPercent,
windowSeconds,
resetAt
resetAt,
valueLabel: creditValueLabel
});
}
@@ -83,7 +102,8 @@ export const fetchQuota = async () => {
providerName,
ok: true,
configured: true,
usage: { windows }
usage: { windows },
planLabel: typeof payload?.data?.level === 'string' && payload.data.level ? payload.data.level : null
});
} catch (error) {
return buildResult({
@@ -51,4 +51,37 @@ describe('Z.ai quota provider', () => {
resetAt: 1787128459979,
});
});
it('maps CREDIT_LIMIT entries to windows with credit value labels and plan level', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({
code: 200,
data: {
limits: [
{ type: 'CREDIT_LIMIT', unit: 3, number: 5, usage: 12000, currentValue: 65, remaining: 11934, percentage: 1, nextResetTime: 1787257978907 },
{ type: 'CREDIT_LIMIT', unit: 6, number: 1, usage: 60000, currentValue: 65, remaining: 59934, percentage: 1, nextResetTime: 1787844668997 },
],
level: 'pro',
},
})));
const result = await fetchQuota();
const windows = result.usage.windows;
expect(result.ok).toBe(true);
expect(result.planLabel).toBe('pro');
expect(windows['5h']).toMatchObject({
usedPercent: 1,
remainingPercent: 99,
windowSeconds: 5 * 60 * 60,
resetAt: 1787257978907,
valueLabel: '65 / 12k credits',
});
expect(windows.weekly).toMatchObject({
usedPercent: 1,
remainingPercent: 99,
windowSeconds: 7 * 24 * 60 * 60,
resetAt: 1787844668997,
valueLabel: '65 / 60k credits',
});
});
});
@@ -24,6 +24,8 @@ attached to that session. Pins never come from project-wide note or plan state.
A new-session draft passes its pins into this metadata when its first message
creates the session.
Directories beneath the managed `~/.config/openchamber/chats` root resolve to that root before project context and project memory are read. Every ordinary chat therefore shares one Chats knowledge owner instead of creating an unreachable context store for each dated session directory.
`session.metadata.openchamber.knowledge_context_delivered` holds the signature
of what the session is carrying. It lives with the session, so it survives the
tab closing and is visible to every sender, including the ones with no tab.