<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lay Patel</title>
    <description>The latest articles on DEV Community by Lay Patel (@laypatel13).</description>
    <link>https://dev.to/laypatel13</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4156172%2F5d2ea743-7e73-4f0c-be11-d2153db1de22.png</url>
      <title>DEV Community: Lay Patel</title>
      <link>https://dev.to/laypatel13</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/laypatel13"/>
    <language>en</language>
    <item>
      <title>Who actually joined that meeting? Connecting Google Meet to cognee</title>
      <dc:creator>Lay Patel</dc:creator>
      <pubDate>Fri, 30 Oct 2026 17:37:36 +0000</pubDate>
      <link>https://dev.to/laypatel13/who-actually-joined-that-meeting-connecting-google-meet-to-cognee-20dj</link>
      <guid>https://dev.to/laypatel13/who-actually-joined-that-meeting-connecting-google-meet-to-cognee-20dj</guid>
      <description>&lt;p&gt;Google Meet knows who joined a call, but not what the call was. Google Calendar knows the title, but not who actually showed up. So I tested my connector with a question that needs both:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Who actually joined the Falcon drone sync and when?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the answer from memory:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The participants who joined that meeting on 2026-10-17 and their times in UTC are: Lay Jokes: 16:36 to 16:39, Lay Patel: 16:36 to 16:37, and 16:37 to 16:39&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both of those accounts are mine, and I cut the line with the meeting link. The two time ranges for Lay Patel are the second account joining from my phone, leaving and coming back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0l3rur2065q6h41cmwz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0l3rur2065q6h41cmwz.png" alt="The Google Meet People panel for Falcon drone sync, showing two contributors" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The title in that question exists only in Calendar, and the attendance exists only in Meet. What joins them is one line. This connector writes the &lt;code&gt;Meeting link&lt;/code&gt; exactly the way my &lt;a href="https://github.com/topoteretes/cognee-community/pull/370" rel="noopener noreferrer"&gt;Google Calendar connector&lt;/a&gt; writes it, so when both sync into the same dataset, a conference and its calendar event end up linked in the graph.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt; is an open source memory layer for AI agents. This is my fourth connector for it during &lt;a href="https://www.wemakedevs.org/hackathons/mergetober" rel="noopener noreferrer"&gt;Mergetober&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; the &lt;a href="https://github.com/topoteretes/cognee-community/pull/416" rel="noopener noreferrer"&gt;PR&lt;/a&gt; is open and hasn't had a maintainer review yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want to use it for
&lt;/h2&gt;

&lt;p&gt;My test call was between two of my own accounts, so it hasn't been through a real meeting yet. I want to use it for hackathon and college project calls I organize, to answer "who actually joined, and when?" after the call is over. I also talk through ideas with a friend late at night, and that's where transcripts would really help, but my accounts are personal Gmail, so for me it's attendance only for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it syncs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every finished conference you organized,&lt;/strong&gt; with who joined and when. Meet only lists conferences where you were the organizer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The last 30 days at most.&lt;/strong&gt; Meet only keeps conferences for 30 days, so the first run can't reach further back. Sync regularly if you want to keep more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcripts, on Google Workspace editions that can transcribe.&lt;/strong&gt; On a personal Gmail account you still get attendance, just no transcripts. My live test was on personal accounts, so I've only verified the transcript path against a fake API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Signed-in participants come with a display name, no email, and all times are in UTC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Until the PR merges, install it from my branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;-b&lt;/span&gt; feature/google-meet-connector https://github.com/laypatel13/cognee-community
&lt;span class="nb"&gt;cd &lt;/span&gt;cognee-community/packages/connector/google-meet
uv &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;In the &lt;a href="https://console.cloud.google.com" rel="noopener noreferrer"&gt;Google Cloud Console&lt;/a&gt;, create a free project and enable the &lt;strong&gt;Google Meet REST API&lt;/strong&gt; and the &lt;strong&gt;Google Drive API&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Google Auth Platform&lt;/strong&gt;, set up the consent screen as &lt;strong&gt;External&lt;/strong&gt;, keep it in &lt;strong&gt;Testing&lt;/strong&gt;, and add your own account as a test user. Add only two read-only scopes: &lt;code&gt;meetings.space.readonly&lt;/code&gt;, and &lt;code&gt;drive.meet.readonly&lt;/code&gt;, which can only see Drive files that Meet created.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Clients&lt;/strong&gt;, create an OAuth client of type &lt;strong&gt;Desktop app&lt;/strong&gt;, download its JSON, and sign in once:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_MEET_CREDENTIALS_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/client_secret.json
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_MEET_TOKEN_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.cognee_google_meet_token.json
uv run python examples/login.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Set your &lt;code&gt;LLM_API_KEY&lt;/code&gt;, then sync and ask:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee_community_connector_google_meet&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;google_meet_source&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;google_meet_source&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;dataset_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_meetings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# makes re-runs incremental and lets deletes reach the graph
&lt;/span&gt;        &lt;span class="n"&gt;max_rows_per_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# removes the default cap of 50 rows
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Who joined yesterday&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s meetings?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_meetings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To get the question at the top of this post, sync Calendar into the same dataset too. Set up the &lt;code&gt;GOOGLE_CALENDAR_*&lt;/code&gt; variables from the Calendar connector, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv pip &lt;span class="nb"&gt;install&lt;/span&gt; ../google-calendar
uv run python examples/example.py &lt;span class="nt"&gt;--with-calendar&lt;/span&gt; &lt;span class="s2"&gt;"Who actually joined the Falcon drone sync and when?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What happened on a real call
&lt;/h2&gt;

&lt;p&gt;I used two personal Gmail accounts and a Calendar event called Falcon drone sync with a Meet link. One account started the call from the event, the other joined from a phone, left and rejoined, and the organizer ended the call. Gemini was the LLM and local fastembed did the embeddings. The &lt;a href="https://github.com/topoteretes/cognee-community/pull/416" rel="noopener noreferrer"&gt;PR&lt;/a&gt; has a video.&lt;/p&gt;

&lt;p&gt;The first sync picked up 6 conferences and 1 calendar event, and the second picked up nothing, as it should. That sounds like a lot for one proper call. Three were short calls from my earlier checks, and two were empty conferences on the Falcon link. Meet records one whenever the link is opened, even if nobody stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Details I cared about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Meet has no change feed.&lt;/strong&gt; So later runs filter on &lt;code&gt;start_time&lt;/code&gt;, from one hour before the newest finished conference, and skip what's already done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calls that aren't finished wait.&lt;/strong&gt; A running conference, or one whose transcript is still being written, stays in a pending list and is read again by id on each run. A transcript Google still hasn't written a day after the conference ended is given up on, so it doesn't cost a request forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A deleted transcript is forgotten, and Drive is how I know.&lt;/strong&gt; Meet keeps listing a transcript after its Google Doc is deleted. So the connector asks Drive whether the Doc still exists, and that's the whole reason for the &lt;code&gt;drive.meet.readonly&lt;/code&gt; scope. The transcript is forgotten and the attendance record stays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No surprise browser pop-ups.&lt;/strong&gt; Only the login script opens a browser. A sync with a missing or under-scoped token raises a clear error instead of waiting for a person who isn't there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It only reads.&lt;/strong&gt; Nothing is created or changed in Meet or Drive. Recordings are noted as yes or no and never downloaded, and smart notes and chat are never read. The conference text does go to your LLM provider during processing, like any document you add to cognee, so use a local model if that matters to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did differently this time
&lt;/h2&gt;

