Skip to content

Commit bbb749f

Browse files
Память: записи можно забыть из приложения, без модели в цепочке
Память копится сама, а убрать неверную запись человек мог только уговорив агента вызвать local_memory_forget — увидеть, что там лежит, тоже было негде. Секция «Локальная память» в диагностике (рядом с журналом retention): счётчики, последние 20 записей с типом/проектом/доверием/датой и кнопка «Забыть» на каждой. Удаление настоящее, поэтому идёт через диалог с текстом записи. Вызовы идут напрямую в 127.0.0.1:4199/mcp (MemoryTools, JSON-RPC tools/call, bearer из MemoryAuth), разбор конверта — MemoryWire. Модель не участвует: иначе удаление зависело бы от её решения, а отказ памяти (HTTP 200 + isError) выглядел бы как «записей нет». MemoryWireTest — 12 тестов на формы ответов: content[0].text, ошибка JSON-RPC в корне, isError с текстом и без, ответ без result, не-JSON, поля записи, вытеснение по числу и по superseded_by, пустая память, счётчики. Проверено на устройстве дважды: агент создал тестовые записи, «Забыть» → подтверждение → строка исчезла, «Всего» 54 → 53; в базе не осталось ни строки, ни terms, ни рёбер graph. Тестовые записи удалены, память в исходном состоянии (61).
1 parent 5b4fb7b commit bbb749f

7 files changed

Lines changed: 583 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,28 @@ ChatOverlay разобран на четыре файла (25.10.2026)
592592
- Проверено на устройстве: секция `music` в `opencode.jsonc` на месте (`url`
593593
`http://127.0.0.1:4200/mcp`, `timeout` 180000), `mcp connected server=music`.
594594

595+
Память: записи можно забыть из приложения, без модели в цепочке (26.10.2026)
596+
- Память копится сама, а убрать неверную запись человек до сих пор мог только уговорив
597+
агента вызвать `local_memory_forget`. Пока этого не произошло, «память» — это мусор,
598+
который никто не чистит: увидеть, что там лежит, тоже было негде.
599+
- Секция «Локальная память» в диагностике (рядом с журналом retention): счётчики
600+
(`Всего`, `связей`, `вытеснено`, `доверие`), последние 20 записей с типом, проектом,
601+
доверием и датой, кнопка «Забыть» на каждой. Удаление настоящее, поэтому идёт через
602+
диалог с текстом записи — «забыть» рядом с каждой строкой иначе нажимается случайно.
603+
- Вызовы идут напрямую в `127.0.0.1:4199/mcp` (`MemoryTools`, JSON-RPC `tools/call`,
604+
bearer из `MemoryAuth`), разбор конверта — `MemoryWire`. Модель не участвует: иначе
605+
удаление зависело бы от её решения, а отказ памяти («HTTP 200 + `isError`») выглядел
606+
бы как «записей нет».
607+
- `MemoryWireTest` — 12 тестов на формы ответов: конверт `content[0].text`, ошибка
608+
JSON-RPC в корне, `isError` с текстом и без, ответ без `result`, не-JSON, поля записи,
609+
вытеснение по числу и по `superseded_by`, пустая память, счётчики.
610+
- Проверено на устройстве сквозным прогоном (тапы по `uiautomator dump`, дважды):
611+
агент создал тестовые записи `mem:rg9z6ilphsbmvohwvi3` и `mem:0dlvbarfofmvoi7y3p`
612+
(`project=test`), запись встала первой в списке, «Забыть» → подтверждение → строка
613+
исчезла, `Всего` 54 → 53, `требует человека` 3 → 2; в базе (`memory.sqlite` + WAL,
614+
снята с устройства) не осталось ни строки, ни `terms`, ни рёбер `graph` — ровно то,
615+
что обещает `forget()`. Обе тестовые записи удалены, память в исходном состоянии (61).
616+
595617
---
596618

