feat: extract contacts from message logs
This commit is contained in:
@@ -18,7 +18,7 @@ Schema notes: `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md`
|
||||
| MCP tool | Primary source | Fallback source | Evidence file | Decision status | Next implementation slice |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `isphere_receive_messages` | decrypted `PacketReader.ProcessPacket` XMPP `<message>` stanzas via `internal/isphere.EncryptedPacketLogSource` | decrypted `Smark.SendReceive` transport stanzas; decrypted `SaveToDB` `Chat_OnMessageArrived` traces; wrapped `MsgLib.db` after DB wrapper is solved | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | C5 env loader wired through ISPHERE_PACKET_LOG_FILE; default source remains empty when unset | fixture-backed configured-source operator smoke |
|
||||
| `isphere_search_contacts` | decrypted packet/send-receive JIDs and later roster stanzas from `Smark.SendReceive` | `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | candidate pending parser coverage | second |
|
||||
| `isphere_search_contacts` | bare sender/receiver JIDs extracted from normalized log-backed messages | decrypted roster/contact stanzas from `Smark.SendReceive`; `MsgLib.db` contact/conversation tables after DB wrapper is solved; UIA helper source if display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#log-decryption-result` | C7 source extraction unit-verified with JID-only display/account fallback | register MCP tool from C7 source |
|
||||
| `isphere_search_groups` | decrypted XMPP group/message stanzas if groupchat/group JIDs appear | `MsgLib.db` group/conversation tables after DB wrapper is solved; UIA helper source if group display names are missing | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | candidate pending group stanza evidence | third |
|
||||
| `isphere_receive_files` | decrypted `PacketReader.ProcessPacket` file-transfer message stanzas plus `zyl\importal\<hash>` cache mapping | decrypted `Smark.SendReceive` and `SaveToDB` traces for file-reference reconciliation | `docs/source-discovery/2026-07-09-n12-pre-zyl-schema-notes.md#field-mapping-evidence` | candidate pending cache-reference mapping | fourth |
|
||||
|
||||
|
||||
@@ -929,7 +929,7 @@ Implemented and verified in this loop:
|
||||
- `Limit <= 0` returns all matches; positive `Limit` truncates.
|
||||
- This loop does not register the MCP tool yet; registration comes after source behavior passes tests.
|
||||
|
||||
- [ ] **Step 1: Write failing contact extraction test**
|
||||
- [x] **Step 1: Write failing contact extraction test**
|
||||
|
||||
Create `internal/isphere/contacts_test.go` and run:
|
||||
|
||||
@@ -939,11 +939,11 @@ go test ./internal/isphere -run TestSearchContactsFromMessagesMatchesBareJIDs -v
|
||||
|
||||
Expected initial failure: contact search types/functions do not exist.
|
||||
|
||||
- [ ] **Step 2: Implement contact extraction/search**
|
||||
- [x] **Step 2: Implement contact extraction/search**
|
||||
|
||||
Create `internal/isphere/contacts.go` with minimal pure Go logic over normalized messages. Use synthetic/redacted fixtures only.
|
||||
|
||||
- [ ] **Step 3: Run full verification and rewrite Loop C8**
|
||||
- [x] **Step 3: Run full verification and rewrite Loop C8**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -958,6 +958,71 @@ Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. R
|
||||
|
||||
---
|
||||
|
||||
## Loop C7 Result
|
||||
|
||||
Implemented and verified in this loop:
|
||||
|
||||
- `internal/isphere.SearchContactsFromMessages(messages, query)` extracts unique contact candidates from normalized message sender/receiver bare JIDs.
|
||||
- Contacts currently use bare JID as `ContactID`, `DisplayName`, and `Account`; `Source` is `local_readonly`, `Confidence` is `0.6`, and `RawRef` is `message_jid` until richer roster/display-name evidence is validated.
|
||||
- Query matching is case-insensitive over bare JID/display name and `Limit` truncates deterministic sorted results.
|
||||
- Verification completed for C7 with the loop-specific test; full verification is run again before commit.
|
||||
|
||||
---
|
||||
|
||||
## Loop C8: Register `isphere_search_contacts` MCP tool
|
||||
|
||||
**Goal:** Register the contact search MCP tool on top of the C7 contact extraction behavior while keeping the tool read-only and clearly marked as JID-derived/local-readonly until richer contact evidence is available.
|
||||
|
||||
**Planned files:**
|
||||
- Create: `internal/tools/isphere_contacts.go`
|
||||
- Create: `internal/tools/isphere_contacts_test.go`
|
||||
- Modify: `internal/mcpserver/server.go`
|
||||
- Modify: `internal/mcpserver/server_test.go`
|
||||
- Modify: `scripts/verify-go-mcp.ps1`
|
||||
- Modify: `docs/go-mcp-runbook.md`
|
||||
- Modify: `docs/current-status-card.md`
|
||||
- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`
|
||||
|
||||
**Planned interface:**
|
||||
- Tool name: `isphere_search_contacts`.
|
||||
- Input: `query`, `limit`.
|
||||
- Source: contact extraction over the same env-configured message source used by `isphere_receive_messages`.
|
||||
- Output shape: `ok`, `contacts`, `next_cursor`, `audit` minimal fields aligned with `docs/mcp-core-tools-contract.md`.
|
||||
- Default empty source returns `ok: true`, `contacts: []`.
|
||||
|
||||
- [ ] **Step 1: Write failing tool test**
|
||||
|
||||
Create `internal/tools/isphere_contacts_test.go` and run:
|
||||
|
||||
```powershell
|
||||
go test ./internal/tools -run TestISphereSearchContactsToolReturnsJIDContacts -v
|
||||
```
|
||||
|
||||
Expected initial failure: contact tool registration does not exist.
|
||||
|
||||
- [ ] **Step 2: Implement contact tool registration**
|
||||
|
||||
Register `isphere_search_contacts` with source injection for tests. Use C7 source behavior and return a contract-shaped map with minimal audit metadata.
|
||||
|
||||
- [ ] **Step 3: Wire server and verification**
|
||||
|
||||
Register contact search in `mcpserver.NewServerWithReceiveSource` or an equivalent shared source path. Update server tool-count tests and `scripts/verify-go-mcp.ps1` to expect six tools and call `isphere_search_contacts` against both default empty source and synthetic configured fixture.
|
||||
|
||||
- [ ] **Step 4: Run full verification and commit**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
go test ./...
|
||||
go build ./cmd/isphere-mcp
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-go-mcp.ps1
|
||||
```
|
||||
|
||||
Expected: all exit 0. Remove any generated root `isphere-mcp.exe` after build. Commit exact C8 paths only and rewrite the next loop from actual results.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Spec coverage: the plan follows the user's cyclic requirement: plan first, implement one loop at a time, update the next loop after each result, and ask only on blockers.
|
||||
|
||||
69
internal/isphere/contacts.go
Normal file
69
internal/isphere/contacts.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package isphere
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Contact struct {
|
||||
ContactID string
|
||||
DisplayName string
|
||||
Account string
|
||||
Source string
|
||||
Confidence float64
|
||||
RawRef string
|
||||
}
|
||||
|
||||
type SearchContactsQuery struct {
|
||||
Query string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type SearchContactsResult struct {
|
||||
Contacts []Contact
|
||||
}
|
||||
|
||||
func SearchContactsFromMessages(messages []Message, query SearchContactsQuery) SearchContactsResult {
|
||||
unique := map[string]Contact{}
|
||||
for _, message := range messages {
|
||||
addMessageContact(unique, message.SenderID)
|
||||
addMessageContact(unique, message.ReceiverID)
|
||||
}
|
||||
|
||||
queryText := strings.ToLower(strings.TrimSpace(query.Query))
|
||||
ids := make([]string, 0, len(unique))
|
||||
for id := range unique {
|
||||
if queryText == "" || strings.Contains(strings.ToLower(id), queryText) || strings.Contains(strings.ToLower(unique[id].DisplayName), queryText) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > len(ids) {
|
||||
limit = len(ids)
|
||||
}
|
||||
contacts := make([]Contact, 0, limit)
|
||||
for _, id := range ids[:limit] {
|
||||
contacts = append(contacts, unique[id])
|
||||
}
|
||||
return SearchContactsResult{Contacts: contacts}
|
||||
}
|
||||
|
||||
func addMessageContact(unique map[string]Contact, id string) {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := unique[trimmed]; exists {
|
||||
return
|
||||
}
|
||||
unique[trimmed] = Contact{
|
||||
ContactID: trimmed,
|
||||
DisplayName: trimmed,
|
||||
Account: trimmed,
|
||||
Source: "local_readonly",
|
||||
Confidence: 0.6,
|
||||
RawRef: "message_jid",
|
||||
}
|
||||
}
|
||||
22
internal/isphere/contacts_test.go
Normal file
22
internal/isphere/contacts_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package isphere
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchContactsFromMessagesMatchesBareJIDs(t *testing.T) {
|
||||
messages := []Message{
|
||||
{ID: "msg-1", SenderID: "alice@imopenfire1-lanzhou", ReceiverID: "bob@imopenfire1-lanzhou"},
|
||||
{ID: "msg-2", SenderID: "alice@imopenfire1-lanzhou", ReceiverID: "carol@imopenfire1-lanzhou"},
|
||||
}
|
||||
|
||||
got := SearchContactsFromMessages(messages, SearchContactsQuery{Query: "imopenfire1", Limit: 2})
|
||||
want := SearchContactsResult{Contacts: []Contact{
|
||||
{ContactID: "alice@imopenfire1-lanzhou", DisplayName: "alice@imopenfire1-lanzhou", Account: "alice@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
|
||||
{ContactID: "bob@imopenfire1-lanzhou", DisplayName: "bob@imopenfire1-lanzhou", Account: "bob@imopenfire1-lanzhou", Source: "local_readonly", Confidence: 0.6, RawRef: "message_jid"},
|
||||
}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("SearchContactsFromMessages() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user