Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
version: 1
delivery: issue389
delivery: memory-topic-creation
context:
kind: branch
branch: feat/todo-step-reminders
branch: fix/395-memory-topic-creation
issues:
- 389
pr: 394
- 395
- 396
- 397
pr: 398
35 changes: 32 additions & 3 deletions packages/opencode/src/memory/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export interface Interface {
* project passes every activation gate. Surface this wherever a silent
* "remains off" would leave the user guessing (e.g. /memory on). */
readonly statusReason: () => Effect.Effect<string | undefined>
/** Truthful one-line state for /memory status surfaces: the statusReason
* blocker when a gate (identity, init, model availability) holds Memory
* inert, else the actual on/off state. */
readonly status: () => Effect.Effect<string>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Memory") {}
Expand Down Expand Up @@ -774,7 +778,9 @@ export const layer: Layer.Layer<
// #350: the why-is-Memory-inert companion of configuration()'s fail-closed
// gates. Mirrors their order; only the gates a user can act on produce a
// reason (identity retirement and admission repair stay log-only — they
// are operator concerns, not /memory on guidance).
// are operator concerns, not /memory on guidance). #397: an enabled
// config whose model no longer resolves is equally inert — active() gates
// on resolveModel() — so it gets its own actionable reason.
const statusReason = Effect.fn("Memory.statusReason")(function* () {
const ctx = yield* InstanceState.context
const current = yield* project.get(ctx.project.id)
Expand All @@ -784,9 +790,29 @@ export const layer: Layer.Layer<
if (current.vcs !== "git") return "Memory requires a git repository."
if (!current.time.initialized)
return "Memory is unavailable until the project is initialized — run /init first, then /memory on."
// An unreadable config/store answers "cannot determine" rather than
// failing the status surface.
const optioned = yield* Effect.option(configuration())
const loaded = Option.isSome(optioned) ? optioned.value?.loaded : undefined
if (loaded?.config.enabled) {
// resolveModel answers undefined (not a failure) when the model is
// absent from the provider list; Effect.option only catches the
// torn-read ModelNotFoundError edge — both mean unavailable here.
const model = yield* Effect.option(resolveModel(loaded.config))
if (!Option.isSome(model) || model.value === undefined)
return "Memory is enabled but its configured model is unavailable — run /memory on to reselect a replacement, or set `model` in .opencode/memory.jsonc to an installed provider/model."
}
return undefined
})

const status: Interface["status"] = Effect.fn("Memory.status")(function* () {
const reason = yield* statusReason()
if (reason) return reason
const optioned = yield* Effect.option(configuration())
const loaded = Option.isSome(optioned) ? optioned.value?.loaded : undefined
return loaded?.config.enabled ? "Memory on" : "Memory remains off"
})

const setEnabledUnsafe = Effect.fn("Memory.setEnabledUnsafe")(function* (enabled: boolean) {
const initial = yield* configuration()
if (!initial) {
Expand Down Expand Up @@ -827,13 +853,16 @@ export const layer: Layer.Layer<
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logWarning("MEMORY command failed", { cause })
return "Memory remains off"
// #397: a failure that statusReason can explain (e.g. no
// installed model to reselect) surfaces the actionable reason
// instead of a bare "remains off".
return (yield* statusReason()) ?? "Memory remains off"
}),
),
),
)

return Service.of({ init, prepare, context, search, checkpoint, setEnabled, statusReason })
return Service.of({ init, prepare, context, search, checkpoint, setEnabled, statusReason, status })
}),
)

Expand Down
72 changes: 61 additions & 11 deletions packages/opencode/src/memory/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { Context, Duration, Effect, Layer, Schema } from "effect"
import { streamObject } from "ai"
import { Provider } from "@/provider/provider"

// Liveness is judged per-chunk, never by a whole-call wall clock: a stream
// that keeps delivering parts is alive, however long the reasoning runs.
// CONNECT_TIMEOUT bounds the wait for the FIRST part; IDLE_TIMEOUT bounds the
// silence BETWEEN parts and is re-armed by every arriving part. Generation
// still terminates on its own via max_output_tokens / a natural stop.
// Liveness is judged per-part, never by a whole-call wall clock: a stream
// that keeps delivering parts is alive, however long the call runs. Two
// caveats: streamObject's fullStream DROPS reasoning-only parts (the ai SDK
// forwards text-delta/finish/error only), so a model that reasons silently
// past these windows still trips the timers — and CONNECT_TIMEOUT bounds the
// wait for the FIRST part while IDLE_TIMEOUT bounds the silence BETWEEN
// parts, re-armed by every arriving part.
const CONNECT_TIMEOUT = Duration.seconds(60)
const IDLE_TIMEOUT = Duration.seconds(60)

