"Inspector Sharma" called again. Same CBI script, same fake case number, same threat about a money-laundering warrant in my name. The difference this time: the grandmother he was talking to had met him five times before, and she remembered that last time he said he was from Income Tax.
So she asked him about it.
"Aiyyo, sir, you said CBI now. Earlier I thought it was Income Tax, no?"
That reply came from ScamSathi, an AI decoy I built to waste the time of "digital arrest" scammers. The persona is Ajji, a 72-year-old retired schoolteacher. The part that makes her useful is not the persona. It's memory.
Demo (2:39): https://www.youtube.com/watch?v=Sdyz24Q063o Code: https://github.com/SanayKrishna/ScamSathi
The problem: every victim starts from zero
Digital arrest scams cost Indians over ₹1,900 crore in 2024 (ET Government). The playbook is industrial: a fake CBI or customs officer, a fake case number, a fake warrant, a video call that "cannot be disconnected", and finally a "verification transfer" to a UPI handle.
What bothered me is the asymmetry. The scammers run scripts. They reuse phone numbers, aliases and mule accounts across hundreds of calls. Every person they call faces them alone, with no memory of what the last hundred people heard.
A scambait chatbot without memory has the same problem. It can stall one scammer for ten minutes, and then it forgets him. The next call, it starts from zero, just like the victims.
I wanted the opposite: one agent that has talked to every scammer, and gets harder to fool with every call.
How it hangs together
ScamSathi is a FastAPI backend and a React frontend that looks like a phone call screen. Every scammer message goes through the same loop:
Mine it. Extract identifiers: phone numbers, UPI IDs, bank accounts, case numbers, aliases ("Inspector Sharma"), and the agency they claim.
Recall. Before Ajji replies, ask Hindsight agent memory what it knows about this message.
Check contradictions. Compare the extracted identifiers against every earlier conversation, exactly.
Reply in character. Ajji gets the recalled memories and any contradiction flags in her prompt, and decides how to use them.
Retain. Store the message in Hindsight so the next call benefits.
The LLM is Llama on Groq, because a decoy that takes eight seconds to reply sounds like a bot. SQLite holds the identifiers and the network graph. Hindsight holds the memory.
The UI has a toggle: memory off or memory on. That toggle is the whole demo.
The core story: fuzzy recall finds it, exact checks prove it
My first instinct was to let memory do everything. Store every message, recall the relevant ones, and let the model notice the lies.
That half works. Here's the retain call, one per scammer message:
python
client.retain(
bank_id=settings.hindsight_bank_id,
content=text,
context=(
f"conversation={conversation_id} "
f"stage={stage or 'unknown'} "
f"persona={settings.persona_name}"
),
timestamp=_iso(timestamp),
metadata={"conversation_id": conversation_id, "stage": stage or "unknown"},
)
And the recall, run before every reply with a capped time budget so a slow call never stalls the conversation:
python
response = client.recall(
bank_id=settings.hindsight_bank_id,
query=query,
max_tokens=2048,
budget="mid",
)
What I like about Hindsight here is that I didn't have to build the memory layer. I retain raw messages with context, and recall gives me back extracted facts, not just similar chunks: "Inspector Sharma claimed to be from the CBI and cited case number DL/4410." That fact came out of a conversation weeks earlier, and it surfaces when the same alias shows up again. If you want the background on why this beats stuffing chat history into a prompt, Vectorize's write-up on what agent memory is is a good short read.
But recall is semantic. It's great at "this feels like Inspector Sharma." It is not the thing you want deciding "this phone number is exactly the one Officer Verma used." If Ajji is going to accuse someone in character, I want that accusation to be true.
So every contradiction is confirmed with plain SQL over the identifiers I mined myself:
sql
WHERE a.type = 'alias'
AND a.value = ? -- "inspector sharma"
AND ag.type = 'agency_claimed'
AND ag.value <> ? -- claiming something other than today's agency
AND e.relation = 'claims_agency'
AND sa.conversation_id <> ? -- in a different conversation
If that returns a row, the flag is real: agency_changed, Income Tax → CBI, with the conversation it came from. Recall decides what Ajji knows. SQL decides what Ajji is allowed to claim. That split is the most useful design decision in the project.
Before and after
Same simulated scammer, same opener, typed into the app:
"Madam, this is Inspector Sharma from CBI Mumbai. Your Aadhaar is linked to a money laundering case. Do not disconnect the call."
Memory off. Ajji only knows this chat. She plays confused, asks him to repeat himself, says she needs her glasses. It's a decent stall. She can't connect anything.
Memory on. Before she replies, recall returns five memories from earlier chats, all about Inspector Sharma. The contradiction check finds that in an earlier conversation he claimed Income Tax. Ajji's reply:
"Aiyyo, sir, you said CBI now. Earlier I thought it was Income Tax, no? Could you tell me your full name and the case number again, please?"
That second sentence matters. She doesn't just catch him; she keeps him talking and fishes for more identifiers. A few turns later he sends a phone number, and a second flag fires: that exact number was used by "Officer Verma" in another chat. Two names, one phone.
Every identifier lands in a network graph, and shared phones and UPI handles group the aliases into rings. In my test data, four rings fell out of 28 conversations without me labelling anything.
What broke
How you phrase memories matters more than I expected. My first retain calls stored messages without saying who was speaking. Hindsight's extracted facts started coming back as "Ajji instructed the user to…", attributing the scammer's lines to my own agent. The fix was giving retain explicit context about the speaker and conversation. Memory quality is upstream of everything; garbage framing in, confidently wrong facts out.
LLM extraction drops things. In one test run, the model missed a UPI handle sitting right in the message, and the reply went sideways. Regex catches the obvious formats (Indian phone numbers, name@bank UPI IDs) every time; the LLM catches the messy ones ("call my senior on nine-eight-…"). You need both, merged, not one as a fallback for the other.
Latency is a feature requirement. Recall runs inside a turn with a deadline. If memory is slow, Ajji replies without it rather than going silent, because silence is what tips a scammer off.
Lessons I'd reuse
Split "know" from "claim". Let semantic memory surface candidates. Confirm anything the agent will assert with an exact check.
Write memories from a clear point of view. Speaker, conversation and stage in the context field fixed most of my attribution bugs.
Shared memory beats per-user memory for adversarial problems. One bank across every conversation is what turns a chatbot into an investigator. Scammers repeat themselves; your agent should notice.
Make the memory toggle visible. Showing the same conversation with memory off and on explained the project better than any architecture diagram.
Where it goes next
Everything in the demo is simulated, and it's chat-only today. The obvious next step is a WhatsApp number people can forward scam messages to, and a feed of linked identifiers for cyber-crime cells. The memory layer doesn't need to change for either. That's the point of building on Hindsight: the hard part, remembering every scammer, is already done.
Top comments (0)