orch-code 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.
Files changed (116) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +624 -0
  4. package/cmd/apply.go +111 -0
  5. package/cmd/auth.go +393 -0
  6. package/cmd/auth_test.go +100 -0
  7. package/cmd/diff.go +57 -0
  8. package/cmd/doctor.go +149 -0
  9. package/cmd/explain.go +192 -0
  10. package/cmd/explain_test.go +62 -0
  11. package/cmd/init.go +100 -0
  12. package/cmd/interactive.go +1372 -0
  13. package/cmd/interactive_input.go +45 -0
  14. package/cmd/interactive_input_test.go +55 -0
  15. package/cmd/logs.go +72 -0
  16. package/cmd/model.go +84 -0
  17. package/cmd/plan.go +149 -0
  18. package/cmd/provider.go +189 -0
  19. package/cmd/provider_model_doctor_test.go +91 -0
  20. package/cmd/root.go +67 -0
  21. package/cmd/run.go +123 -0
  22. package/cmd/run_engine.go +208 -0
  23. package/cmd/run_engine_test.go +30 -0
  24. package/cmd/session.go +589 -0
  25. package/cmd/session_helpers.go +54 -0
  26. package/cmd/session_integration_test.go +30 -0
  27. package/cmd/session_list_current_test.go +87 -0
  28. package/cmd/session_messages_test.go +163 -0
  29. package/cmd/session_runs_test.go +68 -0
  30. package/cmd/sprint1_integration_test.go +119 -0
  31. package/cmd/stats.go +173 -0
  32. package/cmd/stats_test.go +71 -0
  33. package/cmd/version.go +4 -0
  34. package/go.mod +45 -0
  35. package/go.sum +108 -0
  36. package/internal/agents/agent.go +31 -0
  37. package/internal/agents/coder.go +167 -0
  38. package/internal/agents/planner.go +155 -0
  39. package/internal/agents/reviewer.go +118 -0
  40. package/internal/agents/runtime.go +25 -0
  41. package/internal/agents/runtime_test.go +77 -0
  42. package/internal/auth/account.go +78 -0
  43. package/internal/auth/oauth.go +523 -0
  44. package/internal/auth/store.go +287 -0
  45. package/internal/confidence/policy.go +174 -0
  46. package/internal/confidence/policy_test.go +71 -0
  47. package/internal/confidence/scorer.go +253 -0
  48. package/internal/confidence/scorer_test.go +83 -0
  49. package/internal/config/config.go +331 -0
  50. package/internal/config/config_defaults_test.go +138 -0
  51. package/internal/execution/contract_builder.go +160 -0
  52. package/internal/execution/contract_builder_test.go +68 -0
  53. package/internal/execution/plan_compliance.go +161 -0
  54. package/internal/execution/plan_compliance_test.go +71 -0
  55. package/internal/execution/retry_directive.go +132 -0
  56. package/internal/execution/scope_guard.go +69 -0
  57. package/internal/logger/logger.go +120 -0
  58. package/internal/models/contracts_test.go +100 -0
  59. package/internal/models/models.go +269 -0
  60. package/internal/orchestrator/orchestrator.go +701 -0
  61. package/internal/orchestrator/orchestrator_retry_test.go +135 -0
  62. package/internal/orchestrator/review_engine_test.go +50 -0
  63. package/internal/orchestrator/state.go +42 -0
  64. package/internal/orchestrator/test_classifier_test.go +68 -0
  65. package/internal/patch/applier.go +131 -0
  66. package/internal/patch/applier_test.go +25 -0
  67. package/internal/patch/parser.go +89 -0
  68. package/internal/patch/patch.go +60 -0
  69. package/internal/patch/summary.go +30 -0
  70. package/internal/patch/validator.go +104 -0
  71. package/internal/planning/normalizer.go +416 -0
  72. package/internal/planning/normalizer_test.go +64 -0
  73. package/internal/providers/errors.go +35 -0
  74. package/internal/providers/openai/client.go +498 -0
  75. package/internal/providers/openai/client_test.go +187 -0
  76. package/internal/providers/provider.go +47 -0
  77. package/internal/providers/registry.go +32 -0
  78. package/internal/providers/registry_test.go +57 -0
  79. package/internal/providers/router.go +52 -0
  80. package/internal/providers/state.go +114 -0
  81. package/internal/providers/state_test.go +64 -0
  82. package/internal/repo/analyzer.go +188 -0
  83. package/internal/repo/context.go +83 -0
  84. package/internal/review/engine.go +267 -0
  85. package/internal/review/engine_test.go +103 -0
  86. package/internal/runstore/store.go +137 -0
  87. package/internal/runstore/store_test.go +59 -0
  88. package/internal/runtime/lock.go +150 -0
  89. package/internal/runtime/lock_test.go +57 -0
  90. package/internal/session/compaction.go +260 -0
  91. package/internal/session/compaction_test.go +36 -0
  92. package/internal/session/service.go +117 -0
  93. package/internal/session/service_test.go +113 -0
  94. package/internal/storage/storage.go +1498 -0
  95. package/internal/storage/storage_test.go +413 -0
  96. package/internal/testing/classifier.go +80 -0
  97. package/internal/testing/classifier_test.go +36 -0
  98. package/internal/tools/command.go +160 -0
  99. package/internal/tools/command_test.go +56 -0
  100. package/internal/tools/file.go +111 -0
  101. package/internal/tools/git.go +77 -0
  102. package/internal/tools/invalid_params_test.go +36 -0
  103. package/internal/tools/policy.go +98 -0
  104. package/internal/tools/policy_test.go +36 -0
  105. package/internal/tools/registry_test.go +52 -0
  106. package/internal/tools/result.go +30 -0
  107. package/internal/tools/search.go +86 -0
  108. package/internal/tools/tool.go +94 -0
  109. package/main.go +9 -0
  110. package/npm/orch.js +25 -0
  111. package/package.json +41 -0
  112. package/scripts/changelog.js +20 -0
  113. package/scripts/check-release-version.js +21 -0
  114. package/scripts/lib/release-utils.js +223 -0
  115. package/scripts/postinstall.js +157 -0
  116. package/scripts/release.js +52 -0
