Skip to content
Merged
3 changes: 2 additions & 1 deletion packages/opencode/src/altimate/training/store.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// altimate_change - Training store wrapping MemoryStore for learned knowledge
import { MemoryStore, type MemoryBlock } from "../../memory"
import {
TRAINING_META_COMMENT,
TRAINING_TAG,
TRAINING_MAX_PATTERNS_PER_KIND,
TrainingKind,
Expand Down Expand Up @@ -164,5 +165,5 @@ export namespace TrainingStore {
}

function stripTrainingMeta(content: string): string {
return content.replace(/^<!--\s*training\n[\s\S]*?-->\n*/, "").trim()
return content.replace(TRAINING_META_COMMENT, "").trim()
}
8 changes: 7 additions & 1 deletion packages/opencode/src/altimate/training/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
import z from "zod"

export const TRAINING_TAG = "training"

/** Matches the metadata comment ``embedTrainingMeta`` writes at the head of a
* training block's content. Exported so readers that normalise it away — the
* workspace mirror hashes content without it, because the applied counter is
* rewritten every session — cannot drift from the writer. */
export const TRAINING_META_COMMENT = /^<!--\s*training\n[\s\S]*?-->\n*/
export const TRAINING_ID_PREFIX = "training"
// altimate_change start — increase training limits for enterprise teams
// 20 entries per kind is too restrictive for teams with 200+ dbt models spanning
Expand Down Expand Up @@ -70,6 +76,6 @@ export function embedTrainingMeta(content: string, meta: TrainingBlockMeta): str
"-->",
].join("\n")
// Strip existing training meta block if present
const stripped = content.replace(/^<!--\s*training\n[\s\S]*?-->\n*/, "")
const stripped = content.replace(TRAINING_META_COMMENT, "")
return header + "\n" + stripped
}
15 changes: 13 additions & 2 deletions packages/opencode/src/altimate/workspace/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const REQUEST_TIMEOUT_MS = 15_000
export interface DatamateRef {
id: number
name: string
/** Whether the workspace has memory switched on. Surfaced as a user-facing
* toggle in the workspace app, so callers that write memory must respect it.
* Undefined when the backend omitted the field. */
memoryEnabled?: boolean
}

