Skip to content

Commit 56eb135

Browse files
authored
Merge pull request #81 from lazypower/fix/relational-extraction-decouple
fix: run relational extraction when auto-extraction is off
2 parents e3440b7 + 987b99f commit 56eb135

17 files changed

Lines changed: 549 additions & 25 deletions

docs/reference/configuration.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,21 @@ inherit your login `PATH`. Re-running `continuity install-service` bakes a usabl
7979
| Key | Type | Default | Notes |
8080
|---|---|---|---|
8181
| `auto` | boolean | `false` | Automatic session-end extraction: the Stop/SessionEnd hooks asking an LLM to infer memories from the whole transcript. |
82+
| `relational_auto` | boolean | `true` | Automatic relational profiling at session end: analyzing how you work and merging the result into the single profile node. Independent of `auto`. |
8283

83-
Off by default on purpose. When you turn it on, `serve` prints a warning at
84-
startup saying so. Explicit `remember` calls, the signal-phrase path, and
85-
`continuity extract --force` are unaffected either way. Booleans are read
84+
`auto` is off by default on purpose. When you turn it on, `serve` prints a
85+
warning at startup saying so. Explicit `remember` calls, the signal-phrase path,
86+
and `continuity extract --force` are unaffected either way. Booleans are read
8687
loosely — `true`, `1`, and `yes` all mean on; anything else means off.
8788

89+
`relational_auto` is on by default: unlike transcript extraction it never
90+
creates arbitrary memories — it only merges into the system-owned
91+
`mem://user/profile/communication` node, and its provenance is unambiguous
92+
(analysis of the session, not facts transiting it). Turning it off freezes the
93+
relational profile — including relational jobs already sitting in the durable
94+
queue, which are dropped rather than replayed — and `serve` prints a warning at
95+
startup saying so.
96+
8897
### `[embedder]`
8998

