Skip to content

Commit edbd032

Browse files
Merge pull request #106 from 73ai/learning-extraction-and-org
feat: learnings system — git-backed markdown storage with agent tools, CLI, and HTTP serving
2 parents fe11ca8 + 3c72abe commit edbd032

File tree

15 files changed

+1263
-0
lines changed

15 files changed

+1263
-0
lines changed

agent/tools/learnings.go

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"log/slog"
8+
"strings"
9+
"sync"
10+
"time"
11+
12+
"github.com/73ai/openbotkit/agent"
13+
"github.com/73ai/openbotkit/provider"
14+
"github.com/73ai/openbotkit/service/learnings"
15+
)
16+
17+
// LearningsNotifier sends push notifications for learning events.
18+
// This avoids importing the channel package (which imports tools).
19+
type LearningsNotifier interface {
20+
Push(ctx context.Context, message string) error
21+
}
22+
23+
type LearningsDeps struct {
24+
Store *learnings.Store
25+
BaseURL string
26+
Notifier LearningsNotifier
27+
}
28+
29+
type LearningsExtractDeps struct {
30+
LearningsDeps
31+
Provider provider.Provider
32+
Model string
33+
}
34+
35+
// LearningSaveTool
36+
37+
type LearningSaveTool struct {
38+
deps LearningsDeps
39+
}
40+
41+
func NewLearningSaveTool(deps LearningsDeps) *LearningSaveTool {
42+
return &LearningSaveTool{deps: deps}
43+
}
44+
45+
func (t *LearningSaveTool) Name() string { return "learnings_save" }
46+
func (t *LearningSaveTool) Description() string { return "Save learnings as bullet points under a topic" }
47+
func (t *LearningSaveTool) InputSchema() json.RawMessage {
48+
return json.RawMessage(`{
49+
"type": "object",
50+
"properties": {
51+
"topic": {
52+
"type": "string",
53+
"description": "Topic name (e.g. Go, SQL, Docker)"
54+
},
55+
"bullets": {
56+
"type": "array",
57+
"items": {"type": "string"},
58+
"description": "List of learning bullet points"
59+
}
60+
},
61+
"required": ["topic", "bullets"]
62+
}`)
63+
}
64+
65+
type learningSaveInput struct {
66+
Topic string `json:"topic"`
67+
Bullets []string `json:"bullets"`
68+
}
69+
70+
func (t *LearningSaveTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
71+
var in learningSaveInput
72+
if err := json.Unmarshal(input, &in); err != nil {
73+
return "", fmt.Errorf("parse input: %w", err)
74+
}
75+
if in.Topic == "" {
76+
return "", fmt.Errorf("topic is required")
77+
}
78+
if len(in.Bullets) == 0 {
79+
return "", fmt.Errorf("at least one bullet is required")
80+
}
81+
82+
if err := t.deps.Store.Save(in.Topic, in.Bullets); err != nil {
83+
return "", fmt.Errorf("save learning: %w", err)
84+
}
85+
86+
if t.deps.Notifier != nil {
87+
slug := t.deps.Store.Slug(in.Topic)
88+
msg := fmt.Sprintf("Saved a learning about %s", in.Topic)
89+
if t.deps.BaseURL != "" {
90+
msg += fmt.Sprintf("\n%s/learnings/%s", t.deps.BaseURL, slug)
91+
}
92+
if err := t.deps.Notifier.Push(ctx, msg); err != nil {
93+
slog.Warn("learnings: notification failed", "error", err)
94+
}
95+
}
96+
97+
return fmt.Sprintf("Saved %d bullet(s) under topic %q.", len(in.Bullets), in.Topic), nil
98+
}
99+
100+
// LearningReadTool
101+
102+
type LearningReadTool struct {
103+
deps LearningsDeps
104+
}
105+
106+
func NewLearningReadTool(deps LearningsDeps) *LearningReadTool {
107+
return &LearningReadTool{deps: deps}
108+
}
109+
110+
func (t *LearningReadTool) Name() string { return "learnings_read" }
111+
func (t *LearningReadTool) Description() string { return "Read a learning topic or list all topics" }
112+
func (t *LearningReadTool) InputSchema() json.RawMessage {
113+
return json.RawMessage(`{
114+
"type": "object",
115+
"properties": {
116+
"topic": {
117+
"type": "string",
118+
"description": "Topic name to read. Omit to list all topics."
119+
}
120+
}
121+
}`)
122+
}
123+
124+
type learningReadInput struct {
125+
Topic string `json:"topic"`
126+
}
127+
128+
func (t *LearningReadTool) Execute(_ context.Context, input json.RawMessage) (string, error) {
129+
var in learningReadInput
130+
if err := json.Unmarshal(input, &in); err != nil {
131+
return "", fmt.Errorf("parse input: %w", err)
132+
}
133+
134+
if in.Topic == "" {
135+
topics, err := t.deps.Store.List()
136+
if err != nil {
137+
return "", err
138+
}
139+
if len(topics) == 0 {
140+
return "No learning topics saved yet.", nil
141+
}
142+
return "Topics:\n- " + strings.Join(topics, "\n- "), nil
143+
}
144+
145+
content, err := t.deps.Store.Read(in.Topic)
146+
if err != nil {
147+
return "", err
148+
}
149+
return content, nil
150+
}
151+
152+
// LearningSearchTool
153+
154+
type LearningSearchTool struct {
155+
deps LearningsDeps
156+
}
157+
158+
func NewLearningSearchTool(deps LearningsDeps) *LearningSearchTool {
159+
return &LearningSearchTool{deps: deps}
160+
}
161+
162+
func (t *LearningSearchTool) Name() string { return "learnings_search" }
163+
func (t *LearningSearchTool) Description() string {
164+
return "Search across all saved learnings"
165+
}
166+
func (t *LearningSearchTool) InputSchema() json.RawMessage {
167+
return json.RawMessage(`{
168+
"type": "object",
169+
"properties": {
170+
"query": {
171+
"type": "string",
172+
"description": "Search query"
173+
}
174+
},
175+
"required": ["query"]
176+
}`)
177+
}
178+
179+
type learningSearchInput struct {
180+
Query string `json:"query"`
181+
}
182+
183+
func (t *LearningSearchTool) Execute(_ context.Context, input json.RawMessage) (string, error) {
184+
var in learningSearchInput
185+
if err := json.Unmarshal(input, &in); err != nil {
186+
return "", fmt.Errorf("parse input: %w", err)
187+
}
188+
if in.Query == "" {
189+
return "", fmt.Errorf("query is required")
190+
}
191+
192+
results, err := t.deps.Store.Search(in.Query)
193+
if err != nil {
194+
return "", err
195+
}
196+
if len(results) == 0 {
197+
return "No results found.", nil
198+
}
199+
200+
var b strings.Builder
201+
for _, r := range results {
202+
fmt.Fprintf(&b, "[%s] %s\n", r.Topic, r.Line)
203+
}
204+
return b.String(), nil
205+
}
206+
207+
// LearningExtractTool
208+
209+
type LearningExtractTool struct {
210+
deps LearningsExtractDeps
211+
}
212+
213+
func NewLearningExtractTool(deps LearningsExtractDeps) *LearningExtractTool {
214+
return &LearningExtractTool{deps: deps}
215+
}
216+
217+
func (t *LearningExtractTool) Name() string { return "learnings_extract" }
218+
func (t *LearningExtractTool) Description() string {
219+
return "Extract and save key learnings from source material (runs in background)"
220+
}
221+
func (t *LearningExtractTool) InputSchema() json.RawMessage {
222+
return json.RawMessage(`{
223+
"type": "object",
224+
"properties": {
225+
"context": {
226+
"type": "string",
227+
"description": "Source material to extract learnings from"
228+
}
229+
},
230+
"required": ["context"]
231+
}`)
232+
}
233+
234+
type learningExtractInput struct {
235+
Context string `json:"context"`
236+
}
237+
238+
func (t *LearningExtractTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
239+
var in learningExtractInput
240+
if err := json.Unmarshal(input, &in); err != nil {
241+
return "", fmt.Errorf("parse input: %w", err)
242+
}
243+
if in.Context == "" {
244+
return "", fmt.Errorf("context is required")
245+
}
246+
247+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
248+
go func() {
249+
defer cancel()
250+
t.run(ctx, in.Context)
251+
}()
252+
253+
return "Extraction started. You'll get a notification when done.", nil
254+
}
255+
256+
// trackingSaveTool wraps LearningSaveTool to record which topics were saved.
257+
type trackingSaveTool struct {
258+
inner *LearningSaveTool
259+
mu sync.Mutex
260+
slugs []string
261+
}
262+
263+
func (t *trackingSaveTool) Name() string { return t.inner.Name() }
264+
func (t *trackingSaveTool) Description() string { return t.inner.Description() }
265+
func (t *trackingSaveTool) InputSchema() json.RawMessage { return t.inner.InputSchema() }
266+
func (t *trackingSaveTool) Execute(ctx context.Context, input json.RawMessage) (string, error) {
267+
var in learningSaveInput
268+
if err := json.Unmarshal(input, &in); err == nil && in.Topic != "" {
269+
slug := t.inner.deps.Store.Slug(in.Topic)
270+
t.mu.Lock()
271+
t.slugs = append(t.slugs, slug)
272+
t.mu.Unlock()
273+
}
274+
return t.inner.Execute(ctx, input)
275+
}
276+
277+
func (t *LearningExtractTool) run(ctx context.Context, material string) {
278+
if t.deps.Provider == nil {
279+
slog.Error("learnings: extraction skipped, no provider configured")
280+
return
281+
}
282+
283+
subDeps := LearningsDeps{Store: t.deps.Store}
284+
tracker := &trackingSaveTool{inner: NewLearningSaveTool(subDeps)}
285+
toolReg := NewRegistry()
286+
toolReg.Register(tracker)
287+
toolReg.Register(NewLearningReadTool(subDeps))
288+
289+
system := `You are a learning extraction assistant. Read the provided material and extract key learnings.
290+
Learnings are knowledge, techniques, insights, or skills the user picked up. Things like how something works, a useful pattern, a gotcha to avoid.
291+
Do NOT save: personal info (name, timezone, contacts), factual lookups (currency conversions, weather), trivial Q&A, or preferences. Those are not learnings.
292+
If the material has nothing worth saving as a learning, just reply "Nothing to save here" and do not call any tools.
293+
For each distinct topic, call learnings_save with a topic name and concise bullet points.
294+
Use learnings_read first to check existing topics so you can append to them rather than creating duplicates.
295+
Keep bullet points casual, concise, and useful. No emdashes.
296+
After saving, reply with a short friendly 1-2 sentence summary of what you saved. If you saved nothing, say so. This message will be sent as a notification.`
297+
298+
blocks := []provider.SystemBlock{
299+
{Text: system, CacheControl: &provider.CacheControl{Type: "ephemeral"}},
300+
}
301+
302+
a := agent.New(t.deps.Provider, t.deps.Model, toolReg,
303+
agent.WithSystemBlocks(blocks),
304+
agent.WithMaxIterations(15),
305+
)
306+
307+
result, err := a.Run(ctx, material)
308+
if err != nil {
309+
slog.Error("learnings: extraction failed", "error", err)
310+
return
311+
}
312+
313+
if t.deps.Notifier != nil {
314+
msg := strings.TrimSpace(result)
315+
if msg == "" {
316+
msg = "Saved your learnings!"
317+
}
318+
if t.deps.BaseURL != "" {
319+
for _, slug := range tracker.slugs {
320+
msg += fmt.Sprintf("\n%s/learnings/%s", t.deps.BaseURL, slug)
321+
}
322+
}
323+
if err := t.deps.Notifier.Push(ctx, msg); err != nil {
324+
slog.Warn("learnings: extract notification failed", "error", err)
325+
}
326+
}
327+
328+
slog.Info("learnings: extraction complete")
329+
}

0 commit comments

Comments
 (0)