&lt;p&gt;I opened this PR on October 17. Three days later, the &lt;a href="https://github.com/topoteretes/cognee-community/pull/316" rel="noopener noreferrer"&gt;Dropbox&lt;/a&gt; and &lt;a href="https://github.com/topoteretes/cognee-community/pull/365" rel="noopener noreferrer"&gt;Outlook&lt;/a&gt; reviews landed, and a lot of what they found applied here too. So I didn't wait for someone to find it again. I went through those review notes and fixed what fit while this PR sat in the queue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retries moved out of the Google client.&lt;/strong&gt; Its own retries could sleep about a minute per request while cognee holds a staging lock, without asking if the sync should still be running. Now a 429, a 5xx or a dropped connection is retried after 1, 2 and 4 seconds from one 2 minute budget per run, and a 400 or 404 is never retried.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Waits are slept in 1 second steps,&lt;/strong&gt; with &lt;code&gt;check_active&lt;/code&gt; asked in between, so a revoked connection stops a long wait within a second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rows stream per conference,&lt;/strong&gt; so a first sync of 30 days keeps one conference in memory at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong option types are refused,&lt;/strong&gt; so &lt;code&gt;include_transcripts="false"&lt;/code&gt; no longer reads as True.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests went from 68 to 100, and I made sure they can fail: with the Drive check turned off, the forget test goes red.&lt;/p&gt;

&lt;p&gt;The earlier connectors taught me to ask the questions a reviewer would ask before anyone else has to. That's a better use of a maintainer's time than making them find the same bug in a fourth connector. I'm sure the real review will still find things I missed, but they should be new ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking back at the month
&lt;/h2&gt;

&lt;p&gt;This is the last post in my Mergetober series. Four connectors and a lot of review rounds in one month, and I only started coding in May. The hard part for me was sticking with it the whole month. I enjoyed it, but managing my time was rough, and I'm proud that I managed it somehow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PR: &lt;a href="https://github.com/topoteretes/cognee-community/pull/416" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee-community/pull/416&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;cognee: &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Built during &lt;a href="https://www.wemakedevs.org/hackathons/mergetober" rel="noopener noreferrer"&gt;Mergetober&lt;/a&gt; by WeMakeDevs x cognee&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mergetober</category>
      <category>cognee</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Removed doesn't mean deleted: syncing Outlook mail and meetings into AI memory</title>
      <dc:creator>Lay Patel</dc:creator>
      <pubDate>Fri, 30 Oct 2026 17:37:20 +0000</pubDate>
      <link>https://dev.to/laypatel13/removed-doesnt-mean-deleted-syncing-outlook-mail-and-meetings-into-ai-memory-318o</link>
      <guid>https://dev.to/laypatel13/removed-doesnt-mean-deleted-syncing-outlook-mail-and-meetings-into-ai-memory-318o</guid>
      <description>&lt;p&gt;Microsoft Graph tells you a calendar event was removed in two very different situations: someone deleted it, or it just slid out of the date window you asked about. Treat those the same and your AI memory forgets a meeting that still exists.&lt;/p&gt;

&lt;p&gt;Almost everything hard about this connector looks like that. Outlook says one thing, and the safe move is to check what it really means before you delete anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt; is an open source memory layer for AI agents. For &lt;a href="https://www.wemakedevs.org/hackathons/mergetober" rel="noopener noreferrer"&gt;Mergetober&lt;/a&gt; I built an &lt;a href="https://github.com/topoteretes/cognee-community/pull/365" rel="noopener noreferrer"&gt;Outlook connector&lt;/a&gt; that syncs mail folders and your calendar through Microsoft Graph into one memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; the PR is still open as I write this. It has been through three review rounds, and I pushed the fixes for the latest one on October 30.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want to use it for
&lt;/h2&gt;

&lt;p&gt;My college actually uses Outlook, so this is the connector that would really help me out: work, events, dates, all in one place I can ask.&lt;/p&gt;

&lt;p&gt;My live test was on my own Outlook.com account, but with test emails and meetings, so I haven't run it on real mail yet. One question I want to ask needs both sides: "what did this person email me before our last meeting?" Mail search finds the email and the calendar finds the meeting, but neither connects the two. To run it regularly I'll need an app registration, because a Graph Explorer token only lasts about an hour, and on a college account an admin may have to allow it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened on a real account
&lt;/h2&gt;

&lt;p&gt;I tested on a personal Outlook.com account with 4 test emails and 3 test events about a made-up drone project, one of them a daily recurring meeting. Gemini was the LLM and local fastembed did the embeddings.&lt;/p&gt;

&lt;p&gt;I asked who leads the project, how long the battery lasts, and how much money was spent:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Project lead: Meera Joshi. Battery duration: 55 minutes. Money spent: 12 lakh rupees on batteries, plus 8 lakh rupees approved for solar panels&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then, in Outlook, I moved the kickoff email to Archive (one of the synced folders), and deleted the Budget update email and the Budget meeting. Same question after the next sync:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Project Falcon is led by Meera Joshi, the battery lasts 55 minutes, and the provided context does not mention how much money was spent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Sync stats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;First sync&lt;/td&gt;
&lt;td&gt;mail 4, events 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Move kept as an update, deleted email and meeting forgotten&lt;/td&gt;
&lt;td&gt;mail 1, mail deleted 1, events deleted 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;No changes&lt;/td&gt;
&lt;td&gt;all 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Asked only about the deleted money: not in the context&lt;/td&gt;
&lt;td&gt;all 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The live test also caught a bug before I recorded it. The daily meeting came back as the series plus 3 bare occurrences with no title. Now a series is one document and the bare entries are skipped. The &lt;a href="https://github.com/topoteretes/cognee-community/pull/365" rel="noopener noreferrer"&gt;PR&lt;/a&gt; has a video of all four runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Until the PR merges, install it from my branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;-b&lt;/span&gt; feature/outlook-connector https://github.com/laypatel13/cognee-community
&lt;span class="nb"&gt;cd &lt;/span&gt;cognee-community/packages/connector/outlook
uv &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The quick way, no app registration.&lt;/strong&gt; This is how I ran my live test. Sign in to &lt;a href="https://developer.microsoft.com/graph/graph-explorer" rel="noopener noreferrer"&gt;Graph Explorer&lt;/a&gt;, consent to &lt;code&gt;Mail.Read&lt;/code&gt; and &lt;code&gt;Calendars.Read&lt;/code&gt;, and copy the token from its &lt;strong&gt;Access token&lt;/strong&gt; tab. It lasts about an hour and is never refreshed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OUTLOOK_ACCESS_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;token&amp;gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OUTLOOK_MAIL_FOLDERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;inbox,sentitems,archive
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LLM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your key&amp;gt;
uv run python examples/example.py &lt;span class="s2"&gt;"What are my recent emails and meetings about?"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The long-term way.&lt;/strong&gt; Register an app in the &lt;a href="https://entra.microsoft.com" rel="noopener noreferrer"&gt;Microsoft Entra admin center&lt;/a&gt; for any directory plus personal accounts, turn on public client flows, and add the delegated Graph permissions &lt;code&gt;User.Read&lt;/code&gt;, &lt;code&gt;Mail.Read&lt;/code&gt; and &lt;code&gt;Calendars.Read&lt;/code&gt;. Then sign in once with a device code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OUTLOOK_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;application &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OUTLOOK_TOKEN_CACHE_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.cognee_outlook_token_cache.json
uv run python examples/login.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To be upfront: I haven't run this path live. Registering an app on a personal account needs an Azure subscription, which I don't have as a student, so the device code login is only covered by unit tests.&lt;/p&gt;