Expand Down Expand Up @@ -37,7 +39,10 @@ export class GenerateError extends Schema.TaggedErrorClass<GenerateError>()("Mem
cause: Schema.Defect(),
}) {
override get message() {
return `MEMORY model call failed: ${String(this.cause)}`
// openai-compatible flattens a provider SSE error event to its bare
// message string, which can be empty — keep the failure identifiable.
const cause = String(this.cause)
return `MEMORY model call failed: ${cause === "" ? "(provider stream error with an empty message)" : cause}`
}
}

Expand Down Expand Up @@ -76,8 +81,9 @@ function requireJsonToken(request: Request): Request {
// Signals that the stream went silent past the liveness window.
export class Stalled extends Error {}

// Drains `parts`, re-arming the idle watchdog on EVERY part (so a live stream
// that keeps delivering — reasoning deltas included — never trips the timer).
// Drains `parts`, re-arming the idle watchdog on every part the consumer
// sees. NOTE: for streamObject that excludes reasoning-only parts (they are
// filtered out upstream), so silent reasoning does NOT count as liveness.
// Arms `connectTimeout` until the first part and `idleTimeout` between parts;
// a silent window invokes `onStall` (abort the request) and fails with
// `Stalled`, while an `errorOf` hit fails with that part's error.
Expand Down Expand Up @@ -123,6 +129,48 @@ export const drainWithLiveness = <T>(input: {
})()
})

// Providers without structured-outputs support (every openai-compatible model
// today) downgrade response_format to bare {"type":"json_object"} and never
// see the schema passed to streamObject — the model then free-styles a
// different shape every call and client-side validation always rejects
// (issue #395). The schema therefore rides in the system prompt: the draft-07
// document with every $ref inlined, since "#/definitions/..." pointers are
// meaningless to the model. Optional fields arrive as anyOf [T, null]; the
// null arm is dropped so the schema reads "provide T or omit the key",
// matching what the decoder actually accepts.
function jsonSchemaText(schema: Schema.Decoder<unknown>) {
const root = Schema.toStandardJSONSchemaV1(schema)["~standard"].jsonSchema.input({ target: "draft-07" })
const defs = { ...recordOf(root.definitions), ...recordOf(root.$defs) }
const walk = (node: unknown, refs: ReadonlySet<string>): unknown => {
if (Array.isArray(node)) return node.map((item) => walk(item, refs))
if (!isRecord(node)) return node
const ref = typeof node.$ref === "string" ? /^#\/(?:\$defs|definitions)\/(.+)$/.exec(node.$ref)?.[1] : undefined
if (ref !== undefined) {
if (refs.has(ref)) return {}
return walk(defs[ref] ?? {}, new Set([...refs, ref]))
}
if (Array.isArray(node.anyOf) && Object.keys(node).length === 1) {
const kept = node.anyOf.filter(isRecord).filter((arm) => arm.type !== "null")
if (kept.length === 1) return walk(kept[0], refs)
}
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(node)) {
if (key === "definitions" || key === "$defs" || key === "$id" || key === "$schema") continue
out[key] = walk(value, refs)
}
return out
}
return JSON.stringify(walk(root, new Set()))
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

function recordOf(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {}
}