9099
| Key | Type | Default | Accepted values |
@@ -115,6 +124,7 @@ is the command that changes the key *and* re-embeds.
115124
| `CONTINUITY_URL` || *(built from bind + port)* | Full base URL, e.g. `http://127.0.0.1:37777`. **Client-side only** — the CLI, hooks, and MCP server use it to find the server. It does not change what `serve` binds to. |
116125
| `CONTINUITY_EMBEDDER` | `[embedder].backend` | *(no override)* | `auto`, `model2vec`, `ollama`, `tfidf`, `hashtf`, `none`, or empty. Unrecognized values warn and fall back to `auto`. |
117126
| `CONTINUITY_EXTRACTION_AUTO` | `[extraction].auto` | `false` | Any Go boolean: `true`, `false`, `1`, `0`, `t`, `f`. `serve` **refuses to start** on anything else. |
127+
| `CONTINUITY_RELATIONAL_AUTO` | `[extraction].relational_auto` | `true` | Any Go boolean. `serve` **refuses to start** on anything else. `false` freezes the relational profile. |
118128
| `CONTINUITY_OBSERVATION_RETENTION_DAYS` || `14` days | A positive integer number of days, or `off` / `false` to disable pruning. See [below](#observation-retention). |
119129
| `ANTHROPIC_API_KEY` | `[llm].provider` **and** `[llm].anthropic_key` | *(unset)* | An API key. Setting it forces `provider = "anthropic"`, overriding whatever `config.toml` says. |
120130
| `CONTINUITY_GC` || `off` | `off`, `shadow`, `on`. Memory garbage collection. Anything unrecognized is treated as `off`. **Advanced.** |
@@ -266,6 +276,7 @@ anthropic_key = ""
266276

267277
[extraction]
268278
auto = false
279+
relational_auto = true
269280

270281
[embedder]
271282
backend = "auto"

internal/cli/extract.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,21 @@ func runExtract(cmd *cobra.Command, args []string) error {
9999
// than falsely reporting the job as queued. The status string is the server's
100100
// stable extraction_disabled contract (server.StatusExtractionDisabled).
101101
var resp struct {
102-
Status string `json:"status"`
102+
Status string `json:"status"`
103+
Relational string `json:"relational"`
103104
}
104105
if jsonErr := json.Unmarshal(data, &resp); jsonErr == nil && resp.Status == "extraction_disabled" {
105106
fmt.Printf("automatic session extraction is off for %s — re-run with --force to extract it anyway\n", sessionID)
107+
// Relational profiling is decoupled from that gate (#78); report it when
108+
// the server queued a relational-only job for this request.
109+
if resp.Relational == "extracting" {
110+
fmt.Println("relational profiling queued — the profile still updates from this session")
111+
}
112+
// The server failed to queue the relational job — say so instead of
113+
// letting the silent-skip message imply the profile update happened.
114+
if resp.Relational == "error" {
115+
fmt.Fprintf(os.Stderr, "relational profiling could NOT be queued for %s — the profile may miss this session; check serve.log\n", sessionID)
116+
}
106117
return nil
107118
}
108119

internal/cli/serve.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ const (
3535
// envServeExtractionAuto re-enables the deprecated automatic session-end
3636
// extraction (default off). Accepts any strconv.ParseBool value.
3737
envServeExtractionAuto = "CONTINUITY_EXTRACTION_AUTO"
38+
39+
// envServeRelationalAuto is the kill switch for automatic relational
40+
// profiling at session end (default on; #78). Accepts any strconv.ParseBool
41+
// value. Setting it false restores the pre-#78 behavior: non-force /extract
42+
// requests are skipped entirely while autoExtract is off.
43+
envServeRelationalAuto = "CONTINUITY_RELATIONAL_AUTO"
3844
)
3945

4046
// tfidfLexicalNotice is surfaced once at startup whenever the hashed lexical
@@ -165,6 +171,13 @@ func runServe(cmd *cobra.Command, args []string) error {
165171

166172
srv := server.New(db, eng, VersionString())
167173
srv.SetAutoExtraction(cfg.Extraction.Auto)
174+
srv.SetRelationalAuto(cfg.Extraction.RelationalAuto)
175+
if !cfg.Extraction.RelationalAuto {
176+
fmt.Fprintf(os.Stderr,
177+
" ! extraction.relational_auto DISABLED — the relational profile will not "+
178+
"update from session ends. Unset %s to return to the default.\n",
179+
envServeRelationalAuto)
180+
}
168181
if cfg.Extraction.Auto {
169182
fmt.Fprintf(os.Stderr,
170183
" ! extraction.auto ENABLED — automatic session extraction is on; it is off by "+
@@ -304,5 +317,12 @@ func applyServeEnvOverrides(cfg *config.Config) error {
304317
}
305318
cfg.Extraction.Auto = enabled
306319
}
320+
if v := strings.TrimSpace(os.Getenv(envServeRelationalAuto)); v != "" {
321+
enabled, err := strconv.ParseBool(v)
322+
if err != nil {
323+
return fmt.Errorf("%s=%q: must be a boolean (true/false/1/0)", envServeRelationalAuto, v)
324+
}
325+
cfg.Extraction.RelationalAuto = enabled
326+
}
307327
return nil
308328
}

internal/cli/serve_env_test.go

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99

1010
func clearServeEnv(t *testing.T) {
1111
t.Helper()
12-
for _, k := range []string{envServeDB, envServePort, envServeBind, envServeEmbedder} {
12+
for _, k := range []string{envServeDB, envServePort, envServeBind, envServeEmbedder, envServeExtractionAuto, envServeRelationalAuto} {
1313
t.Setenv(k, "")
1414
}
1515
}
@@ -86,6 +86,29 @@ func TestApplyServeEnvOverrides_WhitespaceIgnored(t *testing.T) {
8686
}
8787
}
8888

89+
// TestApplyServeEnvOverrides_RelationalAuto (#78): the kill switch flips the
90+
// on-by-default relational profiling off; an invalid value fails fast.
91+
func TestApplyServeEnvOverrides_RelationalAuto(t *testing.T) {
92+
clearServeEnv(t)
93+
cfg := config.Default()
94+
if !cfg.Extraction.RelationalAuto {
95+
t.Fatal("relational auto must default ON")
96+
}
97+
98+
t.Setenv(envServeRelationalAuto, "false")
99+
if err := applyServeEnvOverrides(&cfg); err != nil {
100+
t.Fatal(err)
101+
}
102+
if cfg.Extraction.RelationalAuto {
103+
t.Error("CONTINUITY_RELATIONAL_AUTO=false must disable relational auto")
104+
}
105+
106+
t.Setenv(envServeRelationalAuto, "not-a-bool")
107+
if err := applyServeEnvOverrides(&cfg); err == nil {
108+
t.Error("expected error for non-boolean CONTINUITY_RELATIONAL_AUTO")
109+
}
110+
}
111+
89112
func TestNormalizeBackend(t *testing.T) {
90113
cases := []struct {
91114
in, want string
@@ -116,10 +139,12 @@ func TestNormalizeBackend_UnknownPassesThrough(t *testing.T) {
116139
// The env constants form a contract used by external automation; pin them.
117140
func TestServeEnvConstants(t *testing.T) {
118141
cases := map[string]string{
119-
"CONTINUITY_DB": envServeDB,
120-
"CONTINUITY_PORT": envServePort,
121-
"CONTINUITY_BIND": envServeBind,
122-
"CONTINUITY_EMBEDDER": envServeEmbedder,
142+
"CONTINUITY_DB": envServeDB,
143+
"CONTINUITY_PORT": envServePort,
144+
"CONTINUITY_BIND": envServeBind,
145+
"CONTINUITY_EMBEDDER": envServeEmbedder,
146+
"CONTINUITY_EXTRACTION_AUTO": envServeExtractionAuto,
147+
"CONTINUITY_RELATIONAL_AUTO": envServeRelationalAuto,
123148
}
124149
for want, got := range cases {
125150
if got != want {

internal/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ type ExtractionConfig struct {
4545
// way. Explicit `continuity remember`, the signal ("remember this") path, and
4646
// `continuity extract --force` (the manual override) are all unaffected.
4747
Auto bool `toml:"auto"`
48+
49+
// RelationalAuto enables automatic relational profiling at session end. It
50+
// defaults to ON, deliberately decoupled from Auto (#78): unlike transcript
51+
// memory extraction, relational profiling merges into a single system-owned
52+
// node (mem://user/profile/communication), never creates arbitrary memories,
53+
// and its provenance is unambiguous — analysis of the session, not facts
54+
// transiting it. CONTINUITY_RELATIONAL_AUTO=false is the kill switch.
55+
RelationalAuto bool `toml:"relational_auto"`
4856
}
4957

5058
// EmbedderConfig governs which embedding backend `serve` constructs.
@@ -84,6 +92,8 @@ func Default() Config {
8492
Extraction: ExtractionConfig{
8593
// Auto session extraction is off by default (deprecated, high-noise).
8694
Auto: false,
95+
// Relational profiling stays on by default (#78).
96+
RelationalAuto: true,
8797
},
8898
Embedder: EmbedderConfig{
8999
Backend: "auto",

internal/config/file.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,11 @@ func applyKV(cfg *Config, section, key, val string) {
125125
cfg.LLM.AnthropicKey = val
126126
}
127127
case "extraction":
128-
if key == "auto" {
128+
switch key {
129+
case "auto":
129130
cfg.Extraction.Auto = parseBoolLoose(val)
131+
case "relational_auto":
132+
cfg.Extraction.RelationalAuto = parseBoolLoose(val)
130133
}
131134
case "embedder":
132135
if key == "backend" {

internal/config/file_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ func TestLoadFile_DefaultBackendIsAuto(t *testing.T) {
5050
}
5151
}
5252

53+
// TestLoadFile_RelationalAuto (#78): relational profiling defaults on and is
54+
// disableable via [extraction] relational_auto = false.
55+
func TestLoadFile_RelationalAuto(t *testing.T) {
56+
if !Default().Extraction.RelationalAuto {
57+
t.Fatal("Default().Extraction.RelationalAuto = false, want true")
58+
}
59+
60+
path := filepath.Join(t.TempDir(), "config.toml")
61+
content := "[extraction]\nrelational_auto = false\n"
62+
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
63+
t.Fatal(err)
64+
}
65+
cfg, err := LoadFile(path)
66+
if err != nil {
67+
t.Fatalf("LoadFile: %v", err)
68+
}
69+
if cfg.Extraction.RelationalAuto {
70+
t.Error("Extraction.RelationalAuto = true, want false from config file")
71+
}
72+
if cfg.Extraction.Auto {
73+
t.Error("Extraction.Auto must stay off by default")
74+
}
75+
}
76+
5377
func TestLoadFile_OtherFieldsRoundtrip(t *testing.T) {
5478
path := filepath.Join(t.TempDir(), "config.toml")
5579
content := `[server]

internal/engine/engine.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,21 @@ func (e *Engine) ExtractSessionForce(sessionID, transcriptPath string) error {
710710
return e.extractSession(sessionID, transcriptPath, true)
711711
}
712712

713+
// ExtractRelational runs only the relational profiling pipeline (#78) — the
714+
// "relational" queue jobs enqueued while autoExtract is off. It writes zero
715+
// memory nodes and does NOT mark the session extracted, so a later
716+
// `continuity extract --force` still runs the full pipeline; extractRelational's
717+
// source-session guard then prevents double-applying the same profile update.
718+
// The vector-identity lock is irrelevant here by construction: relational only
719+
// merges into the fixed system-owned URI and never passes the resurrection gate
720+
// (see the comment above extractRelational's UpsertNode call).
721+
func (e *Engine) ExtractRelational(sessionID, transcriptPath string) error {
722+
if transcriptPath == "" {
723+
return fmt.Errorf("no transcript path provided")
724+
}
725+
return extractRelational(e.DB, e.LLM, sessionID, transcriptPath)
726+
}
727+
713728
func (e *Engine) extractSession(sessionID, transcriptPath string, force bool) error {
714729
if transcriptPath == "" {
715730
return fmt.Errorf("no transcript path provided")

internal/engine/engine_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,61 @@ User trusts agent with code generation and architectural decisions.`
180180
}
181181
}
182182

183+
// TestExtractRelationalOnlyThenForceDoesNotDoubleApply (#78): a relational-only
184+
// job followed by `extract --force` on the same session must not re-apply the
185+
// profile update — extractRelational's source-session guard holds inside the
186+
// forced full pipeline too.
187+
func TestExtractRelationalOnlyThenForceDoesNotDoubleApply(t *testing.T) {
188+
db := testDB(t)
189+
transcriptPath := makeTranscript(t)
190+
if _, err := db.InitSession("rel-idem", "proj"); err != nil {
191+
t.Fatalf("InitSession: %v", err)
192+
}
193+
194+
relationalResp := `## 1. FEEDBACK CALIBRATION
195+
Direct feedback style, specific and immediate.
196+
197+
## 2. WORKING DYNAMIC
198+
Autonomous execution preferred, reviews results.`
199+
relMock := &llm.MockClient{Response: &llm.Response{Content: relationalResp, Provider: "mock"}}
200+
relEng := New(db, relMock)
201+
202+
// Relational-only job: profile written, session NOT marked extracted.
203+
if err := relEng.ExtractRelational("rel-idem", transcriptPath); err != nil {
204+
t.Fatalf("ExtractRelational: %v", err)
205+
}
206+
first, err := db.GetNodeByURI(relationalURI)
207+
if err != nil || first == nil {
208+
t.Fatalf("expected profile node, err=%v", err)
209+
}
210+
if sess, _ := db.GetSession("rel-idem"); sess.ExtractedAt != nil {
211+
t.Fatal("relational-only run must not mark the session extracted")
212+
}
213+
214+
// Forced full extraction on the same session: memory extraction runs, but the
215+
// relational leg skips before its LLM call (source-session dedup).
216+
// Three responses so callIdx distinguishes 2 calls from 3: a relational leg
217+
// that failed to dedup would consume the third slot.
218+
multiMock := &multiResponseMock{responses: []*llm.Response{
219+
{Content: `[{"category":"preferences","uri_hint":"go-style","l0":"Uses Go with minimal deps","l1":"Prefers Go with minimal dependencies and clean architecture","l2":"Full"}]`, Provider: "mock"},
220+
{Content: "concise, collaborative", Provider: "mock"}, // tone
221+
{Content: "unreached", Provider: "mock"},
222+
}}
223+
forceEng := New(db, multiMock)
224+
if err := forceEng.ExtractSessionForce("rel-idem", transcriptPath); err != nil {
225+
t.Fatalf("ExtractSessionForce: %v", err)
226+
}
227+
228+
// Two LLM calls only: memory extraction + tone. Zero for relational.
229+
if multiMock.callIdx != 2 {
230+
t.Errorf("LLM calls = %d, want 2 (relational must dedup)", multiMock.callIdx)
231+
}
232+
second, _ := db.GetNodeByURI(relationalURI)
233+
if second.L1Overview != first.L1Overview || second.SourceSession != first.SourceSession {
234+
t.Error("profile changed on forced re-extraction of the same session")
235+
}
236+
}
237+
183238
func TestExtractRelationalDedup(t *testing.T) {
184239
db := testDB(t)
185240

internal/server/extraction_worker.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,20 @@ func (s *Server) runExtractionJob(job *store.ExtractionJob) error {
124124
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
125125
defer cancel()
126126
return s.engine.ExtractSignal(ctx, job.SessionID, job.Payload)
127+
case "relational":
128+
// Relational-only job (#78): enqueued by handleExtractSession while
129+
// autoExtract is off, so the profile keeps learning without the memory
130+
// pipeline. Runs no memory extraction and never marks the session extracted.
131+
//
132+
// Honor the kill switch at execution time too, not just at enqueue: a job
133+
// queued before CONTINUITY_RELATIONAL_AUTO=false took effect must not
134+
// replay after a restart and write to a profile the operator froze.
135+
// Dropping (nil ⇒ deleted) is the freeze doing its job, not data loss.
136+
if !s.relationalAuto {
137+
log.Printf("extraction worker: dropping queued relational job for %s — relational auto is disabled", job.SessionID)
138+
return nil
139+
}
140+
return s.engine.ExtractRelational(job.SessionID, job.Payload)
127141
default:
128142
// Unknown kind: drop it (nil error ⇒ deleted) rather than retry forever.
129143
log.Printf("extraction worker: unknown job kind %q (job %d) — dropping", job.Kind, job.ID)

0 commit comments

Comments
 (0)