&lt;p&gt;Once you're signed in, syncing looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee_community_connector_outlook&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;outlook_source&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;outlook_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;mail_folders&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentitems&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OUTLOOK_CLIENT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;token_cache_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OUTLOOK_TOKEN_CACHE_PATH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;dataset_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_outlook&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# makes re-runs incremental and lets deletes reach the graph
&lt;/span&gt;        &lt;span class="n"&gt;max_rows_per_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# removes the default cap of 50 rows
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What are my emails about?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_outlook&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only want headers, pass &lt;code&gt;include_mail_bodies=False&lt;/code&gt; and &lt;code&gt;Mail.ReadBasic&lt;/code&gt; is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting parts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Removed events are checked first.&lt;/strong&gt; When Graph reports an event as removed, the connector looks it up by id. A 404 forgets it. Anything else means it only left the window, which is 180 days back and ahead by default, so it stays.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moving mail is not deleting it.&lt;/strong&gt; Every request asks for immutable ids, so a message keeps its id when it moves, and the connector tracks which synced folders each message is in. Inbox to Archive, with both synced, is an update. Deleting moves mail to Deleted Items, which is never synced, so the message is in no synced folder and gets forgotten. The same goes for mail moved to any folder you don't sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Later runs only fetch changes.&lt;/strong&gt; Each mail folder and the calendar use Graph delta queries. The delta link only arrives on the last page, so it's saved once the whole round has finished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;People connect across mail and meetings.&lt;/strong&gt; Sender, recipients, organizer and attendees are written into every message and event, so cognee's entity extraction can link someone's emails to their meetings. That comes from the LLM reading the text, not from a hard identity mapping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors never delete.&lt;/strong&gt; Only a 404, a cancellation, or mail leaving every synced folder forgets anything. A Graph outage, throttling or one unreadable event removes nothing, and one failing folder doesn't stop the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It only reads.&lt;/strong&gt; It never sends, moves or changes anything. Attachments and Bcc are never read, and the logs hold counts only. Your mail text does go to your LLM provider during processing, like any document you add to cognee, so use a local model if that matters to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What three review rounds taught me
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/goran-radonic" rel="noopener noreferrer"&gt;goran-radonic&lt;/a&gt; reviewed this PR the same way as my Dropbox one: real dlt, real cognee, failures injected on purpose. The test count went from 65 on October 18 to 164 on October 30.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round one was 20 points.&lt;/strong&gt; Three of them could lose data or fail without a clear reason. Deleting a series reported every occurrence, and each one was looked up. A failing lookup could wait through &lt;code&gt;Retry-After&lt;/code&gt; with no limit: 20 events answering 503 with a 60 second wait, while cognee holds a staging lock. Now all the waits in a run share a 2 minute budget. For this round I reverted each of the 38 guards in the commit, one at a time, to check that a test went red.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round two found mail and meetings that could never be forgotten.&lt;/strong&gt; I stream rows page by page, but I only saved state at the end. If page 2 failed, the rows from page 1 still loaded, and the state didn't know about them. Delete one of those later and it stayed in memory for good, with no error anywhere. Yielded ids are now kept in the state even when a part fails.&lt;/p&gt;

&lt;p&gt;That round also caught a test that couldn't fail. Graph names all-day time zones the Windows way, like &lt;code&gt;Tonga Standard Time&lt;/code&gt;, and my test used &lt;code&gt;Pacific/Tongatapu&lt;/code&gt;, which Graph doesn't send. So an all-day event on November 8 could be written as November 7, and my test stayed green. The names are now mapped with &lt;code&gt;tzlocal&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It also showed that a short outage could forget live events. I counted a 5xx as a failed attempt, and after 3 attempts I forgot the event. So a 5xx stopped counting, and giving up now keeps the event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round three was caused by that fix.&lt;/strong&gt; If a 5xx doesn't count, it has no limit. A series master that kept answering 500 stayed in the retry list forever and held up the lookups behind it. Over six runs in a row, nothing moved. Now outages are counted per id, separately from real failures, an id in an outage goes after the others, masters and removals are looked up separately, and after 3 runs in a row the id is given up on and kept.&lt;/p&gt;

&lt;p&gt;What I took from this one: a fix is new code with its own failure modes. "This doesn't count as a failure" sounds like a safe rule until you ask what stops it. Every retry, wait and skip needs a limit, and every limit needs a decision about what happens when you reach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits so far
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only the primary calendar, since Graph v1.0 event delta covers that one.&lt;/li&gt;
&lt;li&gt;Attachments are flagged, not ingested.&lt;/li&gt;
&lt;li&gt;Subfolders of a selected folder are not synced.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PR: &lt;a href="https://github.com/topoteretes/cognee-community/pull/365" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee-community/pull/365&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;cognee: &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Built during &lt;a href="https://www.wemakedevs.org/hackathons/mergetober" rel="noopener noreferrer"&gt;Mergetober&lt;/a&gt; by WeMakeDevs x cognee&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mergetober</category>
      <category>cognee</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>My Google Calendar connector got merged. Then the maintainer fixed 8 things in it</title>
      <dc:creator>Lay Patel</dc:creator>
      <pubDate>Fri, 30 Oct 2026 17:37:00 +0000</pubDate>
      <link>https://dev.to/laypatel13/my-google-calendar-connector-got-merged-then-the-maintainer-fixed-8-things-in-it-4aen</link>
      <guid>https://dev.to/laypatel13/my-google-calendar-connector-got-merged-then-the-maintainer-fixed-8-things-in-it-4aen</guid>
      <description>&lt;p&gt;I filled a Google Calendar with test meetings for a made-up drone project, synced it into AI memory, and asked who leads the project, how long the battery lasts, and how much was spent on batteries.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Project Falcon is led by Meera Joshi, the battery lasts 55 minutes, and 12 lakh rupees were spent on batteries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I deleted the Budget meeting, and deleted one occurrence of the daily sync with "This event". I synced again and asked the same question.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Project Falcon is led by Meera Joshi, the battery lasts 55 minutes, and the provided context does not mention how much was spent on batteries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The money is gone, because it only lived in the deleted meeting. And deleting one occurrence didn't take the series with it. Asked what the daily sync covers, it still answered:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The daily sync covers the daily stand-up for Project Falcon, where the team tests drone navigation over the Pune test field&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt; is an open source memory layer for AI agents: it turns data into a knowledge graph you can query in plain language. This connector was my first merged PR of &lt;a href="https://www.wemakedevs.org/hackathons/mergetober" rel="noopener noreferrer"&gt;Mergetober&lt;/a&gt;. &lt;a href="https://github.com/topoteretes/cognee-community/pull/370" rel="noopener noreferrer"&gt;PR #370&lt;/a&gt; went into cognee-community on October 15, four days after I opened it.&lt;/p&gt;

&lt;p&gt;The same day, the maintainer &lt;a href="https://github.com/topoteretes/cognee/pull/5912" rel="noopener noreferrer"&gt;moved it into the cognee SDK&lt;/a&gt;. It now ships with cognee itself, and the SDK file credits PR #370 at the top. That port is the best part of this story, so I'll come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want to use it for
&lt;/h2&gt;