const streamGenerate = (input: {
language: Parameters<typeof streamObject>[0]["model"]
system: string
Expand All @@ -132,8 +180,9 @@ const streamGenerate = (input: {
maxOutputTokens: number
connectTimeout: Duration.Duration
idleTimeout: Duration.Duration
}) =>
Effect.tryPromise({
}): Effect.Effect<unknown, TimeoutError | GenerateError> => {
const system = `${input.system}\n\nThe response must be a single JSON object that validates against this JSON Schema:\n${jsonSchemaText(input.schema)}`
return Effect.tryPromise({
try: (signal) =>
(async () => {
const controller = new AbortController()
Expand All @@ -142,7 +191,7 @@ const streamGenerate = (input: {
try {
const result = streamObject({
model: input.language,
system: input.system,
system,
prompt: input.prompt,
schema: Object.assign(
Schema.toStandardSchemaV1(input.schema),
Expand All @@ -167,6 +216,7 @@ const streamGenerate = (input: {
})(),
catch: (cause) => (cause instanceof Stalled ? new TimeoutError() : new GenerateError({ cause })),
})
}

export const layer = Layer.effect(
Service,
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1887,12 +1887,14 @@ export const layer = Layer.effect(
if (input.command === "memory") {
const memory = Option.getOrUndefined(yield* Effect.serviceOption(Memory.Service))
const argument = input.arguments.trim()
// #396: anything that is not an exact on/off is a status query —
// report the true state instead of a hardcoded "remains off".
const result = memory
? argument === "on"
? yield* memory.setEnabled(true)
: argument === "off"
? yield* memory.setEnabled(false)
: "Memory remains off"
: yield* memory.status()
: "Memory remains off"
const model = yield* currentModel(input.sessionID)
const agentName = input.agent ?? (yield* agents.defaultAgent())
Expand Down
107 changes: 107 additions & 0 deletions packages/opencode/test/memory/memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2128,6 +2128,113 @@ describe("memory enablement", () => {
)
})

function statusFixture() {
const replacement = ProviderTest.model({
providerID: ProviderV2.ID.make("test"),
id: ModelV2.ID.make("replacement"),
})
const state: { config: MemorySchema.Config; available: boolean } = {
config: { ...config, enabled: true, model: "removed/model" },
available: true,
}
const providerLayer = Layer.mock(Provider.Service, {
list: () =>
Effect.succeed(
state.available
? { [replacement.providerID]: ProviderTest.info({ id: replacement.providerID, models: { [replacement.id]: replacement } }) }
: {},
),
getModel: (providerID, modelID) =>
Effect.succeed(
ProviderTest.model({
providerID,
id: modelID,
}),
),
})
const layer = Memory.layer.pipe(
Layer.provide(
Layer.mergeAll(
emptyConfigLayer,
EffectFlock.defaultLayer,
MemoryHome.defaultLayer,
MemoryIdentityFence.defaultLayer,
providerLayer,
Layer.mock(Project.Service, {
get: (id) =>
Effect.succeed({
id,
worktree: "/unused",
vcs: "git" as const,
time: { created: 0, updated: 0, initialized: 1 },
sandboxes: [],
}),
}),
Layer.mock(MemoryConfig.Service, {
load: (directory) =>
Effect.succeed({ config: state.config, path: directory, level: "project" as const }),
loadGlobal: () => Effect.succeed(undefined),
writeGlobal: () => Effect.succeed(true),
writeProject: () => Effect.void,
}),
readyAdmissionLayer,
MemoryLock.defaultLayer,
Layer.mock(MemoryModel.Service, {
generate: () => Effect.die(new Error("status surfaces must not call a model")),
}),
Layer.mock(MemoryStore.Service, {
readTopics: () => Effect.succeed([]),
}),
),
),
)
return { state, it: testEffect(layer) }
}

describe("memory status truthfulness (issues #396 #397)", () => {
const status = statusFixture()

status.it.instance(
"reports why an enabled config is inert when its model is gone",
() =>
Effect.gen(function* () {
const memory = yield* Memory.Service
const reason = yield* memory.statusReason()
if (reason === undefined) return yield* Effect.fail(new Error("expected a model-unavailability reason"))
expect(reason).toContain("model is unavailable")
expect(yield* memory.status()).toBe(reason)
expect(yield* memory.setEnabled(true)).toContain("model is unavailable")
}),
{ git: true },
)

status.it.instance(
"reports the true on/off state once the model resolves",
() =>
Effect.gen(function* () {
const memory = yield* Memory.Service
status.state.config = { ...config, enabled: true, model: "test/replacement" }
expect(yield* memory.statusReason()).toBeUndefined()
expect(yield* memory.status()).toBe("Memory on")
status.state.config = { ...config, enabled: false, model: "test/replacement" }
expect(yield* memory.status()).toBe("Memory remains off")
}),
{ git: true },
)

status.it.instance(
"surfaces the model reason when /memory on cannot reselect any model",
() =>
Effect.gen(function* () {
status.state.config = { ...config, enabled: true, model: "removed/model" }
status.state.available = false
const memory = yield* Memory.Service
expect(yield* memory.setEnabled(true)).toContain("model is unavailable")
}),
{ git: true },
)
})

function assistant(
parentID: MessageID,
sessionID: SessionID,
Expand Down
Loading
Loading