@@ -0,0 +1,150 @@
1
+ package runtime
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "os"
7
+ "path/filepath"
8
+ "syscall"
9
+ "time"
10
+
11
+ "github.com/furkanbeydemir/orch/internal/config"
12
+ )
13
+
14
+ const lockFileName = "lock"
15
+
16
+ type LockManager struct {
17
+ repoRoot string
18
+ lockPath string
19
+ staleAfter time.Duration
20
+ }
21
+
22
+ type LockFile struct {
23
+ PID int `json:"pid"`
24
+ RunID string `json:"run_id,omitempty"`
25
+ CreatedAt time.Time `json:"created_at"`
26
+ }
27
+
28
+ func NewLockManager(repoRoot string, staleAfter time.Duration) *LockManager {
29
+ if staleAfter <= 0 {
30
+ staleAfter = time.Hour
31
+ }
32
+ return &LockManager{
33
+ repoRoot: repoRoot,
34
+ lockPath: filepath.Join(repoRoot, config.OrchDir, lockFileName),
35
+ staleAfter: staleAfter,
36
+ }
37
+ }
38
+
39
+ func (m *LockManager) Acquire(runID string) (func() error, error) {
40
+ if err := config.EnsureOrchDir(m.repoRoot); err != nil {
41
+ return nil, err
42
+ }
43
+
44
+ for attempt := 0; attempt < 2; attempt++ {
45
+ owner := LockFile{
46
+ PID: os.Getpid(),
47
+ RunID: runID,
48
+ CreatedAt: time.Now().UTC(),
49
+ }
50
+ if err := m.create(owner); err == nil {
51
+ return func() error {
52
+ return m.Release(owner)
53
+ }, nil
54
+ } else if !os.IsExist(err) {
55
+ return nil, err
56
+ }
57
+
58
+ existing, readErr := m.readLock()
59
+ if readErr != nil {
60
+ if removeErr := os.Remove(m.lockPath); removeErr != nil && !os.IsNotExist(removeErr) {
61
+ return nil, fmt.Errorf("lock exists and is unreadable: %w", readErr)
62
+ }
63
+ continue
64
+ }
65
+
66
+ if !m.isStale(existing) {
67
+ return nil, fmt.Errorf("repository locked by pid=%d run=%s", existing.PID, existing.RunID)
68
+ }
69
+
70
+ if err := os.Remove(m.lockPath); err != nil && !os.IsNotExist(err) {
71
+ return nil, fmt.Errorf("failed to remove stale lock: %w", err)
72
+ }
73
+ }
74
+
75
+ return nil, fmt.Errorf("failed to acquire repository lock")
76
+ }
77
+
78
+ func (m *LockManager) Release(owner LockFile) error {
79
+ existing, err := m.readLock()
80
+ if err != nil {
81
+ if os.IsNotExist(err) {
82
+ return nil
83
+ }
84
+ return err
85
+ }
86
+
87
+ if existing.PID != owner.PID || !existing.CreatedAt.Equal(owner.CreatedAt) {
88
+ return nil
89
+ }
90
+
91
+ if err := os.Remove(m.lockPath); err != nil && !os.IsNotExist(err) {
92
+ return fmt.Errorf("failed to release lock: %w", err)
93
+ }
94
+
95
+ return nil
96
+ }
97
+
98
+ func (m *LockManager) create(lock LockFile) error {
99
+ f, err := os.OpenFile(m.lockPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
100
+ if err != nil {
101
+ return err
102
+ }
103
+ defer f.Close()
104
+
105
+ enc := json.NewEncoder(f)
106
+ if err := enc.Encode(lock); err != nil {
107
+ return fmt.Errorf("failed to encode lock file: %w", err)
108
+ }
109
+
110
+ return nil
111
+ }
112
+
113
+ func (m *LockManager) readLock() (LockFile, error) {
114
+ var lock LockFile
115
+ data, err := os.ReadFile(m.lockPath)
116
+ if err != nil {
117
+ return lock, err
118
+ }
119
+ if err := json.Unmarshal(data, &lock); err != nil {
120
+ return lock, err
121
+ }
122
+ return lock, nil
123
+ }
124
+
125
+ func (m *LockManager) isStale(lock LockFile) bool {
126
+ if lock.PID <= 0 {
127
+ return true
128
+ }
129
+
130
+ age := time.Since(lock.CreatedAt)
131
+ if age > m.staleAfter {
132
+ return true
133
+ }
134
+
135
+ return !processAlive(lock.PID)
136
+ }
137
+
138
+ func processAlive(pid int) bool {
139
+ if pid == os.Getpid() {
140
+ return true
141
+ }
142
+ process, err := os.FindProcess(pid)
143
+ if err != nil {
144
+ return false
145
+ }
146
+ if err := process.Signal(syscall.Signal(0)); err != nil {
147
+ return false
148
+ }
149
+ return true
150
+ }
@@ -0,0 +1,57 @@
1
+ package runtime
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/furkanbeydemir/orch/internal/config"
10
+ )
11
+
12
+ func TestLockAcquireAndRelease(t *testing.T) {
13
+ repoRoot := t.TempDir()
14
+ if err := config.EnsureOrchDir(repoRoot); err != nil {
15
+ t.Fatalf("ensure orch dir: %v", err)
16
+ }
17
+
18
+ m := NewLockManager(repoRoot, time.Minute)
19
+ release, err := m.Acquire("run-1")
20
+ if err != nil {
21
+ t.Fatalf("acquire first lock: %v", err)
22
+ }
23
+
24
+ if _, err := m.Acquire("run-2"); err == nil {
25
+ t.Fatalf("expected second acquire to fail while lock is held")
26
+ }
27
+
28
+ if err := release(); err != nil {
29
+ t.Fatalf("release lock: %v", err)
30
+ }
31
+
32
+ if _, err := m.Acquire("run-3"); err != nil {
33
+ t.Fatalf("expected acquire to succeed after release: %v", err)
34
+ }
35
+ }
36
+
37
+ func TestLockRecoversStaleLock(t *testing.T) {
38
+ repoRoot := t.TempDir()
39
+ if err := config.EnsureOrchDir(repoRoot); err != nil {
40
+ t.Fatalf("ensure orch dir: %v", err)
41
+ }
42
+
43
+ lockPath := filepath.Join(repoRoot, config.OrchDir, lockFileName)
44
+ stale := `{"pid":999999,"run_id":"old","created_at":"2000-01-01T00:00:00Z"}`
45
+ if err := os.WriteFile(lockPath, []byte(stale), 0o644); err != nil {
46
+ t.Fatalf("write stale lock: %v", err)
47
+ }
48
+
49
+ m := NewLockManager(repoRoot, time.Second)
50
+ release, err := m.Acquire("run-fresh")
51
+ if err != nil {
52
+ t.Fatalf("acquire should recover stale lock: %v", err)
53
+ }
54
+ if err := release(); err != nil {
55
+ t.Fatalf("release lock: %v", err)
56
+ }
57
+ }
@@ -0,0 +1,260 @@
1
+ package session
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "math"
7
+ "regexp"
8
+ "sort"
9
+ "strings"
10
+
11
+ "github.com/furkanbeydemir/orch/internal/storage"
12
+ )
13
+
14
+ type TokenBudget struct {
15
+ ContextLimit int
16
+ ReservedOutput int
17
+ SafetyMargin float64
18
+ }
19
+
20
+ func (b TokenBudget) UsableInput() int {
21
+ usable := b.ContextLimit - b.ReservedOutput
22
+ if usable < 0 {
23
+ return 0
24
+ }
25
+ return usable
26
+ }
27
+
28
+ func ResolveBudget(modelID string) TokenBudget {
29
+ model := strings.ToLower(strings.TrimSpace(modelID))
30
+ switch {
31
+ case strings.Contains(model, "gpt-5"):
32
+ return TokenBudget{ContextLimit: 200000, ReservedOutput: 16000, SafetyMargin: 0.12}
33
+ case strings.Contains(model, "gpt-4"):
34
+ return TokenBudget{ContextLimit: 128000, ReservedOutput: 12000, SafetyMargin: 0.15}
35
+ default:
36
+ return TokenBudget{ContextLimit: 64000, ReservedOutput: 8000, SafetyMargin: 0.18}
37
+ }
38
+ }
39
+
40
+ type modelTokenProfile struct {
41
+ CharPerToken float64
42
+ BaseOverhead int
43
+ RoleOverhead int
44
+ PartOverhead map[string]int
45
+ }
46
+
47
+ func resolveTokenProfile(modelID string) modelTokenProfile {
48
+ model := strings.ToLower(strings.TrimSpace(modelID))
49
+ base := modelTokenProfile{
50
+ CharPerToken: 4.0,
51
+ BaseOverhead: 8,
52
+ RoleOverhead: 2,
53
+ PartOverhead: map[string]int{
54
+ "text": 4,
55
+ "tool": 6,
56
+ "stage": 5,
57
+ "compaction": 8,
58
+ "error": 4,
59
+ "file": 4,
60
+ },
61
+ }
62
+ if strings.Contains(model, "gpt-5") {
63
+ base.CharPerToken = 3.7
64
+ base.BaseOverhead = 10
65
+ }
66
+ if strings.Contains(model, "gpt-4") {
67
+ base.CharPerToken = 3.9
68
+ base.BaseOverhead = 10
69
+ }
70
+ return base
71
+ }
72
+
73
+ func EstimateTokens(messages []MessageWithParts, modelID string) int {
74
+ profile := resolveTokenProfile(modelID)
75
+ totalTokens := 0
76
+ for _, message := range messages {
77
+ totalTokens += profile.BaseOverhead
78
+ totalTokens += profile.RoleOverhead
79
+ totalTokens += charsToTokens(len(strings.TrimSpace(message.Message.Role)), profile.CharPerToken)
80
+ totalTokens += charsToTokens(len(strings.TrimSpace(message.Message.Error)), profile.CharPerToken)
81
+ for _, part := range message.Parts {
82
+ partType := strings.ToLower(strings.TrimSpace(part.Type))
83
+ totalTokens += profile.PartOverhead[partType]
84
+ totalTokens += charsToTokens(len(strings.TrimSpace(part.Payload)), profile.CharPerToken)
85
+ }
86
+ }
87
+ if totalTokens < 0 {
88
+ return 0
89
+ }
90
+ if totalTokens == 0 {
91
+ return 1
92
+ }
93
+ return totalTokens
94
+ }
95
+
96
+ func charsToTokens(chars int, charPerToken float64) int {
97
+ if chars <= 0 {
98
+ return 0
99
+ }
100
+ if charPerToken <= 0 {
101
+ charPerToken = 4.0
102
+ }
103
+ return int(math.Ceil(float64(chars) / charPerToken))
104
+ }
105
+
106
+ func (s *Service) MaybeCompact(sessionID, modelID string) (bool, string, error) {
107
+ if s == nil || s.store == nil {
108
+ return false, "", fmt.Errorf("session service is not initialized")
109
+ }
110
+
111
+ messages, err := s.ListMessagesWithParts(sessionID, 500)
112
+ if err != nil {
113
+ return false, "", err
114
+ }
115
+ if len(messages) == 0 {
116
+ return false, "", nil
117
+ }
118
+
119
+ budget := ResolveBudget(modelID)
120
+ estimated := EstimateTokens(messages, modelID)
121
+ threshold := int(float64(budget.UsableInput()) * (1.0 - budget.SafetyMargin))
122
+ if threshold < 1 {
123
+ threshold = 1
124
+ }
125
+ if estimated < threshold {
126
+ return false, "", nil
127
+ }
128
+
129
+ summary := summarizeForCompaction(messages, estimated, budget)
130
+ summaryPayload, err := json.Marshal(map[string]any{
131
+ "estimated_tokens": estimated,
132
+ "usable_input": budget.UsableInput(),
133
+ "threshold": threshold,
134
+ "safety_margin": budget.SafetyMargin,
135
+ "summary": summary,
136
+ })
137
+ if err != nil {
138
+ return false, "", fmt.Errorf("serialize compaction payload: %w", err)
139
+ }
140
+
141
+ parts := []storage.SessionPart{{Type: "compaction", Payload: string(summaryPayload)}}
142
+ if _, err := s.AppendMessage(MessageInput{
143
+ SessionID: sessionID,
144
+ Role: "assistant",
145
+ FinishReason: "compacted",
146
+ Text: summary,
147
+ }, parts); err != nil {
148
+ return false, "", err
149
+ }
150
+
151
+ affected, err := s.store.CompactSessionParts(sessionID, 12)
152
+ if err != nil {
153
+ return false, "", err
154
+ }
155
+ if err := s.store.UpsertSessionSummary(sessionID, summary); err != nil {
156
+ return false, "", err
157
+ }
158
+
159
+ message := fmt.Sprintf("session compaction applied (tokens=%d, threshold=%d, compacted_parts=%d)", estimated, threshold, affected)
160
+ return true, message, nil
161
+ }
162
+
163
+ func summarizeForCompaction(messages []MessageWithParts, estimated int, budget TokenBudget) string {
164
+ files := collectRelevantPaths(messages, 8)
165
+ recent := collectRecentTexts(messages, 3, 220)
166
+
167
+ lines := []string{
168
+ "## Goal",
169
+ "Continue the conversation with reduced context size.",
170
+ "",
171
+ "## Instructions",
172
+ "Preserve user intent, prioritize latest requests, and avoid reprocessing compacted raw outputs.",
173
+ "",
174
+ "## Discoveries",
175
+ fmt.Sprintf("Estimated context tokens reached %d (usable budget %d).", estimated, budget.UsableInput()),
176
+ }
177
+ if len(recent) > 0 {
178
+ lines = append(lines, "Recent non-empty snippets:")
179
+ for _, snippet := range recent {
180
+ lines = append(lines, "- "+snippet)
181
+ }
182
+ }
183
+
184
+ lines = append(lines,
185
+ "",
186
+ "## Accomplished",
187
+ "Older session parts were compacted and replaced with short placeholders.",
188
+ "",
189
+ "## Next",
190
+ "Continue from the latest user request while relying on this summary and recent messages.",
191
+ )
192
+
193
+ if len(files) > 0 {
194
+ lines = append(lines,
195
+ "",
196
+ "## Relevant files/directories",
197
+ )
198
+ for _, file := range files {
199
+ lines = append(lines, "- "+file)
200
+ }
201
+ }
202
+
203
+ return strings.Join(lines, "\n")
204
+ }
205
+
206
+ func collectRecentTexts(messages []MessageWithParts, maxCount, maxLen int) []string {
207
+ if maxCount <= 0 {
208
+ return []string{}
209
+ }
210
+ out := make([]string, 0, maxCount)
211
+ for i := len(messages) - 1; i >= 0 && len(out) < maxCount; i-- {
212
+ for _, part := range messages[i].Parts {
213
+ text := strings.TrimSpace(ExtractTextPart(part))
214
+ if text == "" {
215
+ continue
216
+ }
217
+ if len(text) > maxLen {
218
+ text = text[:maxLen] + "..."
219
+ }
220
+ out = append(out, text)
221
+ break
222
+ }
223
+ }
224
+ return out
225
+ }
226
+
227
+ var pathPattern = regexp.MustCompile(`(?m)([A-Za-z0-9_./-]+\.[A-Za-z0-9]{1,8}|[A-Za-z0-9_./-]+/)`)
228
+
229
+ func collectRelevantPaths(messages []MessageWithParts, maxCount int) []string {
230
+ seen := map[string]struct{}{}
231
+ paths := make([]string, 0, maxCount)
232
+ for i := len(messages) - 1; i >= 0 && len(paths) < maxCount; i-- {
233
+ for _, part := range messages[i].Parts {
234
+ payload := strings.TrimSpace(part.Payload)
235
+ if payload == "" {
236
+ continue
237
+ }
238
+ matches := pathPattern.FindAllString(payload, -1)
239
+ for _, match := range matches {
240
+ candidate := strings.TrimSpace(match)
241
+ if candidate == "" || strings.HasPrefix(candidate, "http") {
242
+ continue
243
+ }
244
+ if _, ok := seen[candidate]; ok {
245
+ continue
246
+ }
247
+ seen[candidate] = struct{}{}
248
+ paths = append(paths, candidate)
249
+ if len(paths) >= maxCount {
250
+ break
251
+ }
252
+ }
253
+ if len(paths) >= maxCount {
254
+ break
255
+ }
256
+ }
257
+ }
258
+ sort.Strings(paths)
259
+ return paths
260
+ }
@@ -0,0 +1,36 @@
1
+ package session
2
+
3
+ import (
4
+ "testing"
5
+
6
+ "github.com/furkanbeydemir/orch/internal/storage"
7
+ )
8
+
9
+ func TestEstimateTokensModelAware(t *testing.T) {
10
+ messages := []MessageWithParts{{
11
+ Message: storage.SessionMessage{Role: "assistant"},
12
+ Parts: []storage.SessionPart{
13
+ {Type: "text", Payload: `{"text":"hello world"}`},
14
+ {Type: "stage", Payload: `{"actor":"planner","step":"plan","message":"a"}`},
15
+ },
16
+ }}
17
+
18
+ gpt5 := EstimateTokens(messages, "gpt-5.3-codex")
19
+ defaultModel := EstimateTokens(messages, "unknown-model")
20
+ if gpt5 <= 0 || defaultModel <= 0 {
21
+ t.Fatalf("expected positive token estimates, got gpt5=%d default=%d", gpt5, defaultModel)
22
+ }
23
+ if gpt5 == defaultModel {
24
+ t.Fatalf("expected model-aware token difference, both=%d", gpt5)
25
+ }
26
+ }
27
+
28
+ func TestResolveBudgetSafetyMargin(t *testing.T) {
29
+ b := ResolveBudget("gpt-5.3-codex")
30
+ if b.UsableInput() <= 0 {
31
+ t.Fatalf("expected usable input to be positive")
32
+ }
33
+ if b.SafetyMargin <= 0 || b.SafetyMargin >= 1 {
34
+ t.Fatalf("expected safety margin to be between 0 and 1, got %f", b.SafetyMargin)
35
+ }
36
+ }
@@ -0,0 +1,117 @@
1
+ package session
2
+
3
+ import (
4
+ "encoding/json"
5
+ "fmt"
6
+ "strings"
7
+ "time"
8
+
9
+ "github.com/furkanbeydemir/orch/internal/storage"
10
+ )
11
+
12
+ type Service struct {
13
+ store *storage.Store
14
+ }
15
+
16
+ type MessageWithParts struct {
17
+ Message storage.SessionMessage
18
+ Parts []storage.SessionPart
19
+ }
20
+
21
+ type MessageInput struct {
22
+ SessionID string
23
+ Role string
24
+ ParentID string
25
+ ProviderID string
26
+ ModelID string
27
+ FinishReason string
28
+ Error string
29
+ Text string
30
+ }
31
+
32
+ func NewService(store *storage.Store) *Service {
33
+ return &Service{store: store}
34
+ }
35
+
36
+ func (s *Service) AppendText(input MessageInput) (*MessageWithParts, error) {
37
+ parts := []storage.SessionPart{}
38
+ text := strings.TrimSpace(input.Text)
39
+ if text != "" {
40
+ payloadBytes, err := json.Marshal(map[string]string{"text": text})
41
+ if err != nil {
42
+ return nil, fmt.Errorf("serialize text payload: %w", err)
43
+ }
44
+ parts = append(parts, storage.SessionPart{
45
+ Type: "text",
46
+ Payload: string(payloadBytes),
47
+ })
48
+ }
49
+
50
+ return s.AppendMessage(input, parts)
51
+ }
52
+
53
+ func (s *Service) AppendMessage(input MessageInput, parts []storage.SessionPart) (*MessageWithParts, error) {
54
+ if s == nil || s.store == nil {
55
+ return nil, fmt.Errorf("session service is not initialized")
56
+ }
57
+
58
+ role := strings.ToLower(strings.TrimSpace(input.Role))
59
+ if role == "" {
60
+ return nil, fmt.Errorf("message role is required")
61
+ }
62
+
63
+ createdMsg, createdParts, err := s.store.CreateMessageWithParts(storage.SessionMessage{
64
+ SessionID: strings.TrimSpace(input.SessionID),
65
+ Role: role,
66
+ ParentID: strings.TrimSpace(input.ParentID),
67
+ ProviderID: strings.TrimSpace(input.ProviderID),
68
+ ModelID: strings.TrimSpace(input.ModelID),
69
+ FinishReason: strings.TrimSpace(input.FinishReason),
70
+ Error: strings.TrimSpace(input.Error),
71
+ CreatedAt: time.Now().UTC(),
72
+ }, parts)
73
+ if err != nil {
74
+ return nil, err
75
+ }
76
+
77
+ return &MessageWithParts{Message: createdMsg, Parts: createdParts}, nil
78
+ }
79
+
80
+ func (s *Service) ListMessagesWithParts(sessionID string, limit int) ([]MessageWithParts, error) {
81
+ if s == nil || s.store == nil {
82
+ return nil, fmt.Errorf("session service is not initialized")
83
+ }
84
+
85
+ messages, err := s.store.ListSessionMessages(sessionID, limit)
86
+ if err != nil {
87
+ return nil, err
88
+ }
89
+
90
+ result := make([]MessageWithParts, 0, len(messages))
91
+ for _, msg := range messages {
92
+ parts, partErr := s.store.ListSessionParts(msg.ID)
93
+ if partErr != nil {
94
+ return nil, partErr
95
+ }
96
+ result = append(result, MessageWithParts{Message: msg, Parts: parts})
97
+ }
98
+
99
+ return result, nil
100
+ }
101
+
102
+ func ExtractTextPart(part storage.SessionPart) string {
103
+ if strings.ToLower(strings.TrimSpace(part.Type)) != "text" {
104
+ return ""
105
+ }
106
+ if strings.TrimSpace(part.Payload) == "" {
107
+ return ""
108
+ }
109
+ var payload map[string]any
110
+ if err := json.Unmarshal([]byte(part.Payload), &payload); err != nil {
111
+ return strings.TrimSpace(part.Payload)
112
+ }
113
+ if text, ok := payload["text"].(string); ok {
114
+ return strings.TrimSpace(text)
115
+ }
116
+ return strings.TrimSpace(part.Payload)
117
+ }