&lt;p&gt;My calendar has college and hackathon dates, and sometimes submission deadlines. So far I've only run this on the test meetings in this post, but on my own calendar I'd ask things like "what do I have coming up before my next hackathon?" or "how many days do I have left to finish this?" A calendar app shows me the days, but it can't answer that in one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In the &lt;a href="https://console.cloud.google.com" rel="noopener noreferrer"&gt;Google Cloud Console&lt;/a&gt;, create a free project and enable the &lt;strong&gt;Google Calendar API&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Google Auth Platform&lt;/strong&gt;, set up the consent screen as &lt;strong&gt;External&lt;/strong&gt;, keep it in &lt;strong&gt;Testing&lt;/strong&gt;, add your own account as a test user, and add only the &lt;code&gt;calendar.readonly&lt;/code&gt; scope.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Clients&lt;/strong&gt;, create an OAuth client of type &lt;strong&gt;Desktop app&lt;/strong&gt; and download its JSON.&lt;/li&gt;
&lt;li&gt;Install cognee with the Calendar extra and point it at that file:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"cognee[google-calendar]"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_CALENDAR_CREDENTIALS_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/client_secret.json
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GOOGLE_CALENDAR_TOKEN_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.cognee_google_calendar_token.json
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LLM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your key&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Run this. The first run opens a browser for consent, and later runs reuse the saved token:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee.tasks.ingestion.connectors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;google_calendar_source&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;google_calendar_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calendar_ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="n"&gt;dataset_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_calendar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# makes re-runs incremental and lets deletes reach the graph
&lt;/span&gt;        &lt;span class="n"&gt;max_rows_per_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# removes the default cap of 50 rows
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Who is in my Thursday meetings?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_calendar&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to know before you run it. The first run reads each calendar's whole history, because sync tokens don't allow a time window. And apps left in Testing get refresh tokens that expire after 7 days, so sign in again or publish the app for longer use.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened on a real calendar
&lt;/h2&gt;

&lt;p&gt;My live test used the community package on a real Google account, with Gemini as the LLM and local fastembed for embeddings. The calendar had 3 test events and a daily sync repeating 3 times.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcw08wwfe64cxggfzceuz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcw08wwfe64cxggfzceuz.png" alt="A Google Calendar week showing Project Falcon kickoff, Falcon daily sync on three days, Battery review and Budget meeting" width="800" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Sync stats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;First sync: the 3 events, the daily sync series, and one occurrence Google keeps as changed&lt;/td&gt;
&lt;td&gt;scanned 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Budget meeting and one daily sync occurrence forgotten, the series kept&lt;/td&gt;
&lt;td&gt;deleted 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;No changes&lt;/td&gt;
&lt;td&gt;all 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Asked about the deleted money and the daily sync: the money isn't in the context, the series is&lt;/td&gt;
&lt;td&gt;all 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://github.com/topoteretes/cognee-community/pull/370" rel="noopener noreferrer"&gt;PR&lt;/a&gt; has a video of all four runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a calendar is harder than a folder of documents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A series is one document.&lt;/strong&gt; Events are listed with &lt;code&gt;singleEvents&lt;/code&gt; off, so a recurring meeting is one document with its &lt;code&gt;RRULE&lt;/code&gt;. An occurrence you changed becomes its own document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One deleted occurrence isn't a deleted series.&lt;/strong&gt; Google sends back a cancelled entry for it, and only that occurrence is forgotten. Deleting the whole series forgets the series and its changed occurrences.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync tokens expire.&lt;/strong&gt; Later runs only fetch what changed. When Google answers &lt;code&gt;410 Gone&lt;/code&gt;, the calendar is listed again and compared against what's stored, so events deleted in the meantime are still forgotten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;People live in the text.&lt;/strong&gt; The calendar name, organizer and attendees with their responses are written into each event, so cognee's entity extraction can connect the same person across meetings. That comes from the LLM reading the text, not from a hard identity mapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only reads.&lt;/strong&gt; The only scope is &lt;code&gt;calendar.readonly&lt;/code&gt;, attachments are never read, and the saved state holds sync tokens and ids, not titles or descriptions. The event text does go to your LLM provider during processing, like any document you add to cognee, so use a local model if that matters to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 8 things I missed
&lt;/h2&gt;

&lt;p&gt;PR #370 had no review comments. It was merged as it was.&lt;/p&gt;

&lt;p&gt;Then the maintainer ported it to the SDK and fixed eight things along the way, so the SDK wouldn't ship them. The list is in &lt;a href="https://github.com/topoteretes/cognee/pull/5912" rel="noopener noreferrer"&gt;cognee#5912&lt;/a&gt;, and reading it taught me more than the merge did.&lt;/p&gt;

&lt;p&gt;Wrong data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All-day events came out a day too long, because Google's end date is exclusive.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;primary&lt;/code&gt; and the calendar's real id produced two sets of documents.&lt;/li&gt;
&lt;li&gt;Hidden calendars dropped out of the list, which changed the ids and names of everything under them.&lt;/li&gt;
&lt;li&gt;Attendees were written in whatever order Google returned them, so a reorder sent the event through LLM extraction again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Failure handling:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 404 on one calendar forgot it, even when the same run still saw it in the calendar list.&lt;/li&gt;
&lt;li&gt;A 403 with a rate-limit reason wasn't treated as a rate limit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;check_active&lt;/code&gt;, the callback that lets a host stop a sync, only ran once per sync. Now it runs before every API call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Privacy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calendar ids, which are usually email addresses, ended up in logs and error messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these showed up in my 35 tests or my live run. My tests even covered all-day events, and they still passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did with it
&lt;/h2&gt;

&lt;p&gt;I went through that list and applied what fit to the connectors I still had open. Dropbox got it on October 17 and Outlook on October 18: &lt;code&gt;check_active&lt;/code&gt; before every request, no ids in logs or errors, stable attendee order, all-day events ending on the right day. My Google Meet connector was built on the same layout.&lt;/p&gt;

&lt;p&gt;So the lesson from my first merged PR of Mergetober is that a merge doesn't mean your code is finished. When someone fixes your code, read the diff. It's a free review, and it's specific to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Community PR: &lt;a href="https://github.com/topoteretes/cognee-community/pull/370" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee-community/pull/370&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;SDK port: &lt;a href="https://github.com/topoteretes/cognee/pull/5912" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee/pull/5912&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;cognee: &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Built during &lt;a href="https://www.wemakedevs.org/hackathons/mergetober" rel="noopener noreferrer"&gt;Mergetober&lt;/a&gt; by WeMakeDevs x cognee&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mergetober</category>
      <category>cognee</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Forgetting is the hard part: giving an AI agent a memory of my Dropbox</title>
      <dc:creator>Lay Patel</dc:creator>
      <pubDate>Fri, 30 Oct 2026 17:36:41 +0000</pubDate>
      <link>https://dev.to/laypatel13/forgetting-is-the-hard-part-giving-an-ai-agent-a-memory-of-my-dropbox-1612</link>
      <guid>https://dev.to/laypatel13/forgetting-is-the-hard-part-giving-an-ai-agent-a-memory-of-my-dropbox-1612</guid>
      <description>&lt;p&gt;I asked my AI memory three things about a project: who leads it, how long the drone battery lasts, and how much was spent on batteries.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Project Falcon is led by Meera Joshi, the drone battery lasts 55 minutes, and 12 lakh rupees was spent on batteries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then I opened Dropbox, deleted a PDF and a folder, and moved one file somewhere else. I synced again and asked the same question.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Meera Joshi leads Project Falcon. The provided context does not contain information about how long the drone battery lasts or how much was spent on batteries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That second answer is what I spent most of October on. Getting files into memory is the easy part. The hard part is getting them out at the right moment, and never by accident.&lt;/p&gt;

