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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,17 @@ drive your kcode Sessions from your phone.
status, `Stop tree`, plus background tasks. Tap a row to open that session.
- **Queue** — snapshot, continue, steer an item into the live turn, drop an item.
- **Info** — token/cost usage, context-window gauge, model picker from the server
roster, rename/pin/archive, fork, and the skill list.
roster, rename/pin/archive, fork, the skill list with per-session
require/optional/forbid dispositions, and pending Skill/Memory draft review
(approve / edit+approve / reject).

Every capability-optional endpoint answers `404` when the active Runtime lacks it;
the client surfaces that instead of silently failing.
the client surfaces that instead of silently failing. Skill policy and knowledge
review require a session server that exposes
`POST /sessions/:id/skill-policy`, `GET /skills/proposals`, and
`POST /skills/proposals/:id/review` (kinetick-code #99 / #102). Idle Memory drafts
target agent Memory unless `targetRef` is `user`; approve writes before the
decision is recorded so a failed apply stays pending for retry.

## Build

Expand Down
53 changes: 53 additions & 0 deletions app/src/main/java/dev/kinetick/kinetic/api/KcodeClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,59 @@ class KcodeClient(val baseUrl: String, val token: String = "") {
return Wire.skills(get("/skills" + if (params.isEmpty()) "" else "?" + params.joinToString("&")))
}

/**
* Set per-session Skill dispositions (kinetick-code #99).
* Pass `null` in [dispositions] to clear an explicit disposition.
* Answers 404 when the Runtime/server lacks the capability.
*/
fun updateSkillPolicy(
id: String,
dispositions: Map<String, String?>? = null,
closed: Boolean? = null,
): SessionInfo {
val body = JsonObject()
if (dispositions != null) {
val o = JsonObject()
for ((name, value) in dispositions) {
if (value == null) o.add(name, com.google.gson.JsonNull.INSTANCE)
else o.addProperty(name, value)
}
body.add("dispositions", o)
}
if (closed != null) body.addProperty("closed", closed)
return Wire.session(post("/sessions/${enc(id)}/skill-policy", body))
?: throw KcodeException(500, "unexpected skill-policy response")
}

fun knowledgeProposals(
status: String? = "pending",
kind: String? = null,
sessionId: String? = null,
limit: Int? = null,
): KnowledgeProposalPage {
val params = buildList {
if (!status.isNullOrBlank()) add("status=${enc(status)}")
if (!kind.isNullOrBlank()) add("kind=${enc(kind)}")
if (!sessionId.isNullOrBlank()) add("sessionId=${enc(sessionId)}")
if (limit != null) add("limit=$limit")
}
return Wire.knowledgeProposals(
get("/skills/proposals" + if (params.isEmpty()) "" else "?" + params.joinToString("&"))
)
}

fun reviewKnowledgeProposal(
proposalId: String,
decision: String,
editedDraft: String? = null,
reviewNote: String? = null,
): KnowledgeReviewResult = Wire.knowledgeReview(
post(
"/skills/proposals/${enc(proposalId)}/review",
j("decision" to decision, "editedDraft" to editedDraft, "reviewNote" to reviewNote),
)
)

// ---- streams ----

/** GET /events — Runtime event stream (notification backbone). */
Expand Down
64 changes: 64 additions & 0 deletions app/src/main/java/dev/kinetick/kinetic/api/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ data class SessionInfo(
val updatedAt: Long? = null,
val model: SessionModel? = null,
val errorMessage: String? = null,
/** Per-session Skill dispositions from kinetick-code #99. */
val skillPolicy: SessionSkillPolicy? = null,
) {
/**
* Delegated work stays out of the main session list. The server forces
Expand Down Expand Up @@ -226,6 +228,68 @@ data class SkillEntry(

data class SkillPage(val skills: List<SkillEntry> = emptyList(), val hasMore: Boolean = false)

/**
* Session Skill policy projected by the Runtime (kinetick-code #99).
* Unlisted Skills stay optional unless [closed] is true.
*/
data class SessionSkillPolicy(
val closed: Boolean = false,
val mandatory: List<String> = emptyList(),
val optional: List<String> = emptyList(),
val forbidden: List<String> = emptyList(),
) {
fun dispositionFor(name: String): String? {
val key = name.trim().lowercase()
if (key.isEmpty()) return null
if (mandatory.any { it.equals(key, ignoreCase = true) }) return "mandatory"
if (forbidden.any { it.equals(key, ignoreCase = true) }) return "forbidden"
if (optional.any { it.equals(key, ignoreCase = true) }) return "optional"
return if (closed) "hidden" else null
}
}

/** Idle Skill/Memory draft awaiting human approve/reject. */
data class KnowledgeProposal(
val id: String,
val kind: String,
val action: String,
val status: String,
val title: String,
val summary: String = "",
val draft: String = "",
val editedDraft: String? = null,
val rationale: String? = null,
val sessionId: String? = null,
val agentName: String? = null,
/**
* Memory destination. Omitted/null means agent Memory (idle drafts do this);
* `"user"` selects user Memory. Unused for Skill proposals.
*/
val targetRef: String? = null,
val createdAt: Long? = null,
val updatedAt: Long? = null,
) {
val effectiveDraft: String get() = editedDraft ?: draft

/** Short destination label for the review UI. */
fun destinationLabel(): String = when {
kind.equals("memory", ignoreCase = true) &&
targetRef.equals("user", ignoreCase = true) -> "user memory"
kind.equals("memory", ignoreCase = true) -> "agent memory"
else -> "skill"
}
}

data class KnowledgeProposalPage(
val proposals: List<KnowledgeProposal> = emptyList(),
)

data class KnowledgeReviewResult(
val applied: Boolean = false,
val title: String = "",
val status: String = "",
)

// ---- delegation / background / queue ----

data class DelegationMember(
Expand Down
43 changes: 43 additions & 0 deletions app/src/main/java/dev/kinetick/kinetic/api/Wire.kt
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ object Wire {
updatedAt = o.l("updatedAt"),
model = o.o("model")?.let { SessionModel(it.s("providerId"), it.s("modelId"), it.s("variant")) },
errorMessage = o.s("errorMessage"),
skillPolicy = o.o("skillPolicy")?.let { skillPolicy(it) },
)

fun skillPolicy(o: JsonObject): SessionSkillPolicy = SessionSkillPolicy(
closed = o.b("closed") ?: false,
mandatory = o.strList("mandatory"),
optional = o.strList("optional"),
forbidden = o.strList("forbidden"),
)

fun models(text: String): List<ModelEntry> =
Expand Down Expand Up @@ -313,6 +321,41 @@ object Wire {
)
}

fun knowledgeProposal(o: JsonObject): KnowledgeProposal = KnowledgeProposal(
id = o.s("id") ?: "",
kind = o.s("kind") ?: "skill",
action = o.s("action") ?: "create",
status = o.s("status") ?: "pending",
title = o.s("title") ?: "",
summary = o.s("summary") ?: "",
draft = o.s("draft") ?: "",
editedDraft = o.s("editedDraft"),
rationale = o.s("rationale"),
sessionId = o.s("sessionId"),
agentName = o.s("agentName"),
targetRef = o.s("targetRef"),
createdAt = o.l("createdAt"),
updatedAt = o.l("updatedAt"),
)

fun knowledgeProposals(text: String): KnowledgeProposalPage {
val el = root(text) ?: return KnowledgeProposalPage()
if (el.isJsonArray) {
return KnowledgeProposalPage(proposals = el.asJsonArray.objs().map { knowledgeProposal(it) })
}
val o = el.takeIf { it.isJsonObject }?.asJsonObject ?: return KnowledgeProposalPage()
return KnowledgeProposalPage(proposals = o.objs("proposals").map { knowledgeProposal(it) })
}

fun knowledgeReview(text: String): KnowledgeReviewResult {
val o = obj(text) ?: return KnowledgeReviewResult()
return KnowledgeReviewResult(
applied = o.b("applied") ?: false,
title = o.s("title") ?: "",
status = o.s("status") ?: "",
)
}

// ---- delegation / queue / background ----

fun delegation(text: String): DelegationSnapshot {
Expand Down
Loading
Loading