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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +624 -0
- package/cmd/apply.go +111 -0
- package/cmd/auth.go +393 -0
- package/cmd/auth_test.go +100 -0
- package/cmd/diff.go +57 -0
- package/cmd/doctor.go +149 -0
- package/cmd/explain.go +192 -0
- package/cmd/explain_test.go +62 -0
- package/cmd/init.go +100 -0
- package/cmd/interactive.go +1372 -0
- package/cmd/interactive_input.go +45 -0
- package/cmd/interactive_input_test.go +55 -0
- package/cmd/logs.go +72 -0
- package/cmd/model.go +84 -0
- package/cmd/plan.go +149 -0
- package/cmd/provider.go +189 -0
- package/cmd/provider_model_doctor_test.go +91 -0
- package/cmd/root.go +67 -0
- package/cmd/run.go +123 -0
- package/cmd/run_engine.go +208 -0
- package/cmd/run_engine_test.go +30 -0
- package/cmd/session.go +589 -0
- package/cmd/session_helpers.go +54 -0
- package/cmd/session_integration_test.go +30 -0
- package/cmd/session_list_current_test.go +87 -0
- package/cmd/session_messages_test.go +163 -0
- package/cmd/session_runs_test.go +68 -0
- package/cmd/sprint1_integration_test.go +119 -0
- package/cmd/stats.go +173 -0
- package/cmd/stats_test.go +71 -0
- package/cmd/version.go +4 -0
- package/go.mod +45 -0
- package/go.sum +108 -0
- package/internal/agents/agent.go +31 -0
- package/internal/agents/coder.go +167 -0
- package/internal/agents/planner.go +155 -0
- package/internal/agents/reviewer.go +118 -0
- package/internal/agents/runtime.go +25 -0
- package/internal/agents/runtime_test.go +77 -0
- package/internal/auth/account.go +78 -0
- package/internal/auth/oauth.go +523 -0
- package/internal/auth/store.go +287 -0
- package/internal/confidence/policy.go +174 -0
- package/internal/confidence/policy_test.go +71 -0
- package/internal/confidence/scorer.go +253 -0
- package/internal/confidence/scorer_test.go +83 -0
- package/internal/config/config.go +331 -0
- package/internal/config/config_defaults_test.go +138 -0
- package/internal/execution/contract_builder.go +160 -0
- package/internal/execution/contract_builder_test.go +68 -0
- package/internal/execution/plan_compliance.go +161 -0
- package/internal/execution/plan_compliance_test.go +71 -0
- package/internal/execution/retry_directive.go +132 -0
- package/internal/execution/scope_guard.go +69 -0
- package/internal/logger/logger.go +120 -0
- package/internal/models/contracts_test.go +100 -0
- package/internal/models/models.go +269 -0
- package/internal/orchestrator/orchestrator.go +701 -0
- package/internal/orchestrator/orchestrator_retry_test.go +135 -0
- package/internal/orchestrator/review_engine_test.go +50 -0
- package/internal/orchestrator/state.go +42 -0
- package/internal/orchestrator/test_classifier_test.go +68 -0
- package/internal/patch/applier.go +131 -0
- package/internal/patch/applier_test.go +25 -0
- package/internal/patch/parser.go +89 -0
- package/internal/patch/patch.go +60 -0
- package/internal/patch/summary.go +30 -0
- package/internal/patch/validator.go +104 -0
- package/internal/planning/normalizer.go +416 -0
- package/internal/planning/normalizer_test.go +64 -0
- package/internal/providers/errors.go +35 -0
- package/internal/providers/openai/client.go +498 -0
- package/internal/providers/openai/client_test.go +187 -0
- package/internal/providers/provider.go +47 -0
- package/internal/providers/registry.go +32 -0
- package/internal/providers/registry_test.go +57 -0
- package/internal/providers/router.go +52 -0
- package/internal/providers/state.go +114 -0
- package/internal/providers/state_test.go +64 -0
- package/internal/repo/analyzer.go +188 -0
- package/internal/repo/context.go +83 -0
- package/internal/review/engine.go +267 -0
- package/internal/review/engine_test.go +103 -0
- package/internal/runstore/store.go +137 -0
- package/internal/runstore/store_test.go +59 -0
- package/internal/runtime/lock.go +150 -0
- package/internal/runtime/lock_test.go +57 -0
- package/internal/session/compaction.go +260 -0
- package/internal/session/compaction_test.go +36 -0
- package/internal/session/service.go +117 -0
- package/internal/session/service_test.go +113 -0
- package/internal/storage/storage.go +1498 -0
- package/internal/storage/storage_test.go +413 -0
- package/internal/testing/classifier.go +80 -0
- package/internal/testing/classifier_test.go +36 -0
- package/internal/tools/command.go +160 -0
- package/internal/tools/command_test.go +56 -0
- package/internal/tools/file.go +111 -0
- package/internal/tools/git.go +77 -0
- package/internal/tools/invalid_params_test.go +36 -0
- package/internal/tools/policy.go +98 -0
- package/internal/tools/policy_test.go +36 -0
- package/internal/tools/registry_test.go +52 -0
- package/internal/tools/result.go +30 -0
- package/internal/tools/search.go +86 -0
- package/internal/tools/tool.go +94 -0
- package/main.go +9 -0
- package/npm/orch.js +25 -0
- package/package.json +41 -0
- package/scripts/changelog.js +20 -0
- package/scripts/check-release-version.js +21 -0
- package/scripts/lib/release-utils.js +223 -0
- package/scripts/postinstall.js +157 -0
- 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
|
+
}
|