&lt;p&gt;(Project Falcon is made up. I wrote those files for testing.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;cognee&lt;/a&gt; is an open source memory layer for AI agents. You hand it data and it builds a knowledge graph plus vector search on top, so you can ask questions across everything. For &lt;a href="https://www.wemakedevs.org/hackathons/mergetober" rel="noopener noreferrer"&gt;Mergetober&lt;/a&gt; I built a Dropbox connector for it, and it was &lt;a href="https://github.com/topoteretes/cognee-community/pull/316" rel="noopener noreferrer"&gt;merged into cognee-community&lt;/a&gt; on October 30.&lt;/p&gt;

&lt;p&gt;A bit about me: I'm studying CSE (AI/ML), I started coding and using git in May 2026, and I had some open source experience before this. I joined Mergetober because it was something related to AI, and I mean, you can give it memory. Who wouldn't love that?&lt;/p&gt;

&lt;h2&gt;
  
  
  What I want to use it for
&lt;/h2&gt;

&lt;p&gt;So far I've only run this on the test files below, not on my own, and honestly I rarely use Dropbox. If I sync something real, it'll be my college technical reports, like FASTag and smart dustbin. They're PDFs, so the connector can read them, and a question like "which of my reports use RFID, and what did I say about it?" needs an answer across all of them, which a plain file search won't give me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The package isn't on PyPI yet, so install it from the repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/topoteretes/cognee-community
&lt;span class="nb"&gt;cd &lt;/span&gt;cognee-community/packages/connector/dropbox
uv &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;a href="https://www.dropbox.com/developers/apps" rel="noopener noreferrer"&gt;Create a Dropbox app&lt;/a&gt; with &lt;strong&gt;Scoped access&lt;/strong&gt;. Pick &lt;strong&gt;App folder&lt;/strong&gt; if you only want it to see &lt;code&gt;Apps/&amp;lt;your app&amp;gt;/&lt;/code&gt;, or &lt;strong&gt;Full Dropbox&lt;/strong&gt; if you want everything.&lt;/li&gt;
&lt;li&gt;In the &lt;strong&gt;Permissions&lt;/strong&gt; tab, enable &lt;code&gt;files.metadata.read&lt;/code&gt; and &lt;code&gt;files.content.read&lt;/code&gt;, then click &lt;strong&gt;Submit&lt;/strong&gt;. Nothing else is needed.&lt;/li&gt;
&lt;li&gt;Copy the &lt;strong&gt;App key&lt;/strong&gt; and get a refresh token once. This uses the PKCE flow, so there's no app secret and the token doesn't expire:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DROPBOX_APP_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;app key&amp;gt; uv run python examples/get_refresh_token.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Set &lt;code&gt;DROPBOX_APP_KEY&lt;/code&gt;, &lt;code&gt;DROPBOX_REFRESH_TOKEN&lt;/code&gt; and your &lt;code&gt;LLM_API_KEY&lt;/code&gt;, then save this as &lt;code&gt;sync.py&lt;/code&gt; and run it with &lt;code&gt;uv run python sync.py&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cognee_community_connector_dropbox&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dropbox_source&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;dropbox_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;folder_paths&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/Notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;refresh_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROPBOX_REFRESH_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;app_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROPBOX_APP_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;dataset_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_dropbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;write_disposition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# makes re-runs incremental and lets deletes reach the graph
&lt;/span&gt;        &lt;span class="n"&gt;max_rows_per_table&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# removes the default cap of 50 rows
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;cognee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;What are my notes about?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;datasets&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my_dropbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it again whenever you want to sync. It reads txt, md, csv and PDF files and exports Paper docs. Anything else is skipped and counted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened on a real Dropbox
&lt;/h2&gt;

&lt;p&gt;I tested on a free Dropbox account with an App folder app, Gemini as the LLM and local fastembed for embeddings. These are the four runs behind the answers at the top:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;th&gt;Sync stats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;First sync of 5 files, &lt;code&gt;logo.png&lt;/code&gt; skipped as unsupported&lt;/td&gt;
&lt;td&gt;scanned 5, skipped 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;After deleting &lt;code&gt;budget-report.pdf&lt;/code&gt; and the Meetings folder, and moving &lt;code&gt;project-falcon.md&lt;/code&gt; into a new Archive folder&lt;/td&gt;
&lt;td&gt;scanned 1, deleted 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;No changes, and the access token refreshed by itself&lt;/td&gt;
&lt;td&gt;scanned 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Asked only about facts from the deleted files&lt;/td&gt;
&lt;td&gt;scanned 0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The battery facts lived only in the deleted files, so they're gone. Meera Joshi survives because that name was also in the moved file, which was updated in place and not forgotten.&lt;/p&gt;

&lt;p&gt;Here's run 4. cognee says the facts are no longer in its context, and the sync stats are all zero:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fztgf3hsrshzcaqc5ssdu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fztgf3hsrshzcaqc5ssdu.png" alt="Terminal output of run 4: sync stats are all zero and recall says the context has no information about the battery or the spending" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/topoteretes/cognee-community/pull/316" rel="noopener noreferrer"&gt;PR&lt;/a&gt; has a video of runs 1 and 2, with the changes being made in Dropbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things that make forgetting hard
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Only what changed is fetched.&lt;/strong&gt; The connector saves Dropbox's &lt;code&gt;list_folder&lt;/code&gt; cursor, one per folder, so the next run gets just the changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletes arrive without an id.&lt;/strong&gt; Dropbox reports a deleted file by path, but memory is keyed by id. So the connector keeps a path to id index and turns deleted paths back into ids. A deleted folder forgets everything under it: &lt;code&gt;/notes/&lt;/code&gt; matches, &lt;code&gt;/notes2&lt;/code&gt; doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A move looks like a delete.&lt;/strong&gt; A rename shows up as a deleted old path plus the same id at a new path. Deletes are held until every folder has been read, so a moved file is updated in place. It isn't forgotten and learned again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A passing error never deletes.&lt;/strong&gt; A network error, a 5xx, a rate limit, a bad token, or a folder Dropbox reports as locked or restricted removes nothing. Data only goes when Dropbox says it's gone, when a file leaves the synced folders, or when a stored file turns unreadable for good: skipped now, or failed on 3 runs in a row. Old text shouldn't stay in memory as if it were still current.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the review taught me
&lt;/h2&gt;

&lt;p&gt;I opened the PR on October 8 with 35 tests, an in-memory fake of Dropbox, and the live test above. It merged 22 days later with 125 tests. Most of that gap came from review. &lt;a href="https://github.com/goran-radonic" rel="noopener noreferrer"&gt;goran-radonic&lt;/a&gt; ran my code through real dlt and cognee and injected failures on purpose, round after round.&lt;/p&gt;

&lt;p&gt;Four of the things that came up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A file written over an old path.&lt;/strong&gt; Delete a file and create a new one at the same path, and between two syncs Dropbox may report only the new file. My index overwrote the old id and nothing ever forgot it. Through real cognee, the dataset ended up with 2 documents and both versions in the graph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A file edited down to nothing.&lt;/strong&gt; An emptied file gets skipped, and a skipped file emitted nothing, so the old text stayed in memory. The same went for a file that grew past the size limit or got renamed to a type I don't read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The third sync after a folder delete.&lt;/strong&gt; The folder was forgotten correctly, but I lost track of it. The next run read it as never synced and raised a folder not found error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One locked folder.&lt;/strong&gt; This was the last round, on the day it merged, and it's the comment that stuck with me most. I was treating every lookup error on a synced folder as "the folder is gone". That included &lt;code&gt;locked&lt;/code&gt;, which is temporary, and &lt;code&gt;other&lt;/code&gt;, the SDK's catch-all. One injected &lt;code&gt;locked&lt;/code&gt; error made the run forget that folder's files, and the next run learned them again, paying for LLM extraction twice. It also made my own README line, "A passing error never deletes", false. Now only &lt;code&gt;not_found&lt;/code&gt; and &lt;code&gt;not_folder&lt;/code&gt; forget a folder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The other half of the review was about my tests. The maintainer would remove code and check whether anything went red. In the first round, a whole filter could be deleted and all 56 tests stayed green. That's mutation testing. I already checked that each new test failed without its fix, but I hadn't asked the same of the code I'd written earlier. From then on, I reran each mutation myself before pushing.&lt;/p&gt;

&lt;p&gt;What I took from it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A fake only knows what you told it.&lt;/strong&gt; Mine passed because it shared my assumptions. As I said in the PR, the real pipeline "found things my fake never would".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A green test means nothing until you've seen it red.&lt;/strong&gt; Remove the fix, watch the test fail, put the fix back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask "what if this fails halfway?" about every line that touches state.&lt;/strong&gt; And never write "any error means gone". List the errors that really mean gone, and let everything else stop the run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I've carried all three into my other connectors. Thanks to goran-radonic for the time put into these rounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits so far
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Team spaces aren't supported yet.&lt;/li&gt;
&lt;li&gt;Paper export is unit tested, but I haven't tried it on a live Paper doc.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;PR: &lt;a href="https://github.com/topoteretes/cognee-community/pull/316" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee-community/pull/316&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;cognee: &lt;a href="https://github.com/topoteretes/cognee" rel="noopener noreferrer"&gt;https://github.com/topoteretes/cognee&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Built during &lt;a href="https://www.wemakedevs.org/hackathons/mergetober" rel="noopener noreferrer"&gt;Mergetober&lt;/a&gt; by WeMakeDevs x cognee&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>mergetober</category>
      <category>cognee</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>One phished login, 18 poisoned packages, 74 minutes of silence: so I built a graph that asks who can poison your app</title>
      <dc:creator>Lay Patel</dc:creator>
      <pubDate>Sat, 17 Oct 2026 12:15:52 +0000</pubDate>
      <link>https://dev.to/laypatel13/one-phished-login-18-poisoned-packages-74-minutes-of-silence-so-i-built-a-graph-that-asks-who-4dk6</link>
      <guid>https://dev.to/laypatel13/one-phished-login-18-poisoned-packages-74-minutes-of-silence-so-i-built-a-graph-that-asks-who-4dk6</guid>
      <description>&lt;p&gt;On &lt;strong&gt;September 8, 2025&lt;/strong&gt;, someone phished the npm login of one well-known maintainer, &lt;code&gt;@qix&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Within minutes, poisoned versions of &lt;strong&gt;18 packages&lt;/strong&gt; went out: &lt;code&gt;debug&lt;/code&gt;, &lt;code&gt;chalk&lt;/code&gt;, &lt;code&gt;ansi-styles&lt;/code&gt;, &lt;code&gt;strip-ansi&lt;/code&gt;… packages with around &lt;strong&gt;2 billion downloads a week&lt;/strong&gt; between them, sitting deep inside millions of apps.&lt;/p&gt;

&lt;p&gt;The first public advisory showed up at OSV &lt;strong&gt;74 minutes later&lt;/strong&gt;. Until then, every scanner that works from advisories said the same thing: &lt;em&gt;all clear&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That stuck with me. Those scanners weren't broken. They were answering the wrong question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question nobody was asking
&lt;/h2&gt;

&lt;p&gt;Security tools ask &lt;em&gt;"is this version known to be bad?"&lt;/em&gt; That only works &lt;strong&gt;after&lt;/strong&gt; someone has noticed, investigated and published an advisory.&lt;/p&gt;

&lt;p&gt;But the danger on Sept 8 wasn't one package. It was &lt;strong&gt;one person's account&lt;/strong&gt;. The better question is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Who can push code into my app, and how much of it can each of them reach?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No advisory database can answer that, because the answer isn't a fact about a package. It's the &lt;strong&gt;shape&lt;/strong&gt; of your dependencies: you → your 90 direct dependencies → a thousand hidden ones → the hundreds of people who can publish them.&lt;/p&gt;

&lt;p&gt;That's a graph question. So for &lt;a href="https://www.wemakedevs.org/hackathons/falkordb" rel="noopener noreferrer"&gt;Graph Hacks&lt;/a&gt; I built &lt;strong&gt;&lt;a href="https://github.com/laypatel13/chokepoint" rel="noopener noreferrer"&gt;Chokepoint&lt;/a&gt;&lt;/strong&gt; on &lt;strong&gt;FalkorDB&lt;/strong&gt;.&lt;/p&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://chokepoint.onrender.com" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;chokepoint.onrender.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  The graph
&lt;/h2&gt;

&lt;p&gt;Chokepoint reads your &lt;code&gt;package-lock.json&lt;/code&gt;, asks npm who maintains each package and asks OSV about advisories. Then it builds one FalkorDB graph per project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Project&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:DEPENDS_ON&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dev&lt;/span&gt;&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:DEPENDS_ON&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="n"&gt;range&lt;/span&gt;&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:VERSION_OF&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Package&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Maintainer&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:MAINTAINS&lt;/span&gt; &lt;span class="ss"&gt;{&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;at_install&lt;/span&gt;&lt;span class="ss"&gt;}]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Package&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Maintainer&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:PUBLISHED&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Advisory&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:AFFECTS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the replay I used &lt;strong&gt;webpack-dev-server's real lockfile from Aug 25, 2025&lt;/strong&gt;, two weeks before the attack: &lt;strong&gt;1,423 versions, 90 direct dependencies, 685 npm accounts, 3,108 links&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;(One rule I kept from day one: maintainers are stored by &lt;strong&gt;npm username only&lt;/strong&gt;. Emails are dropped the moment they're read.)&lt;/p&gt;

