Skip to content

Commit 8a5d806

Browse files
committed
feat: meta support
1 parent 8d90cb4 commit 8a5d806

26 files changed

Lines changed: 1459 additions & 164 deletions

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![Go Report Card](https://goreportcard.com/badge/github.com/skosovsky/contexty)](https://goreportcard.com/report/github.com/skosovsky/contexty)
55
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
66

7-
`contexty` is a **semantic context engine** for LLM applications: typed message AST, segment-based `ConversationStore`, non-mutating views, unified budgeting, and `Compile()``AbstractPayload`.
7+
`contexty` is a **semantic context engine** for LLM applications: typed message AST, segment-based `ConversationStore`, non-mutating views, unified budgeting, and `Compile()``CompileResult` (payload + transformations by `Message.ID`).
88

99
## Installation
1010

@@ -37,11 +37,13 @@ engine := contexty.NewEngine(
3737
)),
3838
)
3939

40-
payload, err := engine.Compile(ctx)
41-
_ = payload.FlattenMessages()
40+
result, err := engine.Compile(ctx, contexty.CompileRequest{
41+
Pending: []contexty.Message{contexty.TextMessage(contexty.RoleUser, "Current turn")},
42+
})
43+
_ = result.Payload.FlattenMessages()
4244
```
4345

44-
See [Developer Guide](docs/developer-guide.md) and [ADR-001](docs/adr/001-semantic-context-engine.md).
46+
See [Developer Guide](docs/developer-guide.md), [ADR-001](docs/adr/001-semantic-context-engine.md), and [ADR-002](docs/adr/002-clear-break-compile-contract.md).
4547

4648
## Views (non-mutating render)
4749

adapters/store/postgres/store_integration_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,12 +296,14 @@ func expandedSystemMessage() contexty.Message {
296296
func expandedHistoryMessage() contexty.Message {
297297
ts := time.Date(2025, 6, 2, 9, 0, 0, 0, time.UTC)
298298
return contexty.Message{
299+
ID: "msg-expanded-hist",
299300
Role: contexty.RoleUser,
300301
Parts: []contexty.ContentPart{
301302
contexty.TextPart{Text: "see image"},
302303
contexty.ImagePart{URL: "https://example.com/a.png", Detail: "low"},
303304
},
304305
Annotations: contexty.Annotations{Timestamp: &ts, RefID: "img-1"},
306+
Attributes: contexty.Attributes{"tier": "premium", "count": float64(2)},
305307
Provenance: contexty.UserProvenance{Channel: "web", UserID: "u2"},
306308
}
307309
}
@@ -319,6 +321,9 @@ func assertExpandedSemanticRoundTrip(t *testing.T, ctx context.Context, store *S
319321

320322
history := snap.Segment(contexty.SegmentHistory)
321323
require.Len(t, history, 1)
324+
assert.Equal(t, "msg-expanded-hist", history[0].ID)
325+
assert.Equal(t, "premium", history[0].Attributes["tier"])
326+
assert.InEpsilon(t, float64(2), history[0].Attributes["count"], 0)
322327
require.Len(t, history[0].Parts, 2)
323328
_, hasImage := history[0].Parts[1].(contexty.ImagePart)
324329
require.True(t, hasImage)

adapters/store/redis/store_integration_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,14 @@ func expandedSystemMessage() contexty.Message {
243243
func expandedHistoryMessage() contexty.Message {
244244
ts := time.Date(2025, 6, 2, 9, 0, 0, 0, time.UTC)
245245
return contexty.Message{
246+
ID: "msg-expanded-hist",
246247
Role: contexty.RoleUser,
247248
Parts: []contexty.ContentPart{
248249
contexty.TextPart{Text: "see image"},
249250
contexty.ImagePart{URL: "https://example.com/a.png", Detail: "low"},
250251
},
251252
Annotations: contexty.Annotations{Timestamp: &ts, RefID: "img-1"},
253+
Attributes: contexty.Attributes{"tier": "premium", "count": float64(2)},
252254
Provenance: contexty.UserProvenance{Channel: "web", UserID: "u2"},
253255
}
254256
}
@@ -266,6 +268,9 @@ func assertExpandedSemanticRoundTrip(t *testing.T, ctx context.Context, store *S
266268

267269
history := snap.Segment(contexty.SegmentHistory)
268270
require.Len(t, history, 1)
271+
assert.Equal(t, "msg-expanded-hist", history[0].ID)
272+
assert.Equal(t, "premium", history[0].Attributes["tier"])
273+
assert.InEpsilon(t, float64(2), history[0].Attributes["count"], 0)
269274
require.Len(t, history[0].Parts, 2)
270275
_, hasImage := history[0].Parts[1].(contexty.ImagePart)
271276
require.True(t, hasImage)

architecture_test.go

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,23 @@ func TestArchitecture_NoForbiddenExternalImports(t *testing.T) {
3838
}
3939
}
4040

41+
func TestArchitecture_NoJSONMetadataInTextParts(t *testing.T) {
42+
root, err := os.Getwd()
43+
if err != nil {
44+
t.Fatalf("getwd: %v", err)
45+
}
46+
violations, err := findJSONMetadataInTextViolations(root)
47+
if err != nil {
48+
t.Fatalf("scan: %v", err)
49+
}
50+
if len(violations) > 0 {
51+
t.Fatalf(
52+
"json metadata tunneling via TextContent forbidden in semantic core:\n%s",
53+
strings.Join(violations, "\n"),
54+
)
55+
}
56+
}
57+
4158
func TestArchitecture_NoBase64InCore(t *testing.T) {
4259
root, err := os.Getwd()
4360
if err != nil {
@@ -93,13 +110,84 @@ func isArchitectureSourceFile(path string) bool {
93110

94111
func isArchitectureAllowlisted(path string) bool {
95112
switch filepath.Base(path) {
96-
case "transform.go", "views.go", "model.go":
113+
case "transform.go", "views.go", "model.go", "provenance.go", "serializer.go", "content_part.go":
97114
return true
98115
default:
99116
return false
100117
}
101118
}
102119

120+
func findJSONMetadataInTextViolations(root string) ([]string, error) {
121+
fset := token.NewFileSet()
122+
var violations []string
123+
err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
124+
if walkErr != nil {
125+
return walkErr
126+
}
127+
if info.IsDir() {
128+
if shouldSkipArchitectureDir(filepath.Base(path)) {
129+
return filepath.SkipDir
130+
}
131+
return nil
132+
}
133+
if !isArchitectureSourceFile(path) || isArchitectureAllowlisted(path) {
134+
return nil
135+
}
136+
file, parseErr := parser.ParseFile(fset, path, nil, 0)
137+
if parseErr != nil {
138+
return parseErr
139+
}
140+
violations = append(violations, collectJSONMetadataInTextViolations(fset, file)...)
141+
return nil
142+
})
143+
return violations, err
144+
}
145+
146+
func collectJSONMetadataInTextViolations(fset *token.FileSet, file *ast.File) []string {
147+
var violations []string
148+
ast.Inspect(file, func(n ast.Node) bool {
149+
call, ok := n.(*ast.CallExpr)
150+
if !ok || len(call.Args) == 0 {
151+
return true
152+
}
153+
if !isJSONUnmarshalCall(call) {
154+
return true
155+
}
156+
if !exprReferencesTextContent(call.Args[0]) {
157+
return true
158+
}
159+
pos := fset.Position(call.Pos())
160+
violations = append(violations, pos.String())
161+
return true
162+
})
163+
return violations
164+
}
165+
166+
func isJSONUnmarshalCall(call *ast.CallExpr) bool {
167+
sel, ok := call.Fun.(*ast.SelectorExpr)
168+
if !ok {
169+
return false
170+
}
171+
pkg, ok := sel.X.(*ast.Ident)
172+
return ok && pkg.Name == "json" && sel.Sel.Name == "Unmarshal"
173+
}
174+
175+
func exprReferencesTextContent(expr ast.Expr) bool {
176+
found := false
177+
ast.Inspect(expr, func(n ast.Node) bool {
178+
sel, ok := n.(*ast.SelectorExpr)
179+
if !ok {
180+
return true
181+
}
182+
if sel.Sel.Name == "TextContent" {
183+
found = true
184+
return false
185+
}
186+
return true
187+
})
188+
return found
189+
}
190+
103191
func collectStringHeuristicCalls(fset *token.FileSet, file *ast.File) []string {
104192
var violations []string
105193
ast.Inspect(file, func(n ast.Node) bool {

attributes.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package contexty
2+
3+
import "maps"
4+
5+
// Attributes holds host-controlled metadata separate from Provenance (source URI/ID).
6+
type Attributes map[string]any
7+
8+
// Clone returns a deep copy of attributes (shallow copy of map values).
9+
func (a Attributes) Clone() Attributes {
10+
if len(a) == 0 {
11+
return nil
12+
}
13+
out := make(Attributes, len(a))
14+
maps.Copy(out, a)
15+
return out
16+
}

0 commit comments

Comments
 (0)