export interface Binding {
Expand Down Expand Up @@ -237,6 +241,13 @@ async function req<T>(
return json as T
}

/** Shared wire helper for sibling Altimate routers. Exported so callers that
* need the same credential resolution, abort budget and typed-error mapping do
* not duplicate any of it — see ./memory-api.ts, which drives
* ``/datamates/memory/*`` through this exact path. Always pass an explicit
* ``base``; the default is this module's own namespace. */
export { req as altimateRequest }

export namespace WorkspaceApi {
/** Server-authoritative pre-check by git remote. Returns null on 404. */
export async function getBindingForRemote(remote: string): Promise<GetBindingResponse | null> {
Expand Down Expand Up @@ -350,7 +361,7 @@ export namespace WorkspaceApi {
// bare ``[...]``, and a generic ``{data: [...]}`` — so a backend
// contract change (or compat layer) doesn't silently empty the picker.
// (cubic-dev-ai round 3.)
type Row = { id: number | string; name: string }
type Row = { id: number | string; name: string; memory_enabled?: boolean }
const body = await req<Row[] | { datamates?: Row[]; data?: Row[] }>("GET", "/", {
base: "/datamates",
})
Expand Down Expand Up @@ -378,7 +389,7 @@ export namespace WorkspaceApi {
// per-element rather than per-envelope malformed value.
return rows
.filter((d): d is Row => d !== null && typeof d === "object")
.map((d) => ({ id: Number(d.id), name: d.name }))
.map((d) => ({ id: Number(d.id), name: d.name, memoryEnabled: d.memory_enabled }))
.filter((d) => Number.isInteger(d.id) && d.id > 0 && typeof d.name === "string")
}
}
177 changes: 177 additions & 0 deletions packages/opencode/src/altimate/workspace/memory-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// altimate_change - new file
//
// Wire client for the memory routes in altimate-backend (/datamates/memory/*).
// Those routes proxy to a Lambda which runs an LLM extractor before storing, so
// the behaviour here is shaped by two facts established against the live
// service rather than inferred:
//
// * A create runs the extractor. It rewrites the submitted text and
// overwrites the ``memory_type`` and ``title`` metadata keys, and it can
// decline the content entirely and store nothing while still answering 200.
// ``update`` does NOT run the extractor — it writes text and metadata
// verbatim. Every create is therefore followed by an update that restores
// the block exactly as the user wrote it.
// * ``extracted`` is not a stored/not-stored signal. It is false both when
// the extractor declined (no ``result``) and when the extractor errored and
// the raw messages were stored as a fallback (``result`` present, content
// stored). Presence of ``result`` is the only reliable signal, which is why
// ``add`` reports an id rather than a boolean.
//
// Records are tagged with ``metadata.source`` so the backend can keep them out
// of ordinary Datamate reads; this client opts back in explicitly on every read.
import { altimateRequest } from "./api-client"

/** Stamped on every record this CLI writes, and the value the backend filters
* on. Reads must opt in by name or they come back empty. */
export const MIRROR_SOURCE = "altimate-code"

const BASE = "/datamates/memory"

/** Upper bound on records requested per read. The service does not honour
* paging parameters — a repeated request re-runs the identical query — so this
* is a single bounded fetch rather than the first page of several. */
export const LIST_LIMIT = 200

/** A record as returned by ``/list``. */
export interface CloudMemoryRecord {
id: string
memory: string
created_at?: string
updated_at?: string
metadata?: Record<string, unknown> | null
}

/** Metadata written on every mirrored record. Names and types are fixed so
* that lookup and filtering agree across independent call sites. */
export interface MirrorMetadata {
source: typeof MIRROR_SOURCE
block_id: string
block_scope: "global" | "project"
/** Always "private" in v0. Written so a later sharing feature can promote a
* record without a backfill; nothing reads it today. */
visibility: "private"
block_created: string
block_updated: string
/** Absent for global blocks — that is what makes them span workspaces. */
datamate_id?: string
datamate_name?: string
/** Project provenance and the cross-machine convergence key. */
repo_remote?: string
project_path?: string
block_tags?: string
/** ISO-8601. Mirrored so a TTL'd block expires everywhere rather than living
* forever in the workspace once it has left the machine that wrote it. */
block_expires?: string
archived?: "true"
archived_at?: string
}

export function isMirrorRecord(record: CloudMemoryRecord): boolean {
const meta = record.metadata
if (!meta || typeof meta !== "object") return false
return meta.source === MIRROR_SOURCE
}

export function isArchived(record: CloudMemoryRecord): boolean {
return record.metadata?.archived === "true"
}

/** Pull every created record id out of a create response.
*
* A create runs an extractor server-side, and an extractor is free to split one
* submission into several records — each carrying the metadata we sent, so each
* looks like our block. Returning only the first would leave the rest holding
* rewritten text, never repaired, never indexed and never archived, while the
* read path injected all of them under one block id.
*
* The payload shape is not a published contract, so this accepts the observed
* forms rather than guessing: a bare array, or an object wrapping one under
* ``results``/``memories``/``data``. An empty result is the expected outcome
* when the extractor declines the content. */
export function extractRecordIds(result: unknown): string[] {
const rows = (() => {
if (Array.isArray(result)) return result
if (result && typeof result === "object") {
const obj = result as Record<string, unknown>
for (const key of ["results", "memories", "data"]) {
if (Array.isArray(obj[key])) return obj[key] as unknown[]
}
}
return []
})()

const ids: string[] = []
for (const row of rows) {
if (!row || typeof row !== "object") continue
const id = (row as Record<string, unknown>).id ?? (row as Record<string, unknown>).memory_id
if (typeof id === "string" && id) ids.push(id)
}
return ids
}

/** Convenience for callers that only need to know whether anything was stored. */
export function extractRecordId(result: unknown): string | undefined {
return extractRecordIds(result)[0]
}

export namespace MemoryApi {
/** Create a record and report the ids it produced.
*
* Returns an empty array when the service stored nothing. That is not an error —
* the extractor declines content it judges unremarkable — so the caller
* should leave the block unindexed and let a later edit retry, rather than
* treating it as a failure. */
export async function add(content: string, metadata: MirrorMetadata): Promise<string[]> {
const res = await altimateRequest<{ message?: string; result?: unknown }>("POST", "/", {
base: BASE,
allowEmptyBody: true,
body: {
messages: [{ role: "user", content }],
memory_options: { metadata },
},
})
return extractRecordIds(res?.result)
}

/** Overwrite a record verbatim. Does not run the extractor, and replaces the
* metadata dict wholesale, so callers must pass the complete metadata. */
export async function update(
memoryId: string,
content: string,
metadata: MirrorMetadata,
): Promise<void> {
await altimateRequest<{ message?: string }>("PATCH", `/${encodeURIComponent(memoryId)}`, {
base: BASE,
allowEmptyBody: true,
body: { memory: content, metadata },
})
}

/** Read this user's mirrored records.
*
* ``include_sources`` is required: the backend excludes this client's records
* from list/search by default so they do not surface in Datamate sessions.
* No workspace filter is sent — the service's own query for a caller's
* records is not scoped by workspace, so narrowing happens in the caller.
*
* Deliberately NOT capped at ``LIST_LIMIT``. The service ignores paging, so a
* cap here would discard real records before anything could rank them, and
* the user would lose memory with no signal. Session context is already
* bounded downstream: ``MemoryPrompt.inject`` scores every block and appends
* only while it fits the caller's budget. ``LIST_LIMIT`` is used to recognise
* a possibly-cut-short read (see ``fetchKnownRecords``), not to trim one. */
export async function list(): Promise<CloudMemoryRecord[]> {
const rows = await altimateRequest<CloudMemoryRecord[] | { memories?: CloudMemoryRecord[] }>(
"GET",
"/list",
{
base: BASE,
allowEmptyBody: true,
query: { include_sources: MIRROR_SOURCE, page_size: String(LIST_LIMIT) },
},
)
if (!rows) return []
if (Array.isArray(rows)) return rows
return Array.isArray(rows.memories) ? rows.memories : []
}
}
51 changes: 51 additions & 0 deletions packages/opencode/src/altimate/workspace/memory-backfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// altimate_change - new file
//
// Seeds a freshly bound workspace with the memory this machine already holds.
// Without it only blocks written AFTER the bind would ever reach the store, and
// a user's existing memory would stay invisible in the workspace.
//
// Lives in its own module rather than inside ./state.ts because the sweep needs
// MemoryStore, whose write path already reaches ./memory-sync — importing it
// directly from state.ts would close an eval-order cycle
// (state -> backfill -> memory -> store -> memory-sync -> state). state.ts
// reaches this through a lazy dynamic import instead.
import { MemoryStore } from "@/memory/store"
import { Log } from "@/altimate/util/log"
import { backfill, isEnabled } from "./memory-sync"
import type { CachedBinding } from "./state"

const log = Log.create({ service: "altimate-workspace-memory-backfill" })

/** Push every non-expired local block. Throttled and resumable inside
* ``backfill`` — blocks already synced at their current payload are skipped, so
* repeated binds cost index reads rather than uploads.
*
* Covers both scopes: project blocks attach to the workspace just bound, and
* global blocks go up account-level. A bind is the only moment global memory is
* swept; blocks written later ride the ordinary per-write mirror. */
export async function backfillOnBind(directory: string, binding: CachedBinding): Promise<boolean> {
if (!isEnabled()) return false
try {
// The directory and binding are passed in rather than rediscovered. The
// `link` subcommand binds from a plain yargs handler with no instance
// context, so resolving project scope from the ambient instance throws
// there — silently, because this catch turns it into a log line while the
// CLI still prints "Linked". Reading project memory was the entire point.
const blocks = await MemoryStore.listAll({ directory })
if (blocks.length === 0) return true
const result = await backfill(blocks, binding)
log.info("workspace memory seeded after bind", result)
// Only a sweep that stored everything it meant to counts as seeded. A
// failure here must leave the binding eligible for a retry, or local blocks
// stay absent from the workspace until a rebind or an unrelated edit.
// ``declined`` counts too — a block the service explicitly refused (quota,
// permissions) is still absent from the workspace and the binding should
// stay unseeded so a later rebind retries it. Without this a partially-
// rejected backfill left the binding treated as fully seeded. (altimate-
// harness-bot #1116 comment 3840503346.)
return !result.gated && result.failed === 0 && result.declined === 0
} catch (err) {
log.warn("workspace memory backfill after bind failed", { err: String(err) })
return false
}
}
Loading
Loading