&lt;h2&gt;
  
  
  "Who could poison me?" in one query
&lt;/h2&gt;

&lt;p&gt;Here's the heart of it. For every npm account: how many of my direct dependencies eventually pull in a package that account can publish?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;p:&lt;/span&gt;&lt;span class="n"&gt;Project&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;r:&lt;/span&gt;&lt;span class="n"&gt;DEPENDS_ON&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;d:&lt;/span&gt;&lt;span class="n"&gt;Version&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;CALL&lt;/span&gt; &lt;span class="n"&gt;algo.BFS&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;YIELD&lt;/span&gt; &lt;span class="n"&gt;nodes&lt;/span&gt;
&lt;span class="k"&gt;UNWIND&lt;/span&gt; &lt;span class="n"&gt;nodes&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pkg&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pkg&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="py"&gt;pkg:&lt;/span&gt;&lt;span class="n"&gt;Package&lt;/span&gt;
&lt;span class="k"&gt;MATCH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pkg&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="py"&gt;m:&lt;/span&gt;&lt;span class="n"&gt;MAINTAINS&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="py"&gt;u:&lt;/span&gt;&lt;span class="n"&gt;Maintainer&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;$when&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'now'&lt;/span&gt; &lt;span class="ow"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m.current&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="n"&gt;OR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;$when&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'now'&lt;/span&gt; &lt;span class="ow"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m.at_install&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="n"&gt;u.username&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;maintainer&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
       &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reach&lt;/span&gt;&lt;span class="ss"&gt;,&lt;/span&gt;
       &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;r.dev&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;prod_reach&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the replay project, &lt;code&gt;@qix&lt;/code&gt; comes out at &lt;strong&gt;43 of 90&lt;/strong&gt; direct dependencies. One phished login could reach almost half the app.&lt;/p&gt;

&lt;p&gt;Ties are broken with &lt;strong&gt;&lt;code&gt;algo.betweenness&lt;/code&gt;&lt;/strong&gt; (accounts sitting on the bridges many paths pass through) and &lt;strong&gt;&lt;code&gt;algo.pageRank&lt;/code&gt;&lt;/strong&gt; (how influential the versions they control are).&lt;/p&gt;

