opencode-summary 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "opencode-summary",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "opencode TUI plugin: generate and view a summary of the current session",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "author": "frostehhh",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/frostehhh/opencode-summary.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/frostehhh/opencode-summary/issues"
14
+ },
15
+ "homepage": "https://github.com/frostehhh/opencode-summary#readme",
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "tui",
20
+ "summary"
21
+ ],
22
+ "exports": {
23
+ "./tui": "./dist/tui.js"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "LICENSE",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "bun scripts/build.ts",
33
+ "test": "bun test",
34
+ "prepublishOnly": "bun run build && bun test",
35
+ "prepare": "husky"
36
+ },
37
+ "peerDependencies": {
38
+ "@opentui/core": "",
39
+ "@opentui/solid": "",
40
+ "solid-js": ""
41
+ },
42
+ "devDependencies": {
43
+ "@commitlint/cli": "^21.2.4",
44
+ "@commitlint/config-conventional": "^21.2.4",
45
+ "@opencode/plugin": "^2.2.6",
46
+ "@opentui/core": "^0.8.3",
47
+ "@opentui/solid": "^0.8.3",
48
+ "husky": "^9.1.7",
49
+ "solid-js": "^1.9.18"
50
+ },
51
+ "peerDependenciesMeta": {
52
+ "@opentui/core": {
53
+ "optional": true
54
+ },
55
+ "@opentui/solid": {
56
+ "optional": true
57
+ },
58
+ "solid-js": {
59
+ "optional": true
60
+ }
61
+ }
62
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,135 @@
1
+ import path from "node:path"
2
+ import { DEFAULT_PROMPT, GUARD } from "./prompt"
3
+
4
+ export const METADATA_KEY = "opencode-summary"
5
+ export type Metadata = Record<string, unknown>
6
+ export type SummaryRecord = { text: string; generatedAt: number; promptId: string; lastMessageID: string }
7
+
8
+ export const buildRequest = (prompt: string) => `${GUARD}\n\n${prompt}`
9
+
10
+ /** FNV-1a 32-bit, hex. Stable id for "generated with a different prompt". */
11
+ export function promptId(text: string): string {
12
+ let hash = 0x811c9dc5
13
+ for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193) >>> 0
14
+ return hash.toString(16).padStart(8, "0")
15
+ }
16
+
17
+ export function readSummary(metadata: unknown): SummaryRecord | undefined {
18
+ if (!metadata || typeof metadata !== "object") return undefined
19
+ const value = (metadata as Metadata)[METADATA_KEY]
20
+ if (!value || typeof value !== "object") return undefined
21
+ const { text, generatedAt, promptId, lastMessageID } = value as Record<string, unknown>
22
+ if (typeof text !== "string" || typeof generatedAt !== "number") return undefined
23
+ if (typeof promptId !== "string" || typeof lastMessageID !== "string") return undefined
24
+ return { text, generatedAt, promptId, lastMessageID }
25
+ }
26
+
27
+ export const withSummary = (metadata: Metadata | undefined, record: SummaryRecord): Metadata => ({
28
+ ...metadata,
29
+ [METADATA_KEY]: record,
30
+ })
31
+
32
+ export function withoutSummary(metadata: Metadata | undefined): Metadata {
33
+ const { [METADATA_KEY]: _removed, ...rest } = metadata ?? {}
34
+ return rest
35
+ }
36
+
37
+ export const isBlank = (text: string | undefined) => !text || text.trim() === ""
38
+
39
+ /** Copy of tui/src/component/dialog-stash.tsx getRelativeTime; undefined at 7+ days. */
40
+ export function relativeTime(ts: number, now: number): string | undefined {
41
+ const s = Math.floor((now - ts) / 1000)
42
+ const m = Math.floor(s / 60)
43
+ const h = Math.floor(m / 60)
44
+ const d = Math.floor(h / 24)
45
+ if (s < 60) return "just now"
46
+ if (m < 60) return `${m}m ago`
47
+ if (h < 24) return `${h}h ago`
48
+ if (d < 7) return `${d}d ago`
49
+ return undefined
50
+ }
51
+
52
+ /** Copy of tui/src/util/locale.ts datetime: "<time> · <date>". */
53
+ export function datetime(ts: number): string {
54
+ const date = new Date(ts)
55
+ return `${date.toLocaleTimeString(undefined, { timeStyle: "short" })} · ${date.toLocaleDateString()}`
56
+ }
57
+
58
+ export const TURN_LIMIT = 200
59
+ export const GENERATE_TIMEOUT_MS = 180_000
60
+ export type Turns = { count: number; capped: boolean }
61
+
62
+ /** `userMessageIDs` = one page of user messages (newest first, at most `limit`). IDs sort as strings. */
63
+ export function turnsSince(userMessageIDs: readonly string[], lastMessageID: string, limit = TURN_LIMIT): Turns {
64
+ const count = userMessageIDs.filter((id) => id > lastMessageID).length
65
+ return { count, capped: userMessageIDs.length >= limit && count >= limit }
66
+ }
67
+
68
+ export function turnsText({ count, capped }: Turns): string {
69
+ if (capped) return `${TURN_LIMIT}+ turns ago`
70
+ if (count === 0) return "up to date"
71
+ return count === 1 ? "1 turn ago" : `${count} turns ago`
72
+ }
73
+
74
+ export const turnLabel = (turns: Turns) => `summary ${turnsText(turns)}`
75
+
76
+ export function generatedLine(record: SummaryRecord, now: number, turns?: Turns): string {
77
+ const relative = relativeTime(record.generatedAt, now)
78
+ const parts = ["Generated", ...(relative ? [`${relative} ·`] : []), datetime(record.generatedAt)]
79
+ return turns ? `${parts.join(" ")} · ${turnsText(turns)}` : parts.join(" ")
80
+ }
81
+
82
+ export function errorMessage(cause: unknown): string {
83
+ if (cause instanceof Error && cause.message) return cause.message
84
+ return typeof cause === "string" && cause ? cause : "Unknown error"
85
+ }
86
+
87
+ export type BlockReason = "running" | "in-flight"
88
+
89
+ export function blockReason(input: { status: "idle" | "running"; inFlight: boolean }): BlockReason | undefined {
90
+ if (input.inFlight) return "in-flight"
91
+ if (input.status === "running") return "running"
92
+ return undefined
93
+ }
94
+
95
+ /** Newest visible message id. `idsDesc` is newest first; a staged revert hides ids >= `revertBoundary`. */
96
+ export function lastVisibleID(idsDesc: readonly string[], revertBoundary?: string): string | undefined {
97
+ return revertBoundary === undefined ? idsDesc[0] : idsDesc.find((id) => id < revertBoundary)
98
+ }
99
+
100
+ export function blockMessage(reason: BlockReason): string {
101
+ switch (reason) {
102
+ case "in-flight":
103
+ return "Already generating"
104
+ case "running":
105
+ return "Agent is running - wait for it to finish"
106
+ }
107
+ }
108
+
109
+ export function isMessageNotFound(error: unknown): boolean {
110
+ if (!error || typeof error !== "object") return false
111
+ const { _tag, name } = error as { _tag?: unknown; name?: unknown }
112
+ return _tag === "MessageNotFoundError" || name === "MessageNotFoundError"
113
+ }
114
+
115
+ export type ValidityCheck = { found: true } | { found: false; notFound: boolean }
116
+ export const validityAction = (check: ValidityCheck): "keep" | "delete" =>
117
+ !check.found && check.notFound ? "delete" : "keep"
118
+
119
+ export type PromptSource = "default" | "override"
120
+ export type ResolvedPrompt = { text: string; source: PromptSource }
121
+
122
+ export const resolvePrompt = (override: string | undefined): ResolvedPrompt =>
123
+ isBlank(override) ? { text: DEFAULT_PROMPT, source: "default" } : { text: override!.trim(), source: "override" }
124
+
125
+ export function saveAction(edited: string): "noop" | "delete" | "write" {
126
+ if (isBlank(edited)) return "noop"
127
+ return edited.trim() === DEFAULT_PROMPT.trim() ? "delete" : "write"
128
+ }
129
+
130
+ /** User-level prompt override location: `<config home>/opencode/opencode-summary/summary.md`. */
131
+ export function promptPaths(env: { XDG_CONFIG_HOME?: string }, home: string): { dir: string; override: string } {
132
+ const base = env.XDG_CONFIG_HOME?.trim() || path.join(home, ".config")
133
+ const dir = path.join(base, "opencode", "opencode-summary")
134
+ return { dir, override: path.join(dir, "summary.md") }
135
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,12 @@
1
+ export const GUARD = "Do not call any tools and do not take any actions."
2
+
3
+ export const DEFAULT_PROMPT = `Summarize the conversation so far for the user.
4
+ Output markdown only, no preamble, about 300 words or less. Use exactly these sections, bullets only below TL;DR; omit a section if empty:
5
+
6
+ ## TL;DR
7
+ 2-3 sentences.
8
+ ## Goal
9
+ ## Decisions
10
+ ## Progress / current state
11
+ ## Open questions / next steps
12
+ ## Key files and references`