597619
## Стек и зависимости
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package org.opencode.mobile.server
2+
3+
import org.json.JSONObject
4+
import java.net.HttpURLConnection
5+
import java.net.URL
6+
7+
/**
8+
* Вызовы инструментов локальной памяти из приложения, а не из модели.
9+
*
10+
* Зачем это вообще нужно. Память - MCP-сервер, и до сих пор её трогал только
11+
* агент. Кнопка «забыть» в диагностике обязана удалять запись без модели в
12+
* цепочке: иначе человек не может убрать неверную запись, если модель не
13+
* согласится вызвать инструмент, а удаление зависит от того, что модель решит.
14+
*
15+
* Транспорт тот же, что у [MemoryMcp.toolCounts]: JSON-RPC по HTTP на
16+
* 127.0.0.1:[OpencodeRuntime.MEMORY_PORT]/mcp с bearer из [MemoryAuth].
17+
* Таймаут короткий: сервер локальный, а экран диагностики не должен подвисать
18+
* из-за мёртвой памяти - отказ лучше ожидания.
19+
*/
20+
internal object MemoryTools {
21+
/** Сколько последних записей показывать: экран читают глазами, а не скроллят. */
22+
const val LIST_LIMIT = 20
23+
24+
private const val TIMEOUT_MS = 4_000
25+
private const val HTTP_OK_MIN = 200
26+
private const val HTTP_OK_MAX = 299
27+
28+
fun stats(): Result<MemoryWire.Stats> = call("local_memory_stats", JSONObject()).mapCatching(MemoryWire::stats)
29+
30+
fun list(limit: Int = LIST_LIMIT): Result<List<MemoryWire.Record>> =
31+
call("local_memory_list", JSONObject().put("limit", limit)).mapCatching(MemoryWire::records)
32+
33+
fun forget(id: String): Result<Unit> {
34+
val args = JSONObject().put("id", id)
35+
return call("local_memory_forget", args).map { }
36+
}
37+
38+
private fun call(
39+
name: String,
40+
args: JSONObject,
41+
): Result<String> =
42+
runCatching {
43+
val authorization =
44+
MemoryAuth.bearerHeader()
45+
?: error("токен памяти неизвестен: сервер ещё не стартовал")
46+
val url = URL("http://127.0.0.1:${OpencodeRuntime.MEMORY_PORT}${MemoryMcp.MEMORY_PATH}")
47+
val conn =
48+
(url.openConnection() as HttpURLConnection)
49+
.apply {
50+
requestMethod = "POST"
51+
connectTimeout = TIMEOUT_MS
52+
readTimeout = TIMEOUT_MS
53+
doOutput = true
54+
setRequestProperty("Content-Type", "application/json")
55+
setRequestProperty("Accept", "application/json")
56+
setRequestProperty("Authorization", authorization)
57+
}
58+
try {
59+
conn.outputStream.use { it.write(MemoryWire.call(name, args).toByteArray(Charsets.UTF_8)) }
60+
val code = conn.responseCode
61+
val body =
62+
(if (code in HTTP_OK_MIN..HTTP_OK_MAX) conn.inputStream else conn.errorStream)
63+
?.bufferedReader()
64+
?.use { it.readText() }
65+
.orEmpty()
66+
if (code !in HTTP_OK_MIN..HTTP_OK_MAX) error("память ответила HTTP $code")
67+
MemoryWire.toolText(body)
68+
} finally {
69+
conn.disconnect()
70+
}
71+
}
72+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package org.opencode.mobile.server
2+
3+
import org.json.JSONObject
4+
5+
/**
6+
* Формы JSON-RPC локальной памяти: конверт `tools/call` -> текст -> запись.
7+
*
8+
* Почему это отдельный объект, а не разбор на месте в UI. Любой отказ памяти
9+
* приходит с одним и тем же HTTP 200, и разница только внутри тела: ошибка
10+
* JSON-RPC в корне, `result.isError` в результате инструмента и, наконец,
11+
* обычный `result.content[0].text`. Секция диагностики, которая разбирает это
12+
* сама, проверяется только руками на устройстве, а «пустая память» и
13+
* «сломанный разбор» выглядят на экране одинаково - пустым списком. Здесь
14+
* разбор чистый (ни сети, ни Android), поэтому покрыт JVM-тестами
15+
* [MemoryWireTest].
16+
*/
17+
internal object MemoryWire {
18+
/** Одна запись в том виде, в каком её отдаёт `local_memory_list`. */
19+
data class Record(
20+
val id: String,
21+
val content: String,
22+
val type: String,
23+
val project: String,
24+
val tags: String,
25+
val created: Long,
26+
val trust: String,
27+
val superseded: Boolean,
28+
)
29+
30+
/** Счётчики из `local_memory_stats`. */
31+
data class Stats(
32+
val memories: Int,
33+
val edges: Int,
34+
val superseded: Int,
35+
val autoApplyable: Int,
36+
val needsHuman: Int,
37+
)
38+
39+
private const val CONTENT = "content"
40+
private const val TEXT = "text"
41+
private const val NOT_JSON = "ответ памяти - не JSON"
42+
43+
/** Тело запроса `tools/call` для одного инструмента. */
44+
fun call(
45+
name: String,
46+
args: JSONObject = JSONObject(),
47+
id: Int = 1,
48+
): String =
49+
JSONObject()
50+
.put("jsonrpc", "2.0")
51+
.put("id", id)
52+
.put("method", "tools/call")
53+
.put("params", JSONObject().put("name", name).put("arguments", args))
54+
.toString()
55+
56+
/**
57+
* Достаёт текст ответа инструмента из конверта.
58+
*
59+
* Отказ инструмента - это НЕ пустой результат: `isError` без текста всё
60+
* равно бросает, иначе UI показал бы «записей нет» там, где память
61+
* отказалась отвечать.
62+
*/
63+
fun toolText(body: String): String {
64+
val envelope = envelope(parse(body))
65+
val items = envelope.optJSONArray(CONTENT)
66+
val frame = items?.optJSONObject(0)
67+
val text = frame?.optString(TEXT).orEmpty()
68+
val failed = envelope.optBoolean("isError")
69+
val reason = text.ifBlank { "ошибка памяти" }
70+
check(!failed) { reason }
71+
return text
72+
}
73+
74+
/** Список записей из ответа `local_memory_list`. */
75+
fun records(text: String): List<Record> {
76+
val array = parse(text).optJSONArray("memories") ?: return emptyList()
77+
return (0 until array.length()).mapNotNull { index -> array.optJSONObject(index)?.let(::record) }
78+
}
79+
80+
/** Счётчики из ответа `local_memory_stats`. */
81+
fun stats(text: String): Stats {
82+
val root = parse(text)
83+
return Stats(
84+
memories = root.optInt("memories"),
85+
edges = root.optInt("edges"),
86+
superseded = root.optInt("superseded"),
87+
autoApplyable = root.optInt("auto_applyable"),
88+
needsHuman = root.optInt("needs_human"),
89+
)
90+
}
91+
92+
private fun envelope(root: JSONObject): JSONObject {
93+
root.optJSONObject("error")?.let { error(it.optString("message", "ошибка памяти")) }
94+
return checkNotNull(root.optJSONObject("result")) { "в ответе памяти нет result" }
95+
}
96+
97+
private fun record(row: JSONObject): Record =
98+
Record(
99+
id = row.optString("id"),
100+
content = row.optString(CONTENT),
101+
type = row.optString("type"),
102+
project = row.optString("project"),
103+
tags = row.optString("tags"),
104+
created = row.optLong("created"),
105+
trust = row.optString("trust"),
106+
// Вытесненная запись приходит двумя признаками: числом superseded и
107+
// идентификатором замены. Верю обоим: сервер может прислать только один.
108+
superseded = row.optInt("superseded") == 1 || row.optString("superseded_by").isNotEmpty(),
109+
)
110+
111+
private fun parse(text: String): JSONObject =
112+
runCatching { JSONObject(text) }.getOrElse { throw IllegalStateException("$NOT_JSON: ${it.message}") }
113+
}

‎app/src/main/java/org/opencode/mobile/ui/DiagnosticsScreen.kt‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,10 @@ fun DiagnosticsScreen(
525525
refreshKey++
526526
}
527527

528+
// Память стоит рядом с журналом: обе секции про то, что копится само
529+
// и что человек иногда должен почистить руками.
530+
memorySection()
531+
528532
ModelRoutingPicker()
529533

530534
sectionHeader("Валидация runtime")
@@ -739,8 +743,11 @@ private fun dbSize(): Long =
739743
db.length() + File(db.parentFile, "opencode.db-wal").length()
740744
}.getOrDefault(0L)
741745

746+
// internal, а не private: той же строкой пользуется секция памяти (MemorySection.kt).
747+
// Вторая копия «серый заголовок + светлое значение» разъехалась бы с этой при
748+
// первой же правке отступов.
742749
@Composable
743-
private fun InfoRow(
750+
internal fun InfoRow(
744751
label: String,
745752
value: String,
746753
valueColor: Color = Color(0xFFE6E6E6),
@@ -753,7 +760,7 @@ private fun InfoRow(
753760

754761
/** Кнопка-плашка скачивания моделей (тёмный экран диагностики, акцент зелёный). */
755762
@Composable
756-
private fun downloadButton(
763+
internal fun downloadButton(
757764
label: String,
758765
onClick: () -> Unit,
759766
) {

0 commit comments

Comments
 (0)