&lt;h3&gt;
  
  
  The lesson that cost me an evening: BFS, not &lt;code&gt;[:DEPENDS_ON*]&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;My first version used a variable-length pattern, &lt;code&gt;(d)-[:DEPENDS_ON*]-&amp;gt;(pkg)&lt;/code&gt;. It looked elegant and &lt;strong&gt;timed out&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A variable-length match walks every possible &lt;em&gt;path&lt;/em&gt;, and real dependency graphs have exponentially many of them (diamonds everywhere: &lt;code&gt;a → b → d&lt;/code&gt; and &lt;code&gt;a → c → d&lt;/code&gt;…). &lt;code&gt;algo.BFS&lt;/code&gt; visits each node &lt;strong&gt;once&lt;/strong&gt;. Same answer, and it actually returns.&lt;/p&gt;

&lt;p&gt;The opposite turned out to be true for "how many things depend on this one version?": a single walk backwards with &lt;code&gt;count(DISTINCT …)&lt;/code&gt; was about 10× faster than a BFS from every node. Graph work is a lot of measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet Nightjar 🐦
&lt;/h2&gt;

&lt;p&gt;Knowing who &lt;em&gt;could&lt;/em&gt; poison you is half the story. The other half is noticing when someone &lt;em&gt;does&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nightjar&lt;/strong&gt; is Chokepoint's watcher agent (a nightjar is a night bird with huge eyes that hides in plain sight). Every minute it looks for new releases of your app's packages, and for each one it runs a loop:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;watch → investigate (in the graph) → decide → act → remember&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The decision is &lt;strong&gt;plain rules, no AI&lt;/strong&gt;, because a security verdict should be explainable and checkable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Points&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A fresh install would take it (inside your declared ranges)&lt;/td&gt;
&lt;td&gt;+3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A burst: 3+ releases from one account within 10 minutes&lt;/td&gt;
&lt;td&gt;+3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A new install script&lt;/td&gt;
&lt;td&gt;+3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publisher isn't in the package's maintainer list&lt;/td&gt;
&lt;td&gt;+3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provenance dropped&lt;/td&gt;
&lt;td&gt;+2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publisher reaches much of your app&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The account already triggered alerts (memory)&lt;/td&gt;
&lt;td&gt;+1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;3 points is a &lt;strong&gt;WATCH&lt;/strong&gt;, 6 an &lt;strong&gt;ALERT&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Replaying npm's real publish times from Sept 8:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;13:12:10&lt;/strong&gt; &lt;code&gt;ansi-styles@6.2.2&lt;/code&gt;: WATCH&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;13:12:39&lt;/strong&gt; &lt;code&gt;debug@4.4.2&lt;/code&gt;: WATCH&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;13:13:05&lt;/strong&gt; &lt;code&gt;chalk@5.6.1&lt;/code&gt;: &lt;strong&gt;ALERT&lt;/strong&gt;. Inside 6 of the app's ranges (+3), the &lt;strong&gt;third release from @qix in 54 seconds&lt;/strong&gt; (+3), from an account reaching 43 of 90 dependencies (+1). Score 7.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first advisory arrived at &lt;strong&gt;14:26&lt;/strong&gt;. Nightjar would have raised its alert &lt;strong&gt;73 minutes earlier&lt;/strong&gt;, without knowing anything about malware: just from the shape of the graph and the rhythm of the releases.&lt;/p&gt;

&lt;p&gt;And on an ALERT it &lt;strong&gt;acts&lt;/strong&gt;: it pins the version your lockfile already had (only for the poisoned release) under &lt;code&gt;overrides&lt;/code&gt; in &lt;code&gt;package.json&lt;/code&gt;, can open a fix PR on your repo, and notify you on the desktop, Slack or Discord.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5tbr3e0ychcosnrhy5w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5tbr3e0ychcosnrhy5w.png" alt="chokepoint nightjar --drill --act in a terminal" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory, in the graph too
&lt;/h2&gt;

&lt;p&gt;This was the Track 02 part, and my favourite design decision: &lt;strong&gt;Nightjar's memory is a FalkorDB graph of its own&lt;/strong&gt;, one per project (&lt;code&gt;memory:&amp;lt;project&amp;gt;&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cypher"&gt;&lt;code&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:ABOUT&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Package&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:PUBLISHED_BY&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Maintainer&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="ss"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;:BASED_ON&lt;/span&gt;&lt;span class="ss"&gt;]&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="ss"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="ss"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It lasts:&lt;/strong&gt; rebuilding a project's graph never touches its memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It changes:&lt;/strong&gt; open alerts are re-checked against OSV every cycle and move to &lt;strong&gt;CONFIRMED&lt;/strong&gt; or &lt;strong&gt;CLEARED&lt;/strong&gt;, each move a new &lt;code&gt;Decision&lt;/code&gt;, so the history stays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It feeds the next decision:&lt;/strong&gt; earlier alerts on an account raise the score of its next release. On Sept 8, every release after chalk carried &lt;code&gt;@qix already triggered N alert(s)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's shared:&lt;/strong&gt; Nightjar writes it; a second agent reads it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Any AI can use it: MCP
&lt;/h2&gt;

&lt;p&gt;Every question Chokepoint answers is a function in one file, &lt;code&gt;tools.py&lt;/code&gt;, and the same functions are served by an &lt;strong&gt;MCP server&lt;/strong&gt;. The website's "Ask" box is an MCP client of that server: your LLM picks a tool from the list, the call goes through MCP, and &lt;strong&gt;the graph computes the answer&lt;/strong&gt;. The LLM only translates it into plain words, and it never writes Cypher.&lt;/p&gt;

&lt;p&gt;So "what did Nightjar flag?" picks the &lt;code&gt;recall&lt;/code&gt; tool, reads Nightjar's memory graph and lights up every flagged package on the graph. No LLM? Keyword rules pick the tool instead. Nothing in Chokepoint &lt;em&gt;needs&lt;/em&gt; AI.&lt;/p&gt;

