Skip to content
Open
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
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*/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The export exists to prevent drift, but the two inline copies it was meant to replace are still there

memory/prompt.ts:114 (formatTrainingEntry) and embedTrainingMeta below (line 79) still hardcode the same regex verbatim. If either ever changes (e.g. the comment format gains a field), the mirror's stripTrainingMeta and the prompt's display stripping diverge silently. Have both use TRAINING_META_COMMENT.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When /list returns more than 200 records, list() returns them all despite the documented service behavior, and hydration injects the full response. Cap the normalized records at LIST_LIMIT so large workspaces cannot overwhelm the session context.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/memory-api.ts, line 167:

<comment>When `/list` returns more than 200 records, `list()` returns them all despite the documented service behavior, and hydration injects the full response. Cap the normalized records at `LIST_LIMIT` so large workspaces cannot overwhelm the session context.</comment>

<file context>
@@ -0,0 +1,170 @@
+      },
+    )
+    if (!rows) return []
+    if (Array.isArray(rows)) return rows
+    return Array.isArray(rows.memories) ? rows.memories : []
+  }
</file context>

return Array.isArray(rows.memories) ? rows.memories : []
}
}
41 changes: 41 additions & 0 deletions packages/opencode/src/altimate/workspace/memory-backfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// 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<void> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When altimate link uses this bind path, the seed is not reliable: the caller drops backfillOnBind while the CLI forcibly exits after the handler. Await the backfill or otherwise drain it before shutdown so a successful bind actually seeds existing blocks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/workspace/memory-backfill.ts, line 26:

<comment>When `altimate link` uses this bind path, the seed is not reliable: the caller drops `backfillOnBind` while the CLI forcibly exits after the handler. Await the backfill or otherwise drain it before shutdown so a successful bind actually seeds existing blocks.</comment>

<file context>
@@ -0,0 +1,41 @@
+ * 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<void> {
+  if (!isEnabled()) return
+  try {
</file context>

if (!isEnabled()) return
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
const result = await backfill(blocks, binding)
log.info("workspace memory seeded after bind", result)
} catch (err) {
log.warn("workspace memory backfill after bind failed", { err: String(err) })
}
}
Loading
Loading