&lt;p&gt;(A bug I found while recording the demo: my MCP server stripped the "what to light up" field from every result to keep it lean for outside clients, so the website's own Ask answers never lit up the graph. Fixed by letting Chokepoint's own agent ask for it. Recording a demo is the best QA I know.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Replay vs the real thing
&lt;/h2&gt;

&lt;p&gt;The website is a &lt;strong&gt;replay&lt;/strong&gt; of Sept 8 on real data: npm's real publish times, OSV's real advisory times, a real lockfile. At the end it even opens a &lt;strong&gt;real pull request&lt;/strong&gt; on a sandbox fork, and Nightjar leaves a note for each visitor.&lt;/p&gt;

&lt;p&gt;But the real product runs on &lt;strong&gt;your&lt;/strong&gt; machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--python&lt;/span&gt; 3.12 ckpoint
&lt;span class="nb"&gt;cd &lt;/span&gt;your-app
chokepoint            &lt;span class="c"&gt;# builds the graph from package-lock.json&lt;/span&gt;
chokepoint &lt;span class="nb"&gt;who&lt;/span&gt;        &lt;span class="c"&gt;# who can poison you, ranked by reach&lt;/span&gt;
chokepoint check debug@4.4.2
chokepoint nightjar &lt;span class="nt"&gt;--drill&lt;/span&gt; &lt;span class="nt"&gt;--act&lt;/span&gt;   &lt;span class="c"&gt;# the real Sept 8 releases vs YOUR app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It uses an embedded FalkorDB, so there's no Docker, no account and no server, and nothing leaves your laptop. If you plug in an LLM or a GitHub token, they stay on your machine too. That one mattered to me more than any feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself on day one
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ask the question your tools can't.&lt;/strong&gt; The interesting security question wasn't "is it bad?" but "who could make it bad?", and only a graph could answer it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the graph reason; keep the AI as a translator.&lt;/strong&gt; Explainable rules for the verdict, Cypher for the facts, the LLM for the words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your traversals.&lt;/strong&gt; &lt;code&gt;[:DEPENDS_ON*]&lt;/code&gt; vs &lt;code&gt;algo.BFS&lt;/code&gt; was the difference between a timeout and an instant answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory belongs in the graph.&lt;/strong&gt; Alerts, decisions and accounts are relationships, so storing them as a graph made "has this account done this before?" a one-hop question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record your demo early.&lt;/strong&gt; It found three real bugs for me, including one in the published CLI that only showed up on an older Typer version.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;🌐 &lt;strong&gt;Live replay:&lt;/strong&gt; &lt;a href="https://chokepoint.onrender.com" rel="noopener noreferrer"&gt;chokepoint.onrender.com&lt;/a&gt; (free tier, so give it a few seconds to wake up)&lt;/li&gt;
&lt;li&gt;🎬 &lt;strong&gt;Demo video:&lt;/strong&gt; &lt;a href="https://drive.google.com/file/d/1DY8HlSAMpVTsf3D6Liw6TRd3d2U3ecUC/view?usp=sharing" rel="noopener noreferrer"&gt;watch the 5-minute demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💻 &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/laypatel13/chokepoint" rel="noopener noreferrer"&gt;github.com/laypatel13/chokepoint&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;CLI:&lt;/strong&gt; &lt;code&gt;pipx install --python 3.12 ckpoint&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;@qix&lt;/code&gt; didn't do anything wrong: their account was phished, and it could have been anyone's. The point isn't blame. It's knowing, &lt;strong&gt;before&lt;/strong&gt; it happens, how much of your app sits behind one login.&lt;/p&gt;

&lt;p&gt;Built for &lt;strong&gt;Graph Hacks&lt;/strong&gt; (WeMakeDevs × FalkorDB), Tracks 01 + 02.&lt;/p&gt;

</description>
      <category>falkordb</category>
      <category>security</category>
      <category>npm</category>
      <category>ai</category>
    </item>
    <item>
      <title>SQL Doctor: An Offline Tutor That Explains PostgreSQL Errors to My DBMS Lab Classmates</title>
      <dc:creator>Lay Patel</dc:creator>
      <pubDate>Sun, 04 Oct 2026 16:12:41 +0000</pubDate>
      <link>https://dev.to/laypatel13/sql-doctor-an-offline-tutor-that-explains-postgresql-errors-to-my-dbms-lab-classmates-3bdn</link>
      <guid>https://dev.to/laypatel13/sql-doctor-an-offline-tutor-that-explains-postgresql-errors-to-my-dbms-lab-classmates-3bdn</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;This semester I'm taking a DBMS lab where we write PostgreSQL queries every week. Writing the queries isn't the hard part. Understanding the errors is.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;column "instructor.name" must appear in the GROUP BY clause or be used in an aggregate function&lt;/code&gt; makes perfect sense once you know SQL. A few weeks into your first database course, it reads like a riddle. So I built something for my friend Jay Patel and my lab classmates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQL Doctor&lt;/strong&gt; is a small web app. You paste the query that failed and the error PostgreSQL printed, and it tells you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;What went wrong&lt;/strong&gt;, in one or two plain sentences&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where&lt;/strong&gt;, quoting the exact part of the query that broke&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fixed query&lt;/strong&gt;, changing only what's needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tip&lt;/strong&gt; so the same mistake doesn't happen again&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It can explain in English, Hinglish or Gujarati, because that's how my classmates and I actually talk. You can also paste your &lt;code&gt;CREATE TABLE&lt;/code&gt; statements for more accurate answers, and there are four built-in examples of the mistakes we make most often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Try the interface at &lt;a href="https://sql-doctor.onrender.com/" rel="noopener noreferrer"&gt;sql-doctor.onrender.com&lt;/a&gt;. The hosted copy is a preview of the interface. The real answers come from Gemma running locally on your own laptop, which is the whole point, so clone the repo below to run it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/laypatel13" rel="noopener noreferrer"&gt;
        laypatel13
      &lt;/a&gt; / &lt;a href="https://github.com/laypatel13/sql-doctor" rel="noopener noreferrer"&gt;
        sql-doctor
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;SQL Doctor&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Paste a PostgreSQL query that failed and the error it gave you. SQL Doctor explains what went wrong in plain words, points to the exact part of the query that broke, and shows the fixed query. It can explain in English, Hinglish or Gujarati.&lt;/p&gt;
&lt;p&gt;I built it for my DBMS lab classmates, who lose a lot of lab time staring at errors like &lt;code&gt;column "x" must appear in the GROUP BY clause&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;It runs entirely on your own laptop with &lt;strong&gt;Gemma 4&lt;/strong&gt;, an open-weight model, through &lt;strong&gt;Ollama&lt;/strong&gt;. Your queries never leave your machine, it works without internet, and it costs nothing to run.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Run it&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Install &lt;a href="https://ollama.com" rel="nofollow noopener noreferrer"&gt;Ollama&lt;/a&gt; and download the model:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;ollama pull gemma4:e4b&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;On a slower laptop, use &lt;code&gt;gemma4:e2b&lt;/code&gt; instead and change the model name in the app's sidebar.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Install the Python packages:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;python -m venv .venv
&lt;span class="pl-c1"&gt;source&lt;/span&gt; .venv/bin/activate      &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows: .venv\Scripts\activate&lt;/span&gt;
pip install&lt;/pre&gt;…
&lt;/div&gt;
&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/laypatel13/sql-doctor" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;SQL Doctor runs entirely on the laptop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gemma 4 (&lt;code&gt;gemma4:e4b&lt;/code&gt;)&lt;/strong&gt; does the explaining. It's an open-weight model small enough to run on an 8 GB laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama&lt;/strong&gt; serves the model locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streamlit&lt;/strong&gt; is the interface, about 300 lines of Python in a single file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the thinking went into the system prompt. It forces every answer into the same four sections, so answers are consistent and easy to skim. It runs at a low temperature so the fixes stay steady. And it has two rules I care about most: never invent tables or columns the student didn't mention, and say "I'm not sure" instead of guessing. A tutor that confidently gives you a wrong fix is worse than no tutor.&lt;/p&gt;

&lt;p&gt;The answer streams in as Gemma writes it. Once it's done, the sections turn into small badges: red for what broke, green for the fix. The design reuses the theme from Netra, another project of mine: neutral greys everywhere, blue only for things you can click, and color only where it carries meaning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;For a student tool, open models are the whole point.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's free.&lt;/strong&gt; None of my classmates is going to put a credit card on an API account to debug a lab query. Gemma runs locally at zero cost per question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works offline.&lt;/strong&gt; Once the model is downloaded, it doesn't need the internet at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Our work stays on our laptops.&lt;/strong&gt; Lab assignments never get sent to someone else's server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It fits the hardware.&lt;/strong&gt; If a friend's laptop is older, they can switch to &lt;code&gt;gemma4:e2b&lt;/code&gt; in the settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A closed API would have meant keys, usage limits and a bill, and I couldn't hand it to a friend without handing over my key too. With Gemma and Ollama, sharing it is just &lt;code&gt;git clone&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Jay Said
&lt;/h2&gt;

&lt;p&gt;I showed SQL Doctor to Jay, and his reply was one word: "Crazzy! 🔥"&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built this with help from Claude, which helped me scaffold the Streamlit app, shape the system prompt and restyle it with my Netra theme. I then tested it on errors from my own lab work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best Use of Gemma&lt;/strong&gt;: SQL Doctor runs on Gemma 4 locally through Ollama, and Gemma does all of the explaining.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
    </item>
  